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