added first code
This commit is contained in:
commit
72910db36e
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module fitnestracker
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
696
main.go
Normal file
696
main.go
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
indexTmpl *template.Template
|
||||
listenAddr = ":8080"
|
||||
databaseFile = "fitness.db"
|
||||
)
|
||||
|
||||
type SetRow struct {
|
||||
ID int
|
||||
Exercise string
|
||||
Reps int
|
||||
Weight float64
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
|
||||
// Open SQLite DB
|
||||
db, err = sql.Open("sqlite3", databaseFile)
|
||||
if err != nil {
|
||||
log.Fatal("open db:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := initDB(); err != nil {
|
||||
log.Fatal("init db:", err)
|
||||
}
|
||||
|
||||
// Parse template with helper funcs
|
||||
indexTmpl = template.Must(template.New("index").Funcs(template.FuncMap{
|
||||
"add": func(a, b int) int { return a + b },
|
||||
}).Parse(indexHTML))
|
||||
|
||||
// Routes
|
||||
http.HandleFunc("/", handleIndex)
|
||||
http.HandleFunc("/start", handleStartWorkout)
|
||||
http.HandleFunc("/add-set", handleAddSet)
|
||||
|
||||
// APIs
|
||||
http.HandleFunc("/api/rest-times", handleRestTimes)
|
||||
http.HandleFunc("/api/volume", handleVolumeStats)
|
||||
|
||||
log.Printf("Listening on %s ...", listenAddr)
|
||||
if err := http.ListenAndServe(listenAddr, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func initDB() error {
|
||||
// Very simple schema
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS workouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workout_id INTEGER NOT NULL,
|
||||
exercise TEXT NOT NULL,
|
||||
reps INTEGER NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
FOREIGN KEY(workout_id) REFERENCES workouts(id)
|
||||
);
|
||||
`
|
||||
_, err := db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
latestID, err := getLatestWorkoutID()
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var sets []SetRow
|
||||
if latestID != 0 {
|
||||
sets, err = getSetsForWorkout(latestID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data := struct {
|
||||
HasWorkout bool
|
||||
WorkoutID int
|
||||
Sets []SetRow
|
||||
}{
|
||||
HasWorkout: latestID != 0,
|
||||
WorkoutID: latestID,
|
||||
Sets: sets,
|
||||
}
|
||||
|
||||
if err := indexTmpl.Execute(w, data); err != nil {
|
||||
log.Println("template error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleStartWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
_, err := db.Exec(`INSERT INTO workouts (started_at) VALUES (?)`, now)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleAddSet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Always attach to latest workout
|
||||
workoutID, err := getLatestWorkoutID()
|
||||
if err != nil || workoutID == 0 {
|
||||
http.Error(w, "no active workout", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
exercise := r.FormValue("exercise")
|
||||
repsStr := r.FormValue("reps")
|
||||
weightStr := r.FormValue("weight")
|
||||
|
||||
if exercise == "" || repsStr == "" || weightStr == "" {
|
||||
http.Error(w, "missing fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reps, err := strconv.Atoi(repsStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid reps", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
weight, err := strconv.ParseFloat(weightStr, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid weight", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO sets (workout_id, exercise, reps, weight, started_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
workoutID, exercise, reps, weight, now,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- API: rest times between sets (latest workout) ---
|
||||
|
||||
func handleRestTimes(w http.ResponseWriter, r *http.Request) {
|
||||
workoutID, err := getLatestWorkoutID()
|
||||
if err != nil || workoutID == 0 {
|
||||
http.Error(w, "no workout", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, started_at FROM sets WHERE workout_id = ? ORDER BY started_at`,
|
||||
workoutID,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type simpleSet struct {
|
||||
ID int
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
var sets []simpleSet
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var startedStr string
|
||||
if err := rows.Scan(&id, &startedStr); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, startedStr)
|
||||
if err != nil {
|
||||
http.Error(w, "time parse error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sets = append(sets, simpleSet{ID: id, StartedAt: t})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute rest times (seconds) between sets
|
||||
var labels []string
|
||||
var data []float64
|
||||
for i := 1; i < len(sets); i++ {
|
||||
delta := sets[i].StartedAt.Sub(sets[i-1].StartedAt).Seconds()
|
||||
if delta < 0 {
|
||||
delta = 0
|
||||
}
|
||||
labels = append(labels, "Set "+strconv.Itoa(i+1))
|
||||
data = append(data, delta)
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Labels []string `json:"labels"`
|
||||
Data []float64 `json:"data"`
|
||||
}{
|
||||
Labels: labels,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// --- API: volume stats (day / week / month) ---
|
||||
|
||||
func handleVolumeStats(w http.ResponseWriter, r *http.Request) {
|
||||
rangeParam := r.URL.Query().Get("range")
|
||||
if rangeParam == "" {
|
||||
rangeParam = "day"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
var curStart, curEnd time.Time
|
||||
switch rangeParam {
|
||||
case "week":
|
||||
// Start of ISO week (Monday)
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7 // Sunday
|
||||
}
|
||||
curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
|
||||
AddDate(0, 0, -(weekday - 1))
|
||||
curEnd = curStart.AddDate(0, 0, 7)
|
||||
case "month":
|
||||
curStart = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
curEnd = curStart.AddDate(0, 1, 0)
|
||||
default: // "day"
|
||||
curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
curEnd = curStart.AddDate(0, 0, 1)
|
||||
rangeParam = "day"
|
||||
}
|
||||
|
||||
// Previous period of same length
|
||||
period := curEnd.Sub(curStart)
|
||||
prevEnd := curStart
|
||||
prevStart := prevEnd.Add(-period)
|
||||
|
||||
currentVol, err := queryVolume(curStart, curEnd)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prevVol, err := queryVolume(prevStart, prevEnd)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
percentChange := 0.0
|
||||
hasPrevious := prevVol > 0
|
||||
if hasPrevious {
|
||||
percentChange = (currentVol - prevVol) / prevVol * 100.0
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Range string `json:"range"`
|
||||
CurrentVolume float64 `json:"current_volume"`
|
||||
PreviousVolume float64 `json:"previous_volume"`
|
||||
PercentChange float64 `json:"percent_change"`
|
||||
HasPrevious bool `json:"has_previous"`
|
||||
CurrentLabel string `json:"current_label"`
|
||||
PreviousLabel string `json:"previous_label"`
|
||||
}{
|
||||
Range: rangeParam,
|
||||
CurrentVolume: currentVol,
|
||||
PreviousVolume: prevVol,
|
||||
PercentChange: percentChange,
|
||||
HasPrevious: hasPrevious,
|
||||
CurrentLabel: curStart.Format("2006-01-02"),
|
||||
PreviousLabel: prevStart.Format("2006-01-02"),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func queryVolume(start, end time.Time) (float64, error) {
|
||||
// Sum of (reps * weight) for sets whose started_at is in [start, end)
|
||||
startStr := start.UTC().Format(time.RFC3339)
|
||||
endStr := end.UTC().Format(time.RFC3339)
|
||||
|
||||
row := db.QueryRow(`
|
||||
SELECT COALESCE(SUM(reps * weight), 0)
|
||||
FROM sets
|
||||
WHERE started_at >= ? AND started_at < ?
|
||||
`, startStr, endStr)
|
||||
|
||||
var vol float64
|
||||
if err := row.Scan(&vol); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return vol, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func getLatestWorkoutID() (int, error) {
|
||||
row := db.QueryRow(`SELECT id FROM workouts ORDER BY id DESC LIMIT 1`)
|
||||
var id int
|
||||
err := row.Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func getSetsForWorkout(workoutID int) ([]SetRow, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, exercise, reps, weight, started_at
|
||||
FROM sets
|
||||
WHERE workout_id = ?
|
||||
ORDER BY started_at`,
|
||||
workoutID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []SetRow
|
||||
for rows.Next() {
|
||||
var s SetRow
|
||||
var startedStr string
|
||||
if err := rows.Scan(&s.ID, &s.Exercise, &s.Reps, &s.Weight, &startedStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, startedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.StartedAt = t
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- HTML template (single page) ---
|
||||
|
||||
const indexHTML = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Minimal Fitness Tracker</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
h1 { margin-bottom: 0.5rem; }
|
||||
.card {
|
||||
background: #1f2937;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label { display:block; margin-top: 0.5rem; }
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #374151;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
button {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #2563eb; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #374151;
|
||||
padding: 0.4rem 0.3rem;
|
||||
text-align: left;
|
||||
}
|
||||
th { font-size: 0.85rem; text-transform: uppercase; color:#9ca3af; }
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
background: #111827;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
display:inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
background:#111827;
|
||||
color:#9ca3af;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.row {
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Minimal Fitness Tracker</h1>
|
||||
<p style="color:#9ca3af;">Simple self-hosted Go app – log sets, see rest times and training volume.</p>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Workout</h2>
|
||||
{{if .HasWorkout}}
|
||||
<p>Current workout ID: <span class="badge">{{.WorkoutID}}</span></p>
|
||||
{{else}}
|
||||
<p>No workout yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/start">
|
||||
<button type="submit">Start new workout</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Add set</h2>
|
||||
{{if .HasWorkout}}
|
||||
<form method="POST" action="/add-set">
|
||||
<label>
|
||||
Exercise
|
||||
<input type="text" name="exercise" placeholder="Squat" required>
|
||||
</label>
|
||||
<label>
|
||||
Reps
|
||||
<input type="number" name="reps" min="1" step="1" required>
|
||||
</label>
|
||||
<label>
|
||||
Weight (kg)
|
||||
<input type="number" name="weight" step="0.5" min="0" required>
|
||||
</label>
|
||||
<button type="submit">Save set</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p style="color:#fbbf24;">Start a workout first.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sets in latest workout</h2>
|
||||
{{if .Sets}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Exercise</th>
|
||||
<th>Reps</th>
|
||||
<th>Weight</th>
|
||||
<th>Time (UTC)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $s := .Sets}}
|
||||
<tr>
|
||||
<td>{{add $i 1}}</td>
|
||||
<td>{{$s.Exercise}}</td>
|
||||
<td>{{$s.Reps}}</td>
|
||||
<td>{{$s.Weight}}</td>
|
||||
<td>{{$s.StartedAt.Format "15:04:05"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>No sets logged yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Rest time between sets (latest workout)</h2>
|
||||
<canvas id="restChart" height="120"></canvas>
|
||||
<p id="restInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Training volume (Σ weight × reps)</h2>
|
||||
<label for="rangeSelect">View range</label>
|
||||
<select id="rangeSelect" onchange="loadVolumeStats()">
|
||||
<option value="day">Today vs yesterday</option>
|
||||
<option value="week">This week vs last week</option>
|
||||
<option value="month">This month vs last month</option>
|
||||
</select>
|
||||
<canvas id="volumeChart" height="120" style="margin-top:0.5rem;"></canvas>
|
||||
<p id="volumeInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js from CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
let restChartInstance = null;
|
||||
let volumeChartInstance = null;
|
||||
|
||||
function loadRestTimes() {
|
||||
fetch('/api/rest-times')
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error("No rest data yet");
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
const ctx = document.getElementById('restChart').getContext('2d');
|
||||
|
||||
if (!data || !data.labels || data.labels.length === 0) {
|
||||
document.getElementById('restInfo').textContent =
|
||||
'Log at least two sets to see rest times.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (restChartInstance) {
|
||||
restChartInstance.destroy();
|
||||
}
|
||||
|
||||
restChartInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Rest (seconds)',
|
||||
data: data.data
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const avg = data.data.reduce((a,b)=>a+b,0) / data.data.length;
|
||||
document.getElementById('restInfo').textContent =
|
||||
'Average rest: ' + avg.toFixed(1) + ' s';
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('restInfo').textContent =
|
||||
'No rest data yet – add some sets.';
|
||||
});
|
||||
}
|
||||
|
||||
function loadVolumeStats() {
|
||||
const range = document.getElementById('rangeSelect').value;
|
||||
fetch('/api/volume?range=' + encodeURIComponent(range))
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error("No volume data");
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
const ctx = document.getElementById('volumeChart').getContext('2d');
|
||||
|
||||
if (volumeChartInstance) {
|
||||
volumeChartInstance.destroy();
|
||||
}
|
||||
|
||||
volumeChartInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [data.previous_label, data.current_label],
|
||||
datasets: [{
|
||||
label: 'Volume (kg × reps)',
|
||||
data: [data.previous_volume, data.current_volume]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let text = '';
|
||||
const current = data.current_volume;
|
||||
const previous = data.previous_volume;
|
||||
|
||||
const prettyRange =
|
||||
data.range === 'day' ? 'today' :
|
||||
data.range === 'week' ? 'this week' :
|
||||
'this month';
|
||||
|
||||
if (!data.has_previous) {
|
||||
text = 'Total volume ' + prettyRange + ': ' + current.toFixed(1) +
|
||||
' (no previous period logged).';
|
||||
} else {
|
||||
const change = data.percent_change;
|
||||
let dir = 'no change';
|
||||
let arrow = '';
|
||||
if (change > 1e-6) {
|
||||
dir = 'up';
|
||||
arrow = '▲';
|
||||
} else if (change < -1e-6) {
|
||||
dir = 'down';
|
||||
arrow = '▼';
|
||||
}
|
||||
text = 'Total volume ' + prettyRange + ': ' + current.toFixed(1) +
|
||||
' (previous: ' + previous.toFixed(1) +
|
||||
'). Change: ' + dir + ' ' + arrow + ' ' +
|
||||
change.toFixed(1) + '%.';
|
||||
}
|
||||
|
||||
document.getElementById('volumeInfo').textContent = text;
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('volumeInfo').textContent =
|
||||
'No volume data yet – log some sets.';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRestTimes();
|
||||
loadVolumeStats();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
Loading…
Reference in a new issue