Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@

<p align="center"><img src="assets/logo.svg" alt="PUMP" width="320"></p>

**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)
Expand All @@ -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.
Expand Down
Binary file added assets/screenshot-health.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-stats-cardio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-stats-hr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-stats-sleep.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-stats-steps.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 52 additions & 1 deletion internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,63 @@ 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
ExData AllExData
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)
}
44 changes: 44 additions & 0 deletions internal/web/health.go
Original file line number Diff line number Diff line change
@@ -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)
}
188 changes: 188 additions & 0 deletions internal/web/healthstats.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading