diff --git a/fitness.go b/fitness.go index 4363581..1495f6a 100644 --- a/fitness.go +++ b/fitness.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "fmt" "html/template" "log" "math" @@ -40,8 +41,9 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) * return nil } weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m") + weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y") return &specialPage{ - Content: renderFitnessDashboard(fsPath, weightSel), + Content: renderFitnessDashboard(fsPath, weightSel, weeklySel), SuppressTOC: true, } } @@ -173,6 +175,7 @@ func exportWeightUnit(settings json.RawMessage) string { type weightPoint struct { date time.Time value float64 + days int // >0: weekly mean over this many measured days } // extractWeights computes the weight series from the export: one point per @@ -189,12 +192,43 @@ func extractWeights(ex *wlExport) []weightPoint { } weights := make([]weightPoint, 0, len(byDate)) for d, v := range byDate { - weights = append(weights, weightPoint{d, v}) + weights = append(weights, weightPoint{date: d, value: v}) } sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) }) return weights } +func mondayOf(t time.Time) time.Time { + return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7)) +} + +// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday +// start) and averages over the days that have a measurement. Weeks without +// any measurement produce no point. +func weeklyMeanWeights(points []weightPoint) []weightPoint { + type acc struct { + sum float64 + n int + } + byWeek := map[time.Time]*acc{} + for _, p := range points { + w := mondayOf(p.date) + a := byWeek[w] + if a == nil { + a = &acc{} + byWeek[w] = a + } + a.sum += p.value + a.n++ + } + out := make([]weightPoint, 0, len(byWeek)) + for w, a := range byWeek { + out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n}) + } + sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) }) + return out +} + // === SVG geometry === // Chart canvas in viewBox units. The SVG scales to container width via @@ -235,29 +269,6 @@ func (s chartScale) y(v float64) float64 { return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span } -// niceTicks returns round-valued tick positions covering [lo, hi], aiming -// for about n intervals. -func niceTicks(lo, hi float64, n int) []float64 { - span := hi - lo - if span <= 0 || n < 1 { - return nil - } - raw := span / float64(n) - mag := math.Pow(10, math.Floor(math.Log10(raw))) - step := 10 * mag - for _, m := range []float64{1, 2, 5} { - if m*mag >= raw { - step = m * mag - break - } - } - var ticks []float64 - for v := math.Ceil(lo/step) * step; v <= hi+step/1e6; v += step { - ticks = append(ticks, math.Round(v*1000)/1000) - } - return ticks -} - // === View models === type fitnessOptVM struct { @@ -311,9 +322,11 @@ func newChartVM(title, param, sel string) fitnessChartVM { } } -func yTickVMs(sc chartScale, n int) []fitnessTickVM { +// yTickVMs places gridlines at fixed multiples of step across the y domain +// (always 5 kg guides on weight charts, regardless of range). +func yTickVMs(sc chartScale, step float64) []fitnessTickVM { var out []fitnessTickVM - for _, v := range niceTicks(sc.y0, sc.y1, n) { + for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step { out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)}) } return out @@ -330,8 +343,11 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) { // === Chart builders === -func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit string, today time.Time) fitnessChartVM { - vm := newChartVM("Weight ("+unit+")", "weight", sel) +// buildWeightChart renders a weight line chart. It serves both the per-day +// series and the weekly-mean series (points carrying days > 0); the line is +// drawn continuous across days/weeks without a measurement. +func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM { + vm := newChartVM(title, param, sel) points := all var x0, x1 time.Time @@ -376,45 +392,34 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit s } sc := chartScale{x0, x1, lo - pad, hi + pad} - vm.YTicks = yTickVMs(sc, 4) + vm.YTicks = yTickVMs(sc, 5) vm.XTicks = timeXTicks(sc, 4) - // Point markers carry the hover . On dense ranges (1y/All) the - // markers are dropped and the bare polyline stays legible; gaps in the - // data split the line into segments — no interpolation. - markAll := len(points) <= 100 + // One continuous polyline through every point in range — days without a + // measurement do not break the line. Point markers carry the hover + // <title>; on dense ranges they are dropped and the bare line stays + // legible. A single point in range renders as a dot. dot := func(p weightPoint) fitnessDotVM { - return fitnessDotVM{ - X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), - Title: p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit, + label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit + if p.days > 0 { + label = fmt.Sprintf("Week of %s: %s %s (%d days)", + p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days) } + return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label} } - var seg []weightPoint - flush := func() { - if len(seg) >= 2 { - var b strings.Builder - for i, p := range seg { - if i > 0 { - b.WriteByte(' ') - } - b.WriteString(svgNum(sc.x(p.date))) - b.WriteByte(',') - b.WriteString(svgNum(sc.y(p.value))) + if len(points) >= 2 { + var b strings.Builder + for i, p := range points { + if i > 0 { + b.WriteByte(' ') } - vm.Lines = append(vm.Lines, b.String()) - } else if len(seg) == 1 && !markAll { - vm.Dots = append(vm.Dots, dot(seg[0])) + b.WriteString(svgNum(sc.x(p.date))) + b.WriteByte(',') + b.WriteString(svgNum(sc.y(p.value))) } - seg = seg[:0] + vm.Lines = append(vm.Lines, b.String()) } - for i, p := range points { - if i > 0 && p.date.Sub(points[i-1].date) > 24*time.Hour { - flush() - } - seg = append(seg, p) - } - flush() - if markAll { + if len(points) <= 100 { for _, p := range points { vm.Dots = append(vm.Dots, dot(p)) } @@ -462,8 +467,8 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM { var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html")) -func renderFitnessDashboard(fsPath, weightSel string) template.HTML { - data := buildFitnessDash(fsPath, weightSel, time.Now()) +func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML { + data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now()) var buf bytes.Buffer if err := fitnessTmpl.Execute(&buf, data); err != nil { log.Printf("fitness template: %v", err) @@ -475,7 +480,7 @@ func renderFitnessDashboard(fsPath, weightSel string) template.HTML { // buildFitnessDash reads and parses the export per request — no caching, no // indexes. A read or parse failure (including a truncated mid-upload file) // becomes an inline notice; the page itself always renders. -func buildFitnessDash(fsPath, weightSel string, now time.Time) fitnessDashVM { +func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM { raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile)) if err != nil { return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."} @@ -488,8 +493,12 @@ func buildFitnessDash(fsPath, weightSel string, now time.Time) fitnessDashVM { today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) weights := extractWeights(&ex) wGoal, hasWGoal := goalValue(ex.Settings, "weight") + unit := exportWeightUnit(ex.Settings) return fitnessDashVM{Charts: []fitnessChartVM{ - buildWeightChart(weights, wGoal, hasWGoal, weightSel, exportWeightUnit(ex.Settings), today), + buildWeightChart(weights, wGoal, hasWGoal, weightSel, + "weight", "Weight ("+unit+")", unit, today), + buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel, + "weekly", "Weekly average weight ("+unit+")", unit, today), }} }