diff --git a/README.md b/README.md index a1d72a7..5a564c2 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,23 @@

PUMP

-**Please Use More Protein** — workout diary with daily set logging, body weight tracking, and training history stats. +**Please Use More Protein** — workout diary with daily set logging, body weight tracking, training stats, and a whole-body **Health** dashboard fed by your fitness tracker via Android Health Connect. -| Workout | Stats: Exercise Distribution | Stats: Weight Moved | +| Overall Health | Workout | Stats: Exercise Distribution | |---|---|---| -| ![Workout](assets/screenshot-workout.png) | ![Stats Exercise Distribution](assets/screenshot-stats-overview.png) | ![Stats Weight Moved](assets/screenshot-stats-activity.png) | +| ![Overall Health](assets/screenshot-health.png) | ![Workout](assets/screenshot-workout.png) | ![Stats Exercise Distribution](assets/screenshot-stats-overview.png) | -| Stats: Body Weight | Config | | +| Stats: Weight Moved | Stats: Body Weight | Config | |---|---|---| -| ![Stats Body Weight](assets/screenshot-stats-weight.png) | ![Config](assets/screenshot-config.png) | | +| ![Stats Weight Moved](assets/screenshot-stats-activity.png) | ![Stats Body Weight](assets/screenshot-stats-weight.png) | ![Config](assets/screenshot-config.png) | + +| Stats: Steps | Stats: Heart Rate | Stats: Sleep | +|---|---|---| +| ![Stats Steps](assets/screenshot-stats-steps.png) | ![Stats Heart Rate](assets/screenshot-stats-hr.png) | ![Stats Sleep](assets/screenshot-stats-sleep.png) | + +| Stats: Cardio | | | +|---|---|---| +| ![Stats Cardio](assets/screenshot-stats-cardio.png) | | | - [Architecture](#architecture) - [Configuration](#configuration) @@ -39,6 +47,12 @@ Use image `ghcr.io/rwlove/pump`. Set `POSTGRES_DSN`. Front the deployment with a A separate Python service under [`cv/`](cv/) watches gym cameras, detects exercises/reps/sets, and writes them to PUMP via the per-set REST API. Disabled by default — enable on the config page (`CVAutoLog`) once cameras are installed and the sidecar is running. See [`docs/cv-autolog-plan.md`](docs/cv-autolog-plan.md) for the full design and [`cv/README.md`](cv/README.md) for runtime details. +### Health dashboard & wearable metrics + +The **Health** page (`/health/`) is a one-page, whole-body view that pulls from every source PUMP tracks — body weight, strength training, and wearable metrics — as summary tiles (latest value, trend delta, sparkline) that deep-link into the matching Stats tab. + +Wearable data is ingested generically from **Android Health Connect** via the [HC Webhook](https://github.com/mcnaveen/health-connect-webhook) bridge app, which POSTs a Health Connect envelope to **`POST /api/health`** (gated by `HEALTH_INGEST_KEY`). Each datum is stored in the `health_record` table, deduped on `(metric_type, start_time, end_time)`. The known types (steps, heart rate, resting heart rate, sleep, exercise) are charted on dedicated Stats tabs — **Steps**, **Heart Rate**, **Sleep**, **Cardio** — and every other Health Connect type is preserved generically, so no schema change is needed to ingest new metrics. + ## Configuration All configuration is via environment variables. No config file is required. diff --git a/assets/screenshot-health.png b/assets/screenshot-health.png new file mode 100644 index 0000000..a2e1917 Binary files /dev/null and b/assets/screenshot-health.png differ diff --git a/assets/screenshot-stats-cardio.png b/assets/screenshot-stats-cardio.png new file mode 100644 index 0000000..b69aab5 Binary files /dev/null and b/assets/screenshot-stats-cardio.png differ diff --git a/assets/screenshot-stats-hr.png b/assets/screenshot-stats-hr.png new file mode 100644 index 0000000..ae936b5 Binary files /dev/null and b/assets/screenshot-stats-hr.png differ diff --git a/assets/screenshot-stats-sleep.png b/assets/screenshot-stats-sleep.png new file mode 100644 index 0000000..6d50a83 Binary files /dev/null and b/assets/screenshot-stats-sleep.png differ diff --git a/assets/screenshot-stats-steps.png b/assets/screenshot-stats-steps.png new file mode 100644 index 0000000..385aa2d Binary files /dev/null and b/assets/screenshot-stats-steps.png differ diff --git a/internal/models/models.go b/internal/models/models.go index 229cc9f..95a3272 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -111,6 +111,56 @@ type HealthRecord struct { IngestedAt time.Time `db:"INGESTED_AT" json:"IngestedAt,omitempty"` } +// ── Health dashboard aggregates ───────────────────────────────────────────── +// Server-aggregated wearable + body metrics shared by the Health dashboard +// page and the Stats wearable tabs. Built from []HealthRecord by the web +// layer. All series are oldest-first; dates are YYYY-MM-DD in server TZ. + +// DayValue is a single dated scalar (daily step total, resting HR, …). +type DayValue struct { + Date string `json:"Date"` + Value float64 `json:"Value"` +} + +// DayRange is a dated min/avg/max (e.g. a day's heart-rate spread). +type DayRange struct { + Date string `json:"Date"` + Min float64 `json:"Min"` + Avg float64 `json:"Avg"` + Max float64 `json:"Max"` +} + +// SleepNight is one night keyed by wake date, with per-stage minutes (zero +// when the source didn't report stages). +type SleepNight struct { + Date string `json:"Date"` + Minutes float64 `json:"Minutes"` + Deep float64 `json:"Deep"` + Light float64 `json:"Light"` + REM float64 `json:"REM"` + Awake float64 `json:"Awake"` +} + +// CardioSession is one band-tracked workout. +type CardioSession struct { + Date string `json:"Date"` + Type string `json:"Type"` + Minutes float64 `json:"Minutes"` + Meters float64 `json:"Meters"` +} + +// HealthStats is the aggregated wearable view. Available is false when the +// active store has no HealthStore (split-frontend mode) — the UI then shows +// empty states instead of failing. +type HealthStats struct { + Available bool `json:"Available"` + DailySteps []DayValue `json:"DailySteps"` + RestingHR []DayValue `json:"RestingHR"` + DailyHR []DayRange `json:"DailyHR"` + Sleep []SleepNight `json:"Sleep"` + Cardio []CardioSession `json:"Cardio"` +} + // GuiData - web gui data type GuiData struct { Config Conf @@ -118,5 +168,6 @@ type GuiData struct { GroupMap []string // unique exercise groups, in display order OneEx Exercise Version string - ServerDate string // today's date in server timezone (YYYY-MM-DD) + ServerDate string // today's date in server timezone (YYYY-MM-DD) + Health HealthStats // wearable aggregates (Health page + Stats tabs) } diff --git a/internal/web/health.go b/internal/web/health.go new file mode 100644 index 0000000..1dd19d4 --- /dev/null +++ b/internal/web/health.go @@ -0,0 +1,44 @@ +package web + +import ( + "log/slog" + "net/http" + "sort" + "time" + + "github.com/gin-gonic/gin" + + "github.com/rwlove/PUMP/internal/models" +) + +// healthHandler renders the Overall Health dashboard — a one-page view that +// pulls from every source PUMP tracks: body weight, strength training, and +// wearable metrics (steps / heart rate / sleep / cardio from Health Connect). +// Each tile deep-links into the matching Stats tab. +func healthHandler(c *gin.Context) { + sets, err := dataStore.SelectSet() + if err != nil { + slog.Error("healthHandler: SelectSet failed", slog.Any("error", err)) + c.Status(http.StatusInternalServerError) + return + } + weights, err := dataStore.SelectW() + if err != nil { + slog.Error("healthHandler: SelectW failed", slog.Any("error", err)) + c.Status(http.StatusInternalServerError) + return + } + + sort.Slice(sets, func(i, j int) bool { return sets[i].Date < sets[j].Date }) + sort.Slice(weights, func(i, j int) bool { return weights[i].Date < weights[j].Date }) + + var guiData models.GuiData + guiData.Config = appConfig + guiData.ExData.Sets = sets + guiData.ExData.Weight = weights + guiData.ServerDate = time.Now().Format("2006-01-02") + guiData.Health = loadHealthStats() + + c.HTML(http.StatusOK, "header.html", guiData) + c.HTML(http.StatusOK, "health.html", guiData) +} diff --git a/internal/web/healthstats.go b/internal/web/healthstats.go new file mode 100644 index 0000000..5ee7970 --- /dev/null +++ b/internal/web/healthstats.go @@ -0,0 +1,188 @@ +package web + +import ( + "encoding/json" + "log/slog" + "sort" + "time" + + "github.com/rwlove/PUMP/internal/models" + "github.com/rwlove/PUMP/internal/store" +) + +// healthLookbackDays bounds how much wearable history we pull for the Health +// dashboard + Stats tabs. A bit over a year covers the "annual" period. +const healthLookbackDays = 400 + +// loadHealthStats fetches and aggregates wearable records from the active +// store. Returns an empty (Available:false) struct when the store has no +// HealthStore (split-frontend / APIClient mode) or on error, so the page +// degrades to empty states rather than failing. +func loadHealthStats() models.HealthStats { + hs, ok := dataStore.(store.HealthStore) + if !ok { + return models.HealthStats{} + } + since := time.Now().AddDate(0, 0, -healthLookbackDays) + recs, err := hs.SelectHealthRecords("", since) + if err != nil { + slog.Error("loadHealthStats: SelectHealthRecords failed", slog.Any("error", err)) + return models.HealthStats{} + } + return aggregateHealth(recs) +} + +// aggregateHealth collapses raw HealthRecords into the compact per-day series +// the UI charts. Pure (no store) so it's trivially testable. Steps sum per +// day; resting HR averages per day; heart_rate becomes a daily min/avg/max +// spread; sleep groups by wake date with per-stage minutes; exercise becomes +// individual cardio sessions. +func aggregateHealth(recs []models.HealthRecord) models.HealthStats { + out := models.HealthStats{Available: true} + + stepsByDay := map[string]float64{} + restingByDay := map[string][]float64{} + hrByDay := map[string][]float64{} + sleepByDay := map[string]*models.SleepNight{} + + val := func(r models.HealthRecord) float64 { + if r.Value == nil { + return 0 + } + f, _ := r.Value.Float64() + return f + } + + for _, r := range recs { + day := r.StartTime.Local().Format("2006-01-02") + switch r.MetricType { + case "steps": + stepsByDay[day] += val(r) + case "resting_heart_rate": + restingByDay[day] = append(restingByDay[day], val(r)) + case "heart_rate": + hrByDay[day] = append(hrByDay[day], val(r)) + case "sleep": + // Key a night by its wake (end) date when available. + d := day + if r.EndTime != nil { + d = r.EndTime.Local().Format("2006-01-02") + } + n := sleepByDay[d] + if n == nil { + n = &models.SleepNight{Date: d} + sleepByDay[d] = n + } + n.Minutes += val(r) / 60.0 + addSleepStages(r.Extra, n) + case "exercise": + out.Cardio = append(out.Cardio, cardioFromRecord(r, day, val(r))) + } + } + + for d, v := range stepsByDay { + out.DailySteps = append(out.DailySteps, models.DayValue{Date: d, Value: v}) + } + for d, vs := range restingByDay { + out.RestingHR = append(out.RestingHR, models.DayValue{Date: d, Value: mean(vs)}) + } + for d, vs := range hrByDay { + mn, av, mx := minAvgMax(vs) + out.DailyHR = append(out.DailyHR, models.DayRange{Date: d, Min: mn, Avg: av, Max: mx}) + } + for _, n := range sleepByDay { + out.Sleep = append(out.Sleep, *n) + } + + sortByDate(out.DailySteps) + sortByDate(out.RestingHR) + sort.Slice(out.DailyHR, func(i, j int) bool { return out.DailyHR[i].Date < out.DailyHR[j].Date }) + sort.Slice(out.Sleep, func(i, j int) bool { return out.Sleep[i].Date < out.Sleep[j].Date }) + sort.Slice(out.Cardio, func(i, j int) bool { return out.Cardio[i].Date < out.Cardio[j].Date }) + + return out +} + +func sortByDate(s []models.DayValue) { + sort.Slice(s, func(i, j int) bool { return s[i].Date < s[j].Date }) +} + +func mean(vs []float64) float64 { + if len(vs) == 0 { + return 0 + } + var sum float64 + for _, v := range vs { + sum += v + } + return sum / float64(len(vs)) +} + +func minAvgMax(vs []float64) (mn, av, mx float64) { + if len(vs) == 0 { + return 0, 0, 0 + } + mn, mx = vs[0], vs[0] + var sum float64 + for _, v := range vs { + if v < mn { + mn = v + } + if v > mx { + mx = v + } + sum += v + } + return mn, sum / float64(len(vs)), mx +} + +// addSleepStages parses a sleep record's Extra ({"stages":[{stage,duration_seconds}]}) +// and accumulates per-stage minutes onto n. Unknown stage labels are ignored +// (their time still counts toward Minutes from the session duration). +func addSleepStages(extra json.RawMessage, n *models.SleepNight) { + if len(extra) == 0 { + return + } + var e struct { + Stages []struct { + Stage string `json:"stage"` + DurationSeconds float64 `json:"duration_seconds"` + } `json:"stages"` + } + if err := json.Unmarshal(extra, &e); err != nil { + return + } + for _, s := range e.Stages { + m := s.DurationSeconds / 60.0 + switch s.Stage { + case "DEEP": + n.Deep += m + case "LIGHT": + n.Light += m + case "REM": + n.REM += m + case "AWAKE", "OUT_OF_BED", "AWAKE_IN_BED": + n.Awake += m + } + } +} + +// cardioFromRecord builds a CardioSession from an exercise record. Value is +// the session duration in seconds; type and distance come from Extra. +func cardioFromRecord(r models.HealthRecord, day string, durationSeconds float64) models.CardioSession { + cs := models.CardioSession{Date: day, Minutes: durationSeconds / 60.0} + if len(r.Extra) > 0 { + var e struct { + Type string `json:"type"` + DistanceMeters float64 `json:"distance_meters"` + } + if err := json.Unmarshal(r.Extra, &e); err == nil { + cs.Type = e.Type + cs.Meters = e.DistanceMeters + } + } + if cs.Type == "" { + cs.Type = "Workout" + } + return cs +} diff --git a/internal/web/public/js/health-charts.js b/internal/web/public/js/health-charts.js new file mode 100644 index 0000000..44dca79 --- /dev/null +++ b/internal/web/public/js/health-charts.js @@ -0,0 +1,297 @@ +// health-charts.js — wearable charts for the Stats tabs (Steps / Heart Rate / +// Sleep / Cardio) and the Overall Health dashboard. Data comes from +// window.healthData (server-aggregated models.HealthStats). Period filtering +// reuses filterByPeriod() from stats.js when present (Stats page); the +// dashboard uses fixed recent windows and works standalone. + +var _stepsChart = null, _hrChart = null, _sleepChart = null, _cardioChart = null; +var _hdSparks = {}; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function hcPrimary() { + return getComputedStyle(document.documentElement).getPropertyValue('--bs-primary').trim() || '#2780e3'; +} +function hcDestroy(c) { if (c) { c.clear(); c.destroy(); } return null; } +function hcData(key) { return (window.healthData && window.healthData[key]) ? window.healthData[key] : []; } +function hcByPeriod(series, period) { + if (typeof filterByPeriod === 'function') return filterByPeriod(series || [], period); + return series || []; +} +function hcSetText(id, txt) { const el = document.getElementById(id); if (el) el.textContent = txt; } +function hcLast(series, n) { return series.slice(Math.max(0, series.length - n)); } +function hcMean(vals) { return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; } + +// 7-point trailing rolling average over a numeric array (nulls for the warm-up). +function hcRollingAvg(vals, win) { + return vals.map((_, i) => { + if (i < win - 1) return null; + let s = 0; for (let k = i - win + 1; k <= i; k++) s += vals[k]; + return Math.round(s / win); + }); +} + +// fmtDur turns minutes into "7h 12m" / "48m". +function hcFmtDur(min) { + const h = Math.floor(min / 60), m = Math.round(min % 60); + return h > 0 ? (h + 'h ' + m + 'm') : (m + 'm'); +} + +// hcDelta builds a coloured "▲3 / ▼2 / —" delta vs a baseline. lowerBetter +// flips the colour (e.g. resting HR going down is good). +function hcDelta(id, cur, base, lowerBetter, suffix) { + const el = document.getElementById(id); + if (!el) return; + if (base == null || cur == null || !isFinite(base) || base === 0) { el.textContent = ''; return; } + const d = cur - base; + const arrow = d > 0 ? '▲' : (d < 0 ? '▼' : '—'); + const good = lowerBetter ? d < 0 : d > 0; + el.textContent = arrow + ' ' + Math.abs(Math.round(d)) + (suffix || ''); + el.style.color = d === 0 ? 'var(--bs-secondary-color)' : (good ? '#28a745' : '#dc3545'); +} + +// hcSpark draws a tiny axis-less sparkline (dashboard tiles). +function hcSpark(id, vals, color) { + const ctx = document.getElementById(id); + if (!ctx) return; + _hdSparks[id] = hcDestroy(_hdSparks[id]); + if (!vals || !vals.length) return; + _hdSparks[id] = new Chart(ctx, { + type: 'line', + data: { labels: vals.map((_, i) => i), datasets: [{ + data: vals, borderColor: color || hcPrimary(), borderWidth: 2, + backgroundColor: (color || hcPrimary()) + '18', fill: true, + pointRadius: 0, tension: 0.35, + }] }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false } }, + elements: { line: { capBezierPoints: true } }, + } + }); +} + +const hcAxisOpts = { + x: { grid: { display: false }, ticks: { maxRotation: 45, font: { size: 11 }, autoSkip: true, maxTicksLimit: 12 } }, + yCount: (title, suffix) => ({ + beginAtZero: true, grid: { display: false }, + title: { display: true, text: title, font: { size: 12 }, color: 'var(--bs-secondary-color)' }, + ticks: { callback(v) { return Number(v).toLocaleString() + (suffix || ''); } } + }), +}; + +// ─── Steps tab ────────────────────────────────────────────────────────────── + +function renderStepsTab(period) { + const all = hcData('DailySteps'); + const today = window._serverDate; + const todayRow = all.find(d => d.Date === today); + hcSetText('steps-today', todayRow ? Math.round(todayRow.Value).toLocaleString() : '0'); + const last7 = hcLast(all, 7).map(d => d.Value); + hcSetText('steps-avg', last7.length ? Math.round(hcMean(last7)).toLocaleString() : '–'); + hcSetText('steps-best', all.length ? Math.round(Math.max(...all.map(d => d.Value))).toLocaleString() : '–'); + + const data = hcByPeriod(all, period); + const canvas = document.getElementById('steps-chart'); + const noData = document.getElementById('steps-no-data'); + if (!data.length) { if (canvas) canvas.style.display = 'none'; if (noData) noData.style.display = 'block'; _stepsChart = hcDestroy(_stepsChart); return; } + if (canvas) canvas.style.display = ''; if (noData) noData.style.display = 'none'; + + const labels = data.map(d => d.Date), vals = data.map(d => Math.round(d.Value)); + _stepsChart = hcDestroy(_stepsChart); + _stepsChart = new Chart(canvas, { + data: { + labels, datasets: [ + { type: 'bar', label: 'Steps', data: vals, backgroundColor: hcPrimary() + 'cc', borderRadius: 4, order: 2 }, + { type: 'line', label: '7-day avg', data: hcRollingAvg(vals, 7), borderColor: '#888', borderWidth: 2, pointRadius: 0, tension: 0.3, order: 1 }, + ] + }, + options: { responsive: true, scales: { x: hcAxisOpts.x, y: hcAxisOpts.yCount('Steps') }, plugins: { legend: { display: true, position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } } } + }); +} + +// ─── Heart Rate tab ───────────────────────────────────────────────────────── + +function renderHRTab(period) { + const resting = hcData('RestingHR'); + const latest = resting.length ? resting[resting.length - 1].Value : null; + hcSetText('hr-resting', latest != null ? Math.round(latest) : '–'); + const last7 = hcLast(resting, 7).map(d => d.Value); + hcSetText('hr-resting-avg', last7.length ? Math.round(hcMean(last7)) : '–'); + const dailyAll = hcData('DailyHR'); + const todayHR = dailyAll.find(d => d.Date === window._serverDate); + hcSetText('hr-range', todayHR ? (Math.round(todayHR.Min) + '–' + Math.round(todayHR.Max)) : '–'); + + const r = hcByPeriod(resting, period); + const dh = hcByPeriod(dailyAll, period); + const canvas = document.getElementById('hr-chart'); + const noData = document.getElementById('hr-no-data'); + if (!r.length && !dh.length) { if (canvas) canvas.style.display = 'none'; if (noData) noData.style.display = 'block'; _hrChart = hcDestroy(_hrChart); return; } + if (canvas) canvas.style.display = ''; if (noData) noData.style.display = 'none'; + + // Union of dates so the resting line and the min/max band align. + const labels = dh.length ? dh.map(d => d.Date) : r.map(d => d.Date); + const restMap = {}; r.forEach(d => restMap[d.Date] = Math.round(d.Value)); + _hrChart = hcDestroy(_hrChart); + _hrChart = new Chart(canvas, { + data: { + labels, datasets: [ + { type: 'line', label: 'Max', data: dh.map(d => Math.round(d.Max)), borderColor: 'transparent', backgroundColor: hcPrimary() + '18', pointRadius: 0, fill: '+1', order: 3 }, + { type: 'line', label: 'Min', data: dh.map(d => Math.round(d.Min)), borderColor: 'transparent', backgroundColor: hcPrimary() + '18', pointRadius: 0, fill: false, order: 3 }, + { type: 'line', label: 'Avg', data: dh.map(d => Math.round(d.Avg)), borderColor: '#888', borderWidth: 1.5, pointRadius: 0, tension: 0.3, order: 2 }, + { type: 'line', label: 'Resting', data: labels.map(l => restMap[l] != null ? restMap[l] : null), borderColor: hcPrimary(), borderWidth: 2.5, pointRadius: 2, tension: 0.3, spanGaps: true, order: 1 }, + ] + }, + options: { responsive: true, scales: { x: hcAxisOpts.x, y: { beginAtZero: false, grid: { display: false }, title: { display: true, text: 'Heart Rate (bpm)', font: { size: 12 }, color: 'var(--bs-secondary-color)' } } }, plugins: { legend: { display: true, position: 'top', labels: { boxWidth: 12, font: { size: 11 }, filter: i => i.text !== 'Min' } } } } + }); +} + +// ─── Sleep tab ────────────────────────────────────────────────────────────── + +function renderSleepTab(period) { + const all = hcData('Sleep'); + const last = all.length ? all[all.length - 1] : null; + hcSetText('sleep-last', last ? hcFmtDur(last.Minutes) : '–'); + const last7 = hcLast(all, 7).map(d => d.Minutes); + hcSetText('sleep-avg', last7.length ? hcFmtDur(hcMean(last7)) : '–'); + const deepPct = last && last.Minutes > 0 ? Math.round(100 * last.Deep / last.Minutes) : null; + hcSetText('sleep-deep', deepPct != null && deepPct > 0 ? deepPct + '%' : '–'); + + const data = hcByPeriod(all, period); + const canvas = document.getElementById('sleep-chart'); + const noData = document.getElementById('sleep-no-data'); + if (!data.length) { if (canvas) canvas.style.display = 'none'; if (noData) noData.style.display = 'block'; _sleepChart = hcDestroy(_sleepChart); return; } + if (canvas) canvas.style.display = ''; if (noData) noData.style.display = 'none'; + + const labels = data.map(d => d.Date); + const hasStages = data.some(d => (d.Deep + d.Light + d.REM + d.Awake) > 0); + let datasets; + if (hasStages) { + datasets = [ + { label: 'Deep', data: data.map(d => +(d.Deep / 60).toFixed(2)), backgroundColor: hcPrimary() }, + { label: 'Light', data: data.map(d => +(d.Light / 60).toFixed(2)), backgroundColor: hcPrimary() + '99' }, + { label: 'REM', data: data.map(d => +(d.REM / 60).toFixed(2)), backgroundColor: '#6f42c1aa' }, + { label: 'Awake', data: data.map(d => +(d.Awake / 60).toFixed(2)), backgroundColor: '#dc354566' }, + ]; + } else { + datasets = [{ label: 'Sleep', data: data.map(d => +(d.Minutes / 60).toFixed(2)), backgroundColor: hcPrimary() + 'cc', borderRadius: 4 }]; + } + _sleepChart = hcDestroy(_sleepChart); + _sleepChart = new Chart(canvas, { + type: 'bar', + data: { labels, datasets }, + options: { + responsive: true, + scales: { x: Object.assign({ stacked: true }, hcAxisOpts.x), y: { stacked: true, beginAtZero: true, grid: { display: false }, title: { display: true, text: 'Hours', font: { size: 12 }, color: 'var(--bs-secondary-color)' }, ticks: { callback(v) { return v + 'h'; } } } }, + plugins: { legend: { display: hasStages, position: 'top', labels: { boxWidth: 12, font: { size: 11 } } }, tooltip: { callbacks: { label(c) { return c.dataset.label + ': ' + hcFmtDur(c.raw * 60); } } } } + } + }); +} + +// ─── Cardio tab ───────────────────────────────────────────────────────────── + +function renderCardioTab(period) { + const data = hcByPeriod(hcData('Cardio'), period); + hcSetText('cardio-count', data.length); + hcSetText('cardio-mins', Math.round(data.reduce((a, b) => a + b.Minutes, 0))); + const km = data.reduce((a, b) => a + b.Meters, 0) / 1000; + hcSetText('cardio-dist', km > 0 ? km.toFixed(1) + ' km' : '–'); + + const canvas = document.getElementById('cardio-chart'); + const noData = document.getElementById('cardio-no-data'); + const list = document.getElementById('cardio-list'); + if (!data.length) { + if (canvas) canvas.style.display = 'none'; + if (noData) noData.style.display = 'block'; + if (list) list.innerHTML = ''; + _cardioChart = hcDestroy(_cardioChart); + return; + } + if (canvas) canvas.style.display = ''; if (noData) noData.style.display = 'none'; + + _cardioChart = hcDestroy(_cardioChart); + _cardioChart = new Chart(canvas, { + type: 'bar', + data: { labels: data.map(d => d.Date), datasets: [{ label: 'Minutes', data: data.map(d => Math.round(d.Minutes)), backgroundColor: hcPrimary() + 'cc', borderRadius: 4 }] }, + options: { responsive: true, scales: { x: hcAxisOpts.x, y: hcAxisOpts.yCount('Minutes') }, plugins: { legend: { display: false }, tooltip: { callbacks: { label(c) { return data[c.dataIndex].Type + ': ' + hcFmtDur(c.raw); } } } } } + }); + + if (list) { + const rows = data.slice().reverse().slice(0, 12).map(s => { + const dist = s.Meters > 0 ? (s.Meters / 1000).toFixed(2) + ' km' : '—'; + return `${escapeHtml(s.Date)}${escapeHtml(s.Type)}${hcFmtDur(s.Minutes)}${dist}`; + }).join(''); + list.innerHTML = rows; + } +} + +// ─── Overall Health dashboard ───────────────────────────────────────────────── + +function renderHealthDashboard() { + // Body weight (window._allWeight: [{Date, Weight}], oldest-first). + const w = (window._allWeight || []).map(r => ({ Date: r.Date, Value: parseFloat(r.Weight) })); + if (w.length) { + const cur = w[w.length - 1].Value; + hcSetText('hd-weight-val', cur.toFixed(1)); + const base = w.length > 1 ? w[Math.max(0, w.length - 30)].Value : null; + hcDelta('hd-weight-delta', cur, base, true, ' lb'); + hcSpark('hd-weight-spark', hcLast(w, 30).map(d => d.Value)); + } else { hcSetText('hd-weight-val', '–'); } + + // Steps today + 30-day sparkline. + const steps = hcData('DailySteps'); + if (steps.length) { + const todayRow = steps.find(d => d.Date === window._serverDate); + hcSetText('hd-steps-val', todayRow ? Math.round(todayRow.Value).toLocaleString() : '0'); + const avg7 = hcMean(hcLast(steps, 7).map(d => d.Value)); + hcDelta('hd-steps-delta', todayRow ? todayRow.Value : 0, avg7, false, ''); + hcSpark('hd-steps-spark', hcLast(steps, 30).map(d => d.Value)); + } else { hcSetText('hd-steps-val', '–'); } + + // Resting HR. + const rhr = hcData('RestingHR'); + if (rhr.length) { + const cur = rhr[rhr.length - 1].Value; + hcSetText('hd-hr-val', Math.round(cur)); + const base = hcMean(hcLast(rhr, 7).map(d => d.Value)); + hcDelta('hd-hr-delta', cur, base, true, ''); + hcSpark('hd-hr-spark', hcLast(rhr, 30).map(d => d.Value), '#dc3545'); + } else { hcSetText('hd-hr-val', '–'); } + + // Sleep last night. + const sleep = hcData('Sleep'); + if (sleep.length) { + const last = sleep[sleep.length - 1]; + hcSetText('hd-sleep-val', hcFmtDur(last.Minutes)); + const avg7 = hcMean(hcLast(sleep, 7).map(d => d.Minutes)); + hcSetText('hd-sleep-sub', '7-day avg ' + hcFmtDur(avg7)); + hcSpark('hd-sleep-spark', hcLast(sleep, 30).map(d => d.Minutes / 60), '#6f42c1'); + } else { hcSetText('hd-sleep-val', '–'); } + + // Training this week + streak (from window.currentSets: [{Date,...}]). + const sets = window.currentSets || []; + const byDay = {}; + sets.forEach(s => { byDay[s.Date] = (byDay[s.Date] || 0) + 1; }); + const days = Object.keys(byDay).sort(); + const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); + const weekSets = sets.filter(s => new Date(s.Date) >= weekAgo).length; + hcSetText('hd-train-val', weekSets); + hcSetText('hd-train-sub', days.length ? (Object.keys(byDay).filter(d => new Date(d) >= weekAgo).length + ' days this week') : 'no sets yet'); + // Current streak: consecutive days ending today/yesterday with a set. + let streak = 0; const d = new Date(window._serverDate || new Date()); + for (;;) { + const key = d.toLocaleDateString('en-CA'); + if (byDay[key]) { streak++; d.setDate(d.getDate() - 1); } + else if (streak === 0 && key === (window._serverDate)) { d.setDate(d.getDate() - 1); } // allow rest today + else break; + } + hcSetText('hd-streak-val', streak); + hcSetText('hd-streak-sub', streak === 1 ? 'day' : 'days'); + + // Weekly training volume sparkline (last 8 weeks set counts). + const weeks = {}; + sets.forEach(s => { const dt = new Date(s.Date); const wk = Math.floor(dt.getTime() / (7 * 864e5)); weeks[wk] = (weeks[wk] || 0) + 1; }); + const wkVals = Object.keys(weeks).sort().map(k => weeks[k]); + hcSpark('hd-train-spark', hcLast(wkVals, 12), '#28a745'); +} diff --git a/internal/web/public/js/stats.js b/internal/web/public/js/stats.js index 796d7c9..1a39329 100644 --- a/internal/web/public/js/stats.js +++ b/internal/web/public/js/stats.js @@ -95,6 +95,14 @@ function setGlobalPeriod(period) { if (consistencyTab && consistencyTab.classList.contains('show')) { updateConsistencyTab(window.currentSets, period); } + + // Refresh wearable tabs (Health Connect) if visible — render fns live in health-charts.js + [['tab-steps', 'renderStepsTab'], ['tab-hr', 'renderHRTab'], ['tab-sleep', 'renderSleepTab'], ['tab-cardio', 'renderCardioTab']].forEach(function(p) { + const tab = document.getElementById(p[0]); + if (tab && tab.classList.contains('show') && typeof window[p[1]] === 'function') { + window[p[1]](period); + } + }); } // ─── Period helpers for non-set data ───────────────────────────────────────── diff --git a/internal/web/stats.go b/internal/web/stats.go index aa6f6d3..8dfd63c 100644 --- a/internal/web/stats.go +++ b/internal/web/stats.go @@ -40,6 +40,7 @@ func statsHandler(c *gin.Context) { guiData.ExData.Sets = sets guiData.ExData.Weight = weights guiData.ServerDate = time.Now().Format("2006-01-02") + guiData.Health = loadHealthStats() c.HTML(http.StatusOK, "header.html", guiData) c.HTML(http.StatusOK, "stats.html", guiData) diff --git a/internal/web/templates/header.html b/internal/web/templates/header.html index 67f5f04..8a912b0 100644 --- a/internal/web/templates/header.html +++ b/internal/web/templates/header.html @@ -52,6 +52,11 @@ AI + + + + +
+ +
+
+
+
+
+
Daily Steps
+
+
+
Today
+
7-day avg
+
Best day
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ Heart Rate + Resting trend with daily min–max range +
+
+
+
Resting (latest)
+
Resting 7-day avg
+
Range today
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ Sleep + Nightly duration & stages +
+
+
+
Last night
+
7-day avg
+
Deep (last)
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
Cardio Sessions
+
+
+
Sessions
+
Total minutes
+
Distance
+
+ + +
+
+
+
+
+
Recent Sessions
+
+ + + + + + + + + + +
DateTypeTimeDist
+
+
+
+
+
+
+ {{ template "footer.html" }} diff --git a/internal/web/webgui.go b/internal/web/webgui.go index b1bac4d..6b3265a 100644 --- a/internal/web/webgui.go +++ b/internal/web/webgui.go @@ -63,6 +63,7 @@ func RegisterRoutes(r *gin.Engine, s store.Store, cfg models.Conf, onConfigSave r.GET("/config/", configHandler) r.GET("/exercise/", exerciseHandler) r.GET("/stats/", statsHandler) + r.GET("/health/", healthHandler) r.GET("/wall/", wallHandler) // Browser-facing proxy to pump-cv for the admin panel's live data. @@ -107,6 +108,7 @@ func startRouter(s store.Store, ac *store.APIClient, address string) { router.GET("/config/", configHandler) router.GET("/exercise/", exerciseHandler) router.GET("/stats/", statsHandler) + router.GET("/health/", healthHandler) router.GET("/wall/", wallHandler) router.Any("/api/cv/*path", pumpCVProxyHandler)