From 189635d103b7a61a2131dd4260d2f06d9ca4e84a Mon Sep 17 00:00:00 2001 From: luxick Date: Sun, 27 Sep 2026 09:31:01 +0200 Subject: [PATCH] Improvements to fitness page - Data now comes from a calendar - Hovering in charts shows day info - Optical enhancements to chart rendering - Allow setting/showing goal weight - Allow toggeling between absolute and zoomed in weight axis --- README.md | 2 +- assets/fitness/fitness.js | 76 ++++++++- assets/fitness/main.html | 17 +- assets/style.css | 24 ++- calendar.go | 19 ++- fitness.go | 340 +++++++++++++++++++------------------- fitness_test.go | 76 +++++++++ 7 files changed, 365 insertions(+), 189 deletions(-) create mode 100644 fitness_test.go diff --git a/README.md b/README.md index 40d4de7..b3839cf 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Bookmarkable shortcuts: `/today/`, `/this-month/`, `/this-y ## Fitness -With `type = fitness`, a folder shows weight charts (daily and weekly mean, with your goal) below its content. Copy the [Waistline](https://github.com/davidhealey/waistline) app's export into the folder as `waistline_export.json`. Each new export replaces the old file. +With `type = fitness`, a folder shows weight charts (daily and weekly mean) below its content. The weights come from the calendars with the `weight` role (see [Calendars](#calendars)). Add `weight-goal = 85` to the folder's `.page-settings` to draw a goal line. ## Calendars diff --git a/assets/fitness/fitness.js b/assets/fitness/fitness.js index 5be9373..da0cbdb 100644 --- a/assets/fitness/fitness.js +++ b/assets/fitness/fitness.js @@ -1,10 +1,74 @@ -// Fitness dashboard range dropdowns: changing one reloads the page with that -// chart's query parameter updated. Plain GET navigation — each range is a -// distinct, bookmarkable view, so no postReplace/history handling is needed. +// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one +// reloads the page with that chart's query parameter updated. Plain GET +// navigation — each view is distinct and bookmarkable, so no +// postReplace/history handling is needed. document.addEventListener('change', function (e) { - var sel = e.target.closest('[data-fitness-range]'); - if (!sel) return; var url = new URL(window.location.href); - url.searchParams.set(sel.dataset.fitnessRange, sel.value); + var sel = e.target.closest('[data-fitness-range]'); + var abs = e.target.closest('[data-fitness-absolute]'); + if (sel) { + url.searchParams.set(sel.dataset.fitnessRange, sel.value); + } else if (abs) { + if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1'); + else url.searchParams.delete(abs.dataset.fitnessAbsolute); + } else { + return; + } window.location.href = url.toString(); }); + +// Hover tooltip: the pointer snaps to the data point nearest in time. Dots +// are sorted by x, so a binary search finds it; the tooltip sits above the +// point and is clamped to the chart panel. +function fitnessNearest(dots, x) { + var lo = 0, hi = dots.length - 1; + while (lo < hi) { + var mid = (lo + hi) >> 1; + if (dots[mid].cx.baseVal.value < x) lo = mid + 1; else hi = mid; + } + if (lo > 0 && x - dots[lo - 1].cx.baseVal.value < dots[lo].cx.baseVal.value - x) lo--; + return dots[lo]; +} + +function fitnessHide(svg) { + var chart = svg.closest('.fitness-chart'); + svg.classList.remove('is-hover'); + chart.querySelector('.fitness-tip').hidden = true; +} + +document.addEventListener('pointermove', function (e) { + var svg = e.target.closest && e.target.closest('.fitness-svg'); + document.querySelectorAll('.fitness-svg').forEach(function (other) { + if (other !== svg) fitnessHide(other); + }); + if (!svg) return; + var dots = svg.querySelectorAll('.chart-dot'); + if (!dots.length) return; + var ctm = svg.getScreenCTM(); + if (!ctm) return; + var pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse()); + var dot = fitnessNearest(dots, pt.x); + var cx = dot.cx.baseVal.value, cy = dot.cy.baseVal.value; + + var cursor = svg.querySelector('.chart-cursor'); + cursor.setAttribute('x1', cx); + cursor.setAttribute('x2', cx); + var focus = svg.querySelector('.chart-focus'); + focus.setAttribute('cx', cx); + focus.setAttribute('cy', cy); + svg.classList.add('is-hover'); + + var chart = svg.closest('.fitness-chart'); + var tip = chart.querySelector('.fitness-tip'); + tip.firstElementChild.textContent = dot.dataset.date; + tip.lastElementChild.textContent = dot.dataset.value; + tip.hidden = false; + var box = chart.getBoundingClientRect(); + var at = new DOMPoint(cx, cy).matrixTransform(ctm); + var left = at.x - box.left - tip.offsetWidth / 2; + left = Math.max(0, Math.min(left, box.width - tip.offsetWidth)); + var top = at.y - box.top - tip.offsetHeight - 10; + if (top < 0) top = at.y - box.top + 10; + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; +}); diff --git a/assets/fitness/main.html b/assets/fitness/main.html index dace803..f29c658 100644 --- a/assets/fitness/main.html +++ b/assets/fitness/main.html @@ -2,19 +2,23 @@
{{.Title}} - +
+ + +
{{if .Empty}}

No data in this range.

{{else}} - + {{range .YTicks}} {{.Label}} {{end}} {{range .XTicks}} + {{.Label}} {{end}} @@ -23,13 +27,16 @@ {{end}} {{range .Dots}} - {{.Title}} + {{end}} {{if .Goal}} {{.Goal.Label}} {{end}} + + + {{end}}
{{end}} diff --git a/assets/style.css b/assets/style.css index 7c45b63..1465054 100644 --- a/assets/style.css +++ b/assets/style.css @@ -837,8 +837,10 @@ aside.sidebar:empty { display: none; } Server-rendered inline SVG charts. Geometry comes precomputed from Go; colors and strokes are applied here via classes so the inline SVG follows the theme palette. */ -.fitness-chart { padding: var(--space-3); } +.fitness-chart { position: relative; padding: var(--space-3); } .fitness-chart-header { margin-bottom: var(--space-2); } +.fitness-controls { gap: var(--space-3); } +.fitness-absolute { display: flex; align-items: center; gap: var(--space-1); font-size: var(--font-sm); color: var(--text-muted); } .fitness-range { width: auto; font-size: var(--font-sm); } .fitness-empty { border: var(--border-dashed); @@ -852,8 +854,24 @@ aside.sidebar:empty { display: none; } .fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); } .fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; } .fitness-svg .chart-dot { fill: var(--link); } -.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; } -.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); } +.fitness-svg.is-dense .chart-dot { display: none; } +.fitness-svg:not(.is-hover) :is(.chart-cursor, .chart-focus) { display: none; } +.fitness-svg .chart-cursor { stroke: var(--text-muted); stroke-dasharray: 2 2; } +.fitness-svg .chart-focus { fill: var(--link-hover); stroke: var(--bg); stroke-width: 1.5; } +.fitness-svg .chart-goal { stroke: var(--danger); stroke-dasharray: 4 3; } +.fitness-svg .chart-goal-label { fill: var(--danger-hover); font-size: var(--font-xs); } +.fitness-tip { + position: absolute; + display: flex; + flex-direction: column; + padding: var(--space-1) var(--space-2); + background: var(--bg); + border: var(--border); + font-size: var(--font-sm); + white-space: nowrap; + pointer-events: none; +} +.fitness-tip[hidden] { display: none; } /* === Responsive === */ @media (max-width: 1100px) { diff --git a/calendar.go b/calendar.go index c828bb9..a3fd413 100644 --- a/calendar.go +++ b/calendar.go @@ -646,6 +646,20 @@ func calendarDayLines(from, to time.Time) map[string]calDayLines { } } + for key, w := range calendarWeights(s, from, to) { + lines := out[key] + lines.Weight = strings.ReplaceAll(w, ".", ",") + " kg" + out[key] = lines + } + return out +} + +// calendarWeights reads the weight role for [from, to): one entry per day, +// keyed by "YYYY-MM-DD", holding the kg value with a '.' decimal separator. +// Events whose title is not a weight are skipped; several entries on one day: +// the last edited one wins. +func calendarWeights(s calendarSettings, from, to time.Time) map[string]string { + out := map[string]string{} stamps := map[string]time.Time{} for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) { m := weightTitle.FindStringSubmatch(ev.Summary) @@ -653,14 +667,11 @@ func calendarDayLines(from, to time.Time) map[string]calDayLines { continue } key := ev.Start.Format("2006-01-02") - // Several entries on one day: the last edited one wins. if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) { continue } stamps[key] = ev.Stamp - lines := out[key] - lines.Weight = strings.ReplaceAll(m[1], ".", ",") + " kg" - out[key] = lines + out[key] = strings.ReplaceAll(m[1], ",", ".") } return out } diff --git a/fitness.go b/fitness.go index 1495f6a..f260297 100644 --- a/fitness.go +++ b/fitness.go @@ -2,14 +2,12 @@ package main import ( "bytes" - "encoding/json" "fmt" "html/template" "log" "math" "net/http" - "os" - "path/filepath" + "net/url" "sort" "strconv" "strings" @@ -20,10 +18,6 @@ func init() { pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{}) } -// waistlineExportFile is the exact filename the user copies into the folder. -// The single file is always the latest export — no glob, no multi-file merge. -const waistlineExportFile = "waistline_export.json" - type fitnessHandler struct{} // redirect: the fitness dashboard has no virtual URLs; everything renders @@ -34,16 +28,15 @@ func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) // handle renders the dashboard for folders whose .page-settings declares // type = fitness. Markdown content and the folder listing stay visible so -// the user can verify an uploaded export arrived. +// the folder can still hold notes. func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage { s := readPageSettings(fsPath) if s == nil || s.Type != "fitness" { return nil } - weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m") - weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y") + q := r.URL.Query() return &specialPage{ - Content: renderFitnessDashboard(fsPath, weightSel, weeklySel), + Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")), SuppressTOC: true, } } @@ -72,6 +65,20 @@ func validFitnessRange(v, fallback string) string { return fallback } +// fitnessView is one chart's view state from the query string: ?= +// and ?-absolute=1 to start the y axis at 0. +type fitnessView struct { + Range string + Absolute bool +} + +func chartViewFrom(q url.Values, param, fallback string) fitnessView { + return fitnessView{ + Range: validFitnessRange(q.Get(param), fallback), + Absolute: q.Get(param+"-absolute") == "1", + } +} + func rangeMonths(v string) int { for _, r := range fitnessRanges { if r.Value == v { @@ -81,95 +88,6 @@ func rangeMonths(v string) int { return 0 } -// === Waistline export parsing === - -// wlNum is a number in a Waistline export: values appear as JSON numbers, -// numeric strings, null, or are absent. Unparsable values read as not-ok -// instead of failing the whole export parse. -type wlNum struct { - val float64 - ok bool -} - -func (n *wlNum) UnmarshalJSON(b []byte) error { - s := strings.Trim(string(b), `"`) - if s == "" || s == "null" { - return nil - } - v, err := strconv.ParseFloat(s, 64) - if err != nil { - return nil - } - n.val = v - n.ok = true - return nil -} - -// Calorie tracking was removed pending a rethink of the (undocumented) -// per-item formula; only the weight series is read from the export. -type wlExport struct { - Diary []wlDiaryEntry `json:"diary"` - Settings json.RawMessage `json:"settings"` -} - -type wlDiaryEntry struct { - DateTime string `json:"dateTime"` - Stats struct { - Weight wlNum `json:"weight"` - } `json:"stats"` -} - -// exportDate extracts the calendar day from a Waistline dateTime. The values -// are UTC-midnight timestamps; taking the first 10 characters avoids time -// zone conversions shifting the day. -func exportDate(s string) (time.Time, bool) { - if len(s) < 10 { - return time.Time{}, false - } - t, err := time.Parse("2006-01-02", s[:10]) - if err != nil { - return time.Time{}, false - } - return t, true -} - -// goalValue extracts settings.goals..goal-list[0].goal[0] — the first -// weekday slot of the shared goal. Best-effort: any missing or unparsable -// level means "no goal line", never an error. -func goalValue(settings json.RawMessage, key string) (float64, bool) { - var s struct { - Goals map[string]json.RawMessage `json:"goals"` - } - if json.Unmarshal(settings, &s) != nil { - return 0, false - } - var g struct { - GoalList []struct { - Goal []wlNum `json:"goal"` - } `json:"goal-list"` - } - if json.Unmarshal(s.Goals[key], &g) != nil { - return 0, false - } - if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 { - return 0, false - } - n := g.GoalList[0].Goal[0] - return n.val, n.ok -} - -func exportWeightUnit(settings json.RawMessage) string { - var s struct { - Units struct { - Weight string `json:"weight"` - } `json:"units"` - } - if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" { - return s.Units.Weight - } - return "kg" -} - // === Series extraction === type weightPoint struct { @@ -178,26 +96,34 @@ type weightPoint struct { days int // >0: weekly mean over this many measured days } -// extractWeights computes the weight series from the export: one point per -// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last -// one wins. -func extractWeights(ex *wlExport) []weightPoint { - byDate := map[time.Time]float64{} - for _, e := range ex.Diary { - day, ok := exportDate(e.DateTime) - if !ok || !e.Stats.Weight.ok { +// fitnessWeights reads the whole weight calendar up to today: one point per +// day, sorted by date. Dates are UTC midnights to match the chart's today. +func fitnessWeights(today time.Time) []weightPoint { + byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1)) + weights := make([]weightPoint, 0, len(byDay)) + for key, w := range byDay { + d, err := time.Parse("2006-01-02", key) + v, err2 := strconv.ParseFloat(w, 64) + if err != nil || err2 != nil { continue } - byDate[day] = e.Stats.Weight.val - } - weights := make([]weightPoint, 0, len(byDate)) - for d, v := range byDate { 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 } +// weightGoal reads the optional weight-goal key from the fitness folder's +// .page-settings. +func weightGoal(fsPath string) (float64, bool) { + v, ok := readSettingsFile(fsPath)["weight-goal"] + if !ok { + return 0, false + } + g, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v), ",", "."), 64) + return g, err == nil +} + func mondayOf(t time.Time) time.Time { return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7)) } @@ -280,8 +206,9 @@ type fitnessTickVM struct { Pos, Label, Anchor string } +// fitnessDotVM is one data point; Date and Value feed the hover tooltip. type fitnessDotVM struct { - X, Y, Title string + X, Y, Date, Value string } type fitnessGoalVM struct { @@ -289,10 +216,11 @@ type fitnessGoalVM struct { } type fitnessChartVM struct { - Title string - Param string - Options []fitnessOptVM - Empty bool + Title string + Param string + Options []fitnessOptVM + Absolute bool + Empty bool ViewW, ViewH string PlotX, PlotY, PlotR, PlotB string @@ -301,6 +229,7 @@ type fitnessChartVM struct { Goal *fitnessGoalVM Lines []string // polyline points attributes Dots []fitnessDotVM + Dense bool // too many points to draw markers } type fitnessDashVM struct { @@ -308,13 +237,13 @@ type fitnessDashVM struct { Charts []fitnessChartVM } -func newChartVM(title, param, sel string) fitnessChartVM { +func newChartVM(title, param string, view fitnessView) fitnessChartVM { opts := make([]fitnessOptVM, len(fitnessRanges)) for i, r := range fitnessRanges { - opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel} + opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == view.Range} } return fitnessChartVM{ - Title: title, Param: param, Options: opts, + Title: title, Param: param, Options: opts, Absolute: view.Absolute, ViewW: svgNum(chartW), ViewH: svgNum(chartH), PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop), PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom), @@ -323,7 +252,7 @@ func newChartVM(title, param, sel string) fitnessChartVM { } // yTickVMs places gridlines at fixed multiples of step across the y domain -// (always 5 kg guides on weight charts, regardless of range). +// (5 kg guides on weight charts, 20 kg when the axis starts at 0). func yTickVMs(sc chartScale, step float64) []fitnessTickVM { var out []fitnessTickVM for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step { @@ -346,12 +275,12 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) { // 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) +func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM { + vm := newChartVM(title, param, view) points := all var x0, x1 time.Time - if m := rangeMonths(sel); m > 0 { + if m := rangeMonths(view.Range); m > 0 { x0 = today.AddDate(0, -m, 0) points = filterWeights(all, x0) if len(points) == 0 { @@ -391,21 +320,31 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, pad = 1 } sc := chartScale{x0, x1, lo - pad, hi + pad} + yStep := 5.0 + if view.Absolute { + sc.y0 = 0 + sc.y1 = hi + hi*0.05 + yStep = 20 + } - vm.YTicks = yTickVMs(sc, 5) - vm.XTicks = timeXTicks(sc, 4) + vm.YTicks = yTickVMs(sc, yStep) + vm.XTicks = timeXTicks(sc) // One continuous polyline through every point in range — days without a - // measurement do not break the line. Point markers carry the hover - // ; on dense ranges they are dropped and the bare line stays - // legible. A single point in range renders as a dot. + // measurement do not break the line. Every point also gets a marker that + // feeds the hover tooltip; on dense ranges the markers are hidden and the + // bare line stays legible. A single point in range renders as a dot. dot := func(p weightPoint) fitnessDotVM { - 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) + d := fitnessDotVM{ + X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), + Date: p.date.Format("Mon 2006-01-02"), + Value: svgNum(p.value) + " " + unit, } - return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label} + if p.days > 0 { + d.Date = "Week of " + p.date.Format("2006-01-02") + d.Value += fmt.Sprintf(" · %d days", p.days) + } + return d } if len(points) >= 2 { var b strings.Builder @@ -419,11 +358,10 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, } vm.Lines = append(vm.Lines, b.String()) } - if len(points) <= 100 { - for _, p := range points { - vm.Dots = append(vm.Dots, dot(p)) - } + for _, p := range points { + vm.Dots = append(vm.Dots, dot(p)) } + vm.Dense = len(points) > 100 if hasGoal { vm.setGoal(sc, goal, "goal "+svgNum(goal)) @@ -441,24 +379,91 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint { return out } -// timeXTicks places n+1 evenly spaced date labels across a continuous time -// axis; the last label is end-anchored so it stays inside the viewBox. -func timeXTicks(sc chartScale, n int) []fitnessTickVM { - span := sc.x1.Sub(sc.x0) - var out []fitnessTickVM - prev := "" - for i := 0; i <= n; i++ { - t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n))) - label := t.Format("2006-01-02") - if label == prev { - continue +// xTickStep is a calendar-aligned spacing for date labels. +type xTickStep struct { + days, months int + label func(t time.Time, first bool) string +} + +var xTickSteps = []xTickStep{ + {days: 1, label: dayLabel}, + {days: 7, label: dayLabel}, + {days: 14, label: dayLabel}, + {months: 1, label: monthLabel}, + {months: 3, label: monthLabel}, + {months: 6, label: monthLabel}, + {months: 12, label: yearLabel}, + {months: 24, label: yearLabel}, + {months: 60, label: yearLabel}, +} + +func dayLabel(t time.Time, _ bool) string { return t.Format("2 Jan") } + +// monthLabel names the year only where it changes, so a year-long axis +// reads "Oct 2025, Jan 2026, Apr, Jul". +func monthLabel(t time.Time, first bool) string { + if first || t.Month() == time.January { + return t.Format("Jan 2006") + } + return t.Format("Jan") +} + +func yearLabel(t time.Time, _ bool) string { return t.Format("2006") } + +// maxXTicks bounds the label count so labels never collide on the axis. +const maxXTicks = 5 + +// timeXTicks labels the time axis at calendar boundaries — days, Mondays, +// month starts or new years, whichever is the finest step that stays within +// maxXTicks. Labels near the plot edges are anchored inward so they stay +// inside the viewBox. +func timeXTicks(sc chartScale) []fitnessTickVM { + var ticks []time.Time + var step xTickStep + for _, step = range xTickSteps { + ticks = alignedTicks(sc.x0, sc.x1, step) + if len(ticks) <= maxXTicks { + break } - prev = label + } + out := make([]fitnessTickVM, 0, len(ticks)) + for i, t := range ticks { + x := sc.x(t) anchor := "middle" - if i == n { + switch { + case x-chartLeft < 24: + anchor = "start" + case chartW-chartRight-x < 24: anchor = "end" } - out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor}) + out = append(out, fitnessTickVM{Pos: svgNum(x), Label: step.label(t, i == 0), Anchor: anchor}) + } + return out +} + +// alignedTicks lists every boundary of step within [from, to]. Week steps +// start on Mondays; month and year steps on multiples of the step (Jan/Apr/ +// Jul/Oct for quarters, even years for two-year steps). +func alignedTicks(from, to time.Time, step xTickStep) []time.Time { + var t time.Time + if step.days > 0 { + t = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC) + if step.days > 1 { + t = mondayOf(t) + } + } else { + months := from.Year()*12 + int(from.Month()) - 1 + months -= months % step.months + t = time.Date(months/12, time.Month(months%12+1), 1, 0, 0, 0, 0, time.UTC) + } + var out []time.Time + for ; !t.After(to); t = t.AddDate(0, step.months, step.days) { + if !t.Before(from) { + out = append(out, t) + } + if len(out) > maxXTicks { + break + } } return out } @@ -467,8 +472,8 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM { var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html")) -func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML { - data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now()) +func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML { + data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now()) var buf bytes.Buffer if err := fitnessTmpl.Execute(&buf, data); err != nil { log.Printf("fitness template: %v", err) @@ -477,28 +482,23 @@ func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML { return template.HTML(buf.String()) } -// 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, 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."} - } - var ex wlExport - if err := json.Unmarshal(raw, &ex); err != nil { - return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()} +// buildFitnessDash reads the weight calendar per request (the calendar store +// caches parsed files). A missing calendar setup becomes an inline notice; +// the page itself always renders. +func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM { + if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 { + return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."} } 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) + weights := fitnessWeights(today) + wGoal, hasWGoal := weightGoal(fsPath) + const unit = "kg" return fitnessDashVM{Charts: []fitnessChartVM{ - buildWeightChart(weights, wGoal, hasWGoal, weightSel, + buildWeightChart(weights, wGoal, hasWGoal, weightView, "weight", "Weight ("+unit+")", unit, today), - buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel, + buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView, "weekly", "Weekly average weight ("+unit+")", unit, today), }} } diff --git a/fitness_test.go b/fitness_test.go new file mode 100644 index 0000000..98f4ed2 --- /dev/null +++ b/fitness_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "os" + "strings" + "path/filepath" + "testing" + "time" +) + +func TestFitnessWeights(t *testing.T) { + withBerlin(t) + dir := t.TempDir() + prev := calendarsDir + calendarsDir = dir + t.Cleanup(func() { calendarsDir = prev }) + + if err := os.WriteFile(filepath.Join(dir, ".page-settings"), + []byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil { + t.Fatal(err) + } + writeCal(t, dir, "Gewicht", + `UID:1 + SUMMARY:96 kg + DTSTART;VALUE=DATE:20261001`, + `UID:2 + SUMMARY:95,6kg + DTSTART;VALUE=DATE:20261003`, + `UID:3 + SUMMARY:not a weight + DTSTART;VALUE=DATE:20261004`, + `UID:4 + SUMMARY:90 kg + DTSTART;VALUE=DATE:20261010`, + ) + + today := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) + got := fitnessWeights(today) + want := []weightPoint{ + {date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), value: 96}, + {date: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), value: 95.6}, + } + if len(got) != len(want) { + t.Fatalf("got %+v, want %+v", got, want) + } + for i := range want { + if !got[i].date.Equal(want[i].date) || got[i].value != want[i].value { + t.Fatalf("point %d: got %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestTimeXTicks(t *testing.T) { + d := func(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) } + for _, c := range []struct { + from, to time.Time + want string + }{ + {d(2022, 11, 28), d(2026, 9, 21), "2023 2024 2025 2026"}, + {d(2025, 9, 27), d(2026, 9, 27), "Oct 2025|Jan 2026|Apr|Jul"}, + {d(2026, 6, 27), d(2026, 9, 27), "Jul 2026|Aug|Sep"}, + {d(2026, 8, 27), d(2026, 9, 27), "31 Aug|7 Sep|14 Sep|21 Sep"}, + } { + var got []string + for _, tk := range timeXTicks(chartScale{x0: c.from, x1: c.to}) { + got = append(got, tk.Label) + } + sep := " " + if strings.Contains(c.want, "|") { + sep = "|" + } + if g := strings.Join(got, sep); g != c.want { + t.Errorf("%s..%s: got %q, want %q", c.from.Format("2006-01-02"), c.to.Format("2006-01-02"), g, c.want) + } + } +}