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:
+170
-170
@@ -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: ?<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 {
|
||||
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.<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 ===
|
||||
|
||||
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
|
||||
// <title>; 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),
|
||||
}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user