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
This commit is contained in:
2026-09-27 09:31:01 +02:00
parent 4116a0c332
commit 189635d103
7 changed files with 365 additions and 189 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-y
## Fitness ## 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 ## Calendars
+70 -6
View File
@@ -1,10 +1,74 @@
// Fitness dashboard range dropdowns: changing one reloads the page with that // Fitness dashboard range dropdowns and "absolute" checkboxes: changing one
// chart's query parameter updated. Plain GET navigation — each range is a // reloads the page with that chart's query parameter updated. Plain GET
// distinct, bookmarkable view, so no postReplace/history handling is needed. // navigation — each view is distinct and bookmarkable, so no
// postReplace/history handling is needed.
document.addEventListener('change', function (e) { document.addEventListener('change', function (e) {
var sel = e.target.closest('[data-fitness-range]');
if (!sel) return;
var url = new URL(window.location.href); 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(); 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';
});
+12 -5
View File
@@ -2,19 +2,23 @@
<section class="fitness-chart panel"> <section class="fitness-chart panel">
<div class="fitness-chart-header row space-between"> <div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span> <span class="caption">{{.Title}}</span>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range"> <div class="row fitness-controls">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}} <label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
</select> <select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
</div> </div>
{{if .Empty}} {{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p> <p class="fitness-empty is-empty">No data in this range.</p>
{{else}} {{else}}
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}"> <svg class="fitness-svg{{if .Dense}} is-dense{{end}}" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
{{range .YTicks}} {{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/> <line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text> <text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}} {{end}}
{{range .XTicks}} {{range .XTicks}}
<line class="chart-grid" x1="{{.Pos}}" y1="{{$.PlotY}}" x2="{{.Pos}}" y2="{{$.PlotB}}"/>
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text> <text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}} {{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/> <line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
@@ -23,13 +27,16 @@
<polyline class="chart-line" points="{{.}}"/> <polyline class="chart-line" points="{{.}}"/>
{{end}} {{end}}
{{range .Dots}} {{range .Dots}}
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle> <circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5" data-date="{{.Date}}" data-value="{{.Value}}"/>
{{end}} {{end}}
{{if .Goal}} {{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/> <line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text> <text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}} {{end}}
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
<circle class="chart-focus" r="4"/>
</svg> </svg>
<div class="fitness-tip" hidden><strong></strong><span></span></div>
{{end}} {{end}}
</section> </section>
{{end}} {{end}}
+21 -3
View File
@@ -837,8 +837,10 @@ aside.sidebar:empty { display: none; }
Server-rendered inline SVG charts. Geometry comes precomputed from Go; Server-rendered inline SVG charts. Geometry comes precomputed from Go;
colors and strokes are applied here via classes so the inline SVG follows colors and strokes are applied here via classes so the inline SVG follows
the theme palette. */ 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-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-range { width: auto; font-size: var(--font-sm); }
.fitness-empty { .fitness-empty {
border: var(--border-dashed); 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-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-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
.fitness-svg .chart-dot { fill: var(--link); } .fitness-svg .chart-dot { fill: var(--link); }
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; } .fitness-svg.is-dense .chart-dot { display: none; }
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); } .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 === */ /* === Responsive === */
@media (max-width: 1100px) { @media (max-width: 1100px) {
+15 -4
View File
@@ -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{} stamps := map[string]time.Time{}
for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) { for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) {
m := weightTitle.FindStringSubmatch(ev.Summary) m := weightTitle.FindStringSubmatch(ev.Summary)
@@ -653,14 +667,11 @@ func calendarDayLines(from, to time.Time) map[string]calDayLines {
continue continue
} }
key := ev.Start.Format("2006-01-02") 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) { if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) {
continue continue
} }
stamps[key] = ev.Stamp stamps[key] = ev.Stamp
lines := out[key] out[key] = strings.ReplaceAll(m[1], ",", ".")
lines.Weight = strings.ReplaceAll(m[1], ".", ",") + " kg"
out[key] = lines
} }
return out return out
} }
+170 -170
View File
@@ -2,14 +2,12 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"math" "math"
"net/http" "net/http"
"os" "net/url"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -20,10 +18,6 @@ func init() {
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{}) 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{} type fitnessHandler struct{}
// redirect: the fitness dashboard has no virtual URLs; everything renders // 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 // handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so // 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 { func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath) s := readPageSettings(fsPath)
if s == nil || s.Type != "fitness" { if s == nil || s.Type != "fitness" {
return nil return nil
} }
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m") q := r.URL.Query()
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
return &specialPage{ return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel), Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
SuppressTOC: true, SuppressTOC: true,
} }
} }
@@ -72,6 +65,20 @@ func validFitnessRange(v, fallback string) string {
return fallback return fallback
} }
// fitnessView is one chart's view state from the query string: ?<param>=<range>
// and ?<param>-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 { func rangeMonths(v string) int {
for _, r := range fitnessRanges { for _, r := range fitnessRanges {
if r.Value == v { if r.Value == v {
@@ -81,95 +88,6 @@ func rangeMonths(v string) int {
return 0 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.<key>.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 === // === Series extraction ===
type weightPoint struct { type weightPoint struct {
@@ -178,26 +96,34 @@ type weightPoint struct {
days int // >0: weekly mean over this many measured days days int // >0: weekly mean over this many measured days
} }
// extractWeights computes the weight series from the export: one point per // fitnessWeights reads the whole weight calendar up to today: one point per
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last // day, sorted by date. Dates are UTC midnights to match the chart's today.
// one wins. func fitnessWeights(today time.Time) []weightPoint {
func extractWeights(ex *wlExport) []weightPoint { byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1))
byDate := map[time.Time]float64{} weights := make([]weightPoint, 0, len(byDay))
for _, e := range ex.Diary { for key, w := range byDay {
day, ok := exportDate(e.DateTime) d, err := time.Parse("2006-01-02", key)
if !ok || !e.Stats.Weight.ok { v, err2 := strconv.ParseFloat(w, 64)
if err != nil || err2 != nil {
continue 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}) weights = append(weights, weightPoint{date: d, value: v})
} }
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) }) sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights 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 { func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7)) return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
} }
@@ -280,8 +206,9 @@ type fitnessTickVM struct {
Pos, Label, Anchor string Pos, Label, Anchor string
} }
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
type fitnessDotVM struct { type fitnessDotVM struct {
X, Y, Title string X, Y, Date, Value string
} }
type fitnessGoalVM struct { type fitnessGoalVM struct {
@@ -289,10 +216,11 @@ type fitnessGoalVM struct {
} }
type fitnessChartVM struct { type fitnessChartVM struct {
Title string Title string
Param string Param string
Options []fitnessOptVM Options []fitnessOptVM
Empty bool Absolute bool
Empty bool
ViewW, ViewH string ViewW, ViewH string
PlotX, PlotY, PlotR, PlotB string PlotX, PlotY, PlotR, PlotB string
@@ -301,6 +229,7 @@ type fitnessChartVM struct {
Goal *fitnessGoalVM Goal *fitnessGoalVM
Lines []string // polyline points attributes Lines []string // polyline points attributes
Dots []fitnessDotVM Dots []fitnessDotVM
Dense bool // too many points to draw markers
} }
type fitnessDashVM struct { type fitnessDashVM struct {
@@ -308,13 +237,13 @@ type fitnessDashVM struct {
Charts []fitnessChartVM Charts []fitnessChartVM
} }
func newChartVM(title, param, sel string) fitnessChartVM { func newChartVM(title, param string, view fitnessView) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges)) opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range 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{ return fitnessChartVM{
Title: title, Param: param, Options: opts, Title: title, Param: param, Options: opts, Absolute: view.Absolute,
ViewW: svgNum(chartW), ViewH: svgNum(chartH), ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop), PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom), 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 // 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 { func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM var out []fitnessTickVM
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step { 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 // 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 // series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement. // 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 { func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, sel) vm := newChartVM(title, param, view)
points := all points := all
var x0, x1 time.Time var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 { if m := rangeMonths(view.Range); m > 0 {
x0 = today.AddDate(0, -m, 0) x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0) points = filterWeights(all, x0)
if len(points) == 0 { if len(points) == 0 {
@@ -391,21 +320,31 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
pad = 1 pad = 1
} }
sc := chartScale{x0, x1, lo - pad, hi + pad} 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.YTicks = yTickVMs(sc, yStep)
vm.XTicks = timeXTicks(sc, 4) vm.XTicks = timeXTicks(sc)
// One continuous polyline through every point in range — days without a // One continuous polyline through every point in range — days without a
// measurement do not break the line. Point markers carry the hover // measurement do not break the line. Every point also gets a marker that
// <title>; on dense ranges they are dropped and the bare line stays // feeds the hover tooltip; on dense ranges the markers are hidden and the
// legible. A single point in range renders as a dot. // bare line stays legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM { dot := func(p weightPoint) fitnessDotVM {
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit d := fitnessDotVM{
if p.days > 0 { X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)),
label = fmt.Sprintf("Week of %s: %s %s (%d days)", Date: p.date.Format("Mon 2006-01-02"),
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days) 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 { if len(points) >= 2 {
var b strings.Builder 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()) vm.Lines = append(vm.Lines, b.String())
} }
if len(points) <= 100 { for _, p := range points {
for _, p := range points { vm.Dots = append(vm.Dots, dot(p))
vm.Dots = append(vm.Dots, dot(p))
}
} }
vm.Dense = len(points) > 100
if hasGoal { if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal)) vm.setGoal(sc, goal, "goal "+svgNum(goal))
@@ -441,24 +379,91 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
return out return out
} }
// timeXTicks places n+1 evenly spaced date labels across a continuous time // xTickStep is a calendar-aligned spacing for date labels.
// axis; the last label is end-anchored so it stays inside the viewBox. type xTickStep struct {
func timeXTicks(sc chartScale, n int) []fitnessTickVM { days, months int
span := sc.x1.Sub(sc.x0) label func(t time.Time, first bool) string
var out []fitnessTickVM }
prev := ""
for i := 0; i <= n; i++ { var xTickSteps = []xTickStep{
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n))) {days: 1, label: dayLabel},
label := t.Format("2006-01-02") {days: 7, label: dayLabel},
if label == prev { {days: 14, label: dayLabel},
continue {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" anchor := "middle"
if i == n { switch {
case x-chartLeft < 24:
anchor = "start"
case chartW-chartRight-x < 24:
anchor = "end" 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 return out
} }
@@ -467,8 +472,8 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM {
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html")) var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML { func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML {
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now()) data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
var buf bytes.Buffer var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil { if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err) log.Printf("fitness template: %v", err)
@@ -477,28 +482,23 @@ func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
return template.HTML(buf.String()) return template.HTML(buf.String())
} }
// buildFitnessDash reads and parses the export per request — no caching, no // buildFitnessDash reads the weight calendar per request (the calendar store
// indexes. A read or parse failure (including a truncated mid-upload file) // caches parsed files). A missing calendar setup becomes an inline notice;
// becomes an inline notice; the page itself always renders. // the page itself always renders.
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM { func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM {
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile)) if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 {
if err != nil { return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."}
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()}
} }
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := extractWeights(&ex) weights := fitnessWeights(today)
wGoal, hasWGoal := goalValue(ex.Settings, "weight") wGoal, hasWGoal := weightGoal(fsPath)
unit := exportWeightUnit(ex.Settings) const unit = "kg"
return fitnessDashVM{Charts: []fitnessChartVM{ return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel, buildWeightChart(weights, wGoal, hasWGoal, weightView,
"weight", "Weight ("+unit+")", unit, today), "weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel, buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView,
"weekly", "Weekly average weight ("+unit+")", unit, today), "weekly", "Weekly average weight ("+unit+")", unit, today),
}} }}
} }
+76
View File
@@ -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)
}
}
}