Fitnes-tracker/main.go
2025-12-04 18:49:19 +01:00

1233 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"database/sql"
"encoding/json"
"html/template"
"log"
"net/http"
"os"
"strconv"
"time"
_ "github.com/mattn/go-sqlite3"
)
var (
db *sql.DB
indexTmpl *template.Template
listenAddr = ":8080"
)
// A single set row for display
type SetRow struct {
ID int
Exercise string
Reps int
Weight float64
StartedAt time.Time
}
// Workout summary for history view
type WorkoutSummary struct {
ID int
StartedAt time.Time
EndedAt *time.Time
Duration time.Duration
SetCount int
Volume float64
TotalRest time.Duration
HasEnded bool
DurationHuman string
RestHuman string
Template string
}
// Internal workout structure
type Workout struct {
ID int
StartedAt time.Time
EndedAt *time.Time
Template string
}
func main() {
var err error
// --- DB path from env: DATABASE_URL (fallback: fitness.db) ---
dbPath := os.Getenv("DATABASE_URL")
if dbPath == "" {
dbPath = "fitness.db"
}
log.Printf("Using database file: %s", dbPath)
// Open SQLite DB
db, err = sql.Open("sqlite3", dbPath)
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 },
"eq": func(a, b interface{}) bool { return a == b },
}).Parse(indexHTML))
// Routes
http.HandleFunc("/", handleIndex)
http.HandleFunc("/start", handleStartWorkout)
http.HandleFunc("/finish", handleFinishWorkout)
http.HandleFunc("/add-set", handleAddSet)
// APIs
http.HandleFunc("/api/rest-times", handleRestTimes)
http.HandleFunc("/api/volume", handleVolumeStats)
http.HandleFunc("/api/last-set", handleLastSet)
log.Printf("Listening on %s ...", listenAddr)
if err := http.ListenAndServe(listenAddr, nil); err != nil {
log.Fatal(err)
}
}
func initDB() error {
// Migration-friendly: create basic tables, then ALTER for new columns.
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)
);
`
if _, err := db.Exec(schema); err != nil {
return err
}
// Add new columns if they don't exist (ignore errors if already there).
_, _ = db.Exec(`ALTER TABLE workouts ADD COLUMN ended_at TEXT`)
_, _ = db.Exec(`ALTER TABLE workouts ADD COLUMN template TEXT`)
return nil
}
// --- Handlers ---
func handleIndex(w http.ResponseWriter, r *http.Request) {
// Current / latest workout
latest, err := getLatestWorkout()
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
var sets []SetRow
if latest != nil {
sets, err = getSetsForWorkout(latest.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
}
// Date filters for history
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
var from, to *time.Time
if fromStr != "" {
t, err := time.Parse("2006-01-02", fromStr)
if err == nil {
t0 := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
from = &t0
}
}
if toStr != "" {
t, err := time.Parse("2006-01-02", toStr)
if err == nil {
t1 := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
to = &t1
}
}
workouts, err := getWorkoutSummaries(from, to)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
data := struct {
HasWorkout bool
WorkoutID int
WorkoutActive bool
WorkoutStart *time.Time
WorkoutEnd *time.Time
WorkoutTemplate string
Sets []SetRow
From string
To string
Workouts []WorkoutSummary
}{
HasWorkout: latest != nil,
WorkoutID: func() int {
if latest != nil {
return latest.ID
}
return 0
}(),
WorkoutActive: latest != nil && latest.EndedAt == nil,
WorkoutStart: func() *time.Time {
if latest != nil {
return &latest.StartedAt
}
return nil
}(),
WorkoutEnd: func() *time.Time {
if latest != nil {
return latest.EndedAt
}
return nil
}(),
WorkoutTemplate: func() string {
if latest != nil {
return latest.Template
}
return ""
}(),
Sets: sets,
From: fromStr,
To: toStr,
Workouts: workouts,
}
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)
templateVal := r.FormValue("template") // "" / "A" / "B"
_, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, templateVal)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleFinishWorkout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Find latest workout that is not finished
row := db.QueryRow(`SELECT id FROM workouts WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1`)
var id int
if err := row.Scan(&id); err != nil {
// no active workout; just return
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(`UPDATE workouts SET ended_at = ? WHERE id = ?`, now, id)
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
latest, err := getLatestWorkout()
if err != nil || latest == nil {
http.Error(w, "no active workout", http.StatusBadRequest)
return
}
// exercise from dropdown + optional custom
selected := r.FormValue("exercise_select")
exerciseCustom := r.FormValue("exercise_custom")
exercise := selected
if selected == "custom" {
exercise = exerciseCustom
}
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 (?, ?, ?, ?, ?)`,
latest.ID, 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) {
latest, err := getLatestWorkout()
if err != nil || latest == nil {
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`,
latest.ID,
)
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)
}
// --- API: last set for exercise (for autofill) ---
func handleLastSet(w http.ResponseWriter, r *http.Request) {
ex := r.URL.Query().Get("exercise")
if ex == "" {
http.Error(w, "missing exercise", http.StatusBadRequest)
return
}
row := db.QueryRow(`SELECT reps, weight FROM sets WHERE exercise = ? ORDER BY started_at DESC LIMIT 1`, ex)
var reps int
var weight float64
err := row.Scan(&reps, &weight)
if err == sql.ErrNoRows {
resp := struct {
Found bool `json:"found"`
}{Found: false}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
resp := struct {
Found bool `json:"found"`
Reps int `json:"reps"`
Weight float64 `json:"weight"`
}{
Found: true,
Reps: reps,
Weight: weight,
}
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 & summaries ---
func getLatestWorkout() (*Workout, error) {
row := db.QueryRow(`SELECT id, started_at, ended_at, template FROM workouts ORDER BY id DESC LIMIT 1`)
var id int
var startedStr string
var endedStr sql.NullString
var templateStr sql.NullString
err := row.Scan(&id, &startedStr, &endedStr, &templateStr)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
started, err := time.Parse(time.RFC3339, startedStr)
if err != nil {
return nil, err
}
var ended *time.Time
if endedStr.Valid && endedStr.String != "" {
t, err := time.Parse(time.RFC3339, endedStr.String)
if err == nil {
ended = &t
}
}
tmpl := ""
if templateStr.Valid {
tmpl = templateStr.String
}
return &Workout{ID: id, StartedAt: started, EndedAt: ended, Template: tmpl}, nil
}
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()
}
// Get workout summaries for history, optional date filter
func getWorkoutSummaries(from, to *time.Time) ([]WorkoutSummary, error) {
query := `SELECT id, started_at, ended_at, template FROM workouts`
var args []interface{}
if from != nil || to != nil {
query += " WHERE"
first := true
if from != nil {
query += " started_at >= ?"
args = append(args, from.UTC().Format(time.RFC3339))
first = false
}
if to != nil {
if !first {
query += " AND"
}
query += " started_at < ?"
args = append(args, to.UTC().Format(time.RFC3339))
}
}
query += " ORDER BY started_at DESC LIMIT 50"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var res []WorkoutSummary
for rows.Next() {
var id int
var startedStr string
var endedStr sql.NullString
var templateStr sql.NullString
if err := rows.Scan(&id, &startedStr, &endedStr, &templateStr); err != nil {
return nil, err
}
started, err := time.Parse(time.RFC3339, startedStr)
if err != nil {
return nil, err
}
var ended *time.Time
hasEnded := false
if endedStr.Valid && endedStr.String != "" {
t, err := time.Parse(time.RFC3339, endedStr.String)
if err == nil {
ended = &t
hasEnded = true
}
}
tmpl := ""
if templateStr.Valid {
tmpl = templateStr.String
}
// Stats: sets, volume, total rest
rows2, err := db.Query(`SELECT reps, weight, started_at FROM sets WHERE workout_id = ? ORDER BY started_at`, id)
if err != nil {
return nil, err
}
type setData struct {
Reps int
Weight float64
StartedAt time.Time
}
var sd []setData
for rows2.Next() {
var r int
var w float64
var stStr string
if err := rows2.Scan(&r, &w, &stStr); err != nil {
rows2.Close()
return nil, err
}
t, err := time.Parse(time.RFC3339, stStr)
if err != nil {
rows2.Close()
return nil, err
}
sd = append(sd, setData{Reps: r, Weight: w, StartedAt: t})
}
rows2.Close()
setCount := len(sd)
var volume float64
var rest time.Duration
for i, s := range sd {
volume += float64(s.Reps) * s.Weight
if i > 0 {
d := s.StartedAt.Sub(sd[i-1].StartedAt)
if d > 0 {
rest += d
}
}
}
var duration time.Duration
if ended != nil {
duration = ended.Sub(started)
} else {
duration = 0
}
res = append(res, WorkoutSummary{
ID: id,
StartedAt: started,
EndedAt: ended,
Duration: duration,
SetCount: setCount,
Volume: volume,
TotalRest: rest,
HasEnded: hasEnded,
DurationHuman: formatDuration(duration),
RestHuman: formatDuration(rest),
Template: tmpl,
})
}
return res, rows.Err()
}
// simple duration formatter: 1h 23m, 5m 10s, etc.
func formatDuration(d time.Duration) string {
if d <= 0 {
return "-"
}
sec := int(d.Seconds())
h := sec / 3600
sec = sec % 3600
m := sec / 60
s := sec % 60
if h > 0 {
return strconv.Itoa(h) + "h " + strconv.Itoa(m) + "m"
}
if m > 0 {
return strconv.Itoa(m) + "m " + strconv.Itoa(s) + "s"
}
return strconv.Itoa(s) + "s"
}
// --- 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: 1100px;
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"],
input[type="date"],
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.80rem; 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: 1.2fr 1fr;
gap: 1rem;
}
.row-narrow {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (max-width: 900px) {
.row {
grid-template-columns: 1fr;
}
}
.flex-row {
display:flex;
gap:0.5rem;
align-items:center;
}
.flex-row input[type="text"] {
flex:1;
}
.small {
font-size:0.8rem;
color:#9ca3af;
}
.tag {
display:inline-block;
padding:0.1rem 0.4rem;
border-radius:999px;
font-size:0.7rem;
background:#111827;
color:#9ca3af;
cursor:pointer;
}
.tag:hover {
background:#374151;
}
</style>
</head>
<body>
<h1>Minimal Fitness Tracker</h1>
<p style="color:#9ca3af;">Self-hosted 5×5 log A/B templates, volume, rest, and history.</p>
<div class="row">
<div>
<div class="card">
<h2>Workout control</h2>
{{if .HasWorkout}}
<p>
Current workout ID:
<span class="badge">{{.WorkoutID}}</span><br>
<span class="small">
Started: {{if .WorkoutStart}}{{.WorkoutStart.Format "2006-01-02 15:04"}} UTC{{else}}-{{end}}<br>
Ended: {{if .WorkoutEnd}}{{.WorkoutEnd.Format "2006-01-02 15:04"}} UTC{{else}}(active){{end}}<br>
Template:
{{if eq .WorkoutTemplate "A"}}
<span class="tag">A: Squat / Bench / Row</span>
{{else if eq .WorkoutTemplate "B"}}
<span class="tag">B: Squat / OHP / Deadlift / Chin-up</span>
{{else}}
<span class="small">None</span>
{{end}}
</span>
</p>
{{else}}
<p>No workout yet.</p>
{{end}}
<form method="POST" action="/start" style="margin-top:0.5rem;">
<label>
Start new workout with template
<select name="template">
<option value="">None</option>
<option value="A">Workout A (Squat / Bench / Barbell Row)</option>
<option value="B">Workout B (Squat / Overhead Press / Deadlift / Chin-up)</option>
</select>
</label>
<button type="submit">Start new workout</button>
</form>
{{if .WorkoutActive}}
<form method="POST" action="/finish" style="display:inline-block;">
<button type="submit" style="background:#ef4444;">Finish workout</button>
</form>
{{end}}
</div>
<div class="card">
<h2>Add set</h2>
{{if .HasWorkout}}
{{if eq .WorkoutTemplate "A"}}
<p class="small">
Today: <span class="tag" onclick="setExercise('Squat')">Squat</span>
<span class="tag" onclick="setExercise('Bench Press')">Bench</span>
<span class="tag" onclick="setExercise('Barbell Row')">Row</span>
</p>
{{else if eq .WorkoutTemplate "B"}}
<p class="small">
Today: <span class="tag" onclick="setExercise('Squat')">Squat</span>
<span class="tag" onclick="setExercise('Overhead Press')">OHP</span>
<span class="tag" onclick="setExercise('Deadlift')">Deadlift</span>
<span class="tag" onclick="setExercise('Chin-up')">Chin-up</span>
</p>
{{end}}
<form method="POST" action="/add-set">
<label>
Exercise
<select name="exercise_select" id="exercise_select" required onchange="toggleCustomExercise(); onExerciseChange();">
<option value="Squat">Squat</option>
<option value="Bench Press">Bench Press</option>
<option value="Barbell Row">Barbell Row</option>
<option value="Overhead Press">Overhead Press</option>
<option value="Deadlift">Deadlift</option>
<option value="Pull-up">Pull-up</option>
<option value="Chin-up">Chin-up</option>
<option value="Dip">Dip</option>
<option value="Barbell Curl">Barbell Curl</option>
<option value="Tricep Extension">Tricep Extension</option>
<option value="Face Pull">Face Pull</option>
<option value="Lat Pulldown">Lat Pulldown</option>
<option value="custom">Custom…</option>
</select>
</label>
<div class="flex-row small" id="customExerciseRow" style="margin-top:0.4rem; display:none;">
<span>Custom name:</span>
<input type="text" name="exercise_custom" id="exercise_custom" placeholder="Front Squat, Incline Bench…">
</div>
<label>
Reps
<input type="number" id="repsInput" name="reps" min="1" step="1" required>
</label>
<label>
Weight (kg)
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
</label>
<button type="submit">Save set (end set)</button>
<p class="small">Tip: the time you press “Save set” is used as the set time. Rest between sets is calculated automatically.</p>
</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>
<div class="row-narrow">
<div class="card">
<h2>Workout history & filter</h2>
<form method="GET" action="/" class="flex-row" style="gap:0.75rem; flex-wrap:wrap;">
<div style="flex:1; min-width:160px;">
<label>From
<input type="date" name="from" value="{{.From}}">
</label>
</div>
<div style="flex:1; min-width:160px;">
<label>To
<input type="date" name="to" value="{{.To}}">
</label>
</div>
<div style="align-self:flex-end;">
<button type="submit">Filter</button>
</div>
</form>
{{if .Workouts}}
<table>
<thead>
<tr>
<th>ID</th>
<th>Date</th>
<th>Start</th>
<th>End</th>
<th>Dur.</th>
<th>Sets</th>
<th>Volume</th>
<th>Rest (approx.)</th>
<th>Tpl</th>
</tr>
</thead>
<tbody>
{{range .Workouts}}
<tr>
<td>{{.ID}}</td>
<td>{{.StartedAt.Format "2006-01-02"}}</td>
<td>{{.StartedAt.Format "15:04"}}</td>
<td>{{if .HasEnded}}{{.EndedAt.Format "15:04"}}{{else}}-{{end}}</td>
<td>{{.DurationHuman}}</td>
<td>{{.SetCount}}</td>
<td>{{printf "%.0f" .Volume}}</td>
<td>{{.RestHuman}}</td>
<td>{{.Template}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="small">Rest time is estimated from time between sets (button presses).</p>
{{else}}
<p>No workouts logged yet.</p>
{{end}}
</div>
</div>
<!-- Chart.js from CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let restChartInstance = null;
let volumeChartInstance = null;
function toggleCustomExercise() {
const select = document.getElementById('exercise_select');
const row = document.getElementById('customExerciseRow');
const input = document.getElementById('exercise_custom');
if (select.value === 'custom') {
row.style.display = 'flex';
input.required = true;
} else {
row.style.display = 'none';
input.required = false;
input.value = '';
}
}
function setExercise(name) {
const select = document.getElementById('exercise_select');
select.value = name;
toggleCustomExercise();
onExerciseChange();
}
function onExerciseChange() {
const select = document.getElementById('exercise_select');
if (!select || select.value === 'custom') return;
loadLastSetForExercise(select.value);
}
function loadLastSetForExercise(exName) {
if (!exName || exName === 'custom') return;
fetch('/api/last-set?exercise=' + encodeURIComponent(exName))
.then(res => res.json())
.then(data => {
if (!data.found) return;
const repsInput = document.getElementById('repsInput');
const weightInput = document.getElementById('weightInput');
if (repsInput && (!repsInput.value || repsInput.value === '0')) {
repsInput.value = data.reps;
}
if (weightInput && (!weightInput.value || weightInput.value === '0')) {
weightInput.value = data.weight;
}
})
.catch(() => {});
}
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', () => {
toggleCustomExercise();
onExerciseChange();
loadRestTimes();
loadVolumeStats();
});
</script>
</body>
</html>
`