Files
datascape/fitness.go
T
2026-06-11 12:55:43 +02:00

791 lines
20 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
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
// inside the normal GET /{path}/ flow.
func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
return "", false
}
// 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.
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")
calSel := validFitnessRange(r.URL.Query().Get("calories"), "1m")
return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel, calSel),
SuppressTOC: true,
}
}
// === Time ranges ===
type fitnessRange struct {
Value string
Label string
Months int // 0 = all data
}
var fitnessRanges = []fitnessRange{
{"1m", "1 month", 1},
{"3m", "3 months", 3},
{"1y", "1 year", 12},
{"all", "All", 0},
}
func validFitnessRange(v, fallback string) string {
for _, r := range fitnessRanges {
if r.Value == v {
return v
}
}
return fallback
}
func rangeMonths(v string) int {
for _, r := range fitnessRanges {
if r.Value == v {
return r.Months
}
}
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
}
type wlExport struct {
Diary []wlDiaryEntry `json:"diary"`
FoodList []wlFood `json:"foodList"`
Recipes []wlRecipe `json:"recipes"`
Settings json.RawMessage `json:"settings"`
}
type wlDiaryEntry struct {
DateTime string `json:"dateTime"`
Items []wlItem `json:"items"`
Stats struct {
Weight wlNum `json:"weight"`
} `json:"stats"`
}
type wlItem struct {
ID wlNum `json:"id"`
Type string `json:"type"`
Portion wlNum `json:"portion"`
Quantity wlNum `json:"quantity"`
}
type wlFood struct {
ID wlNum `json:"id"`
Unit string `json:"unit"`
Nutrition struct {
Calories wlNum `json:"calories"`
} `json:"nutrition"`
}
type wlRecipe struct {
ID wlNum `json:"id"`
Portion wlNum `json:"portion"`
Nutrition struct {
Calories wlNum `json:"calories"`
} `json:"nutrition"`
}
// 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 exportUnits(settings json.RawMessage) (weight, energy string) {
weight, energy = "kg", "kcal"
var s struct {
Units struct {
Weight string `json:"weight"`
Energy string `json:"energy"`
} `json:"units"`
}
if json.Unmarshal(settings, &s) == nil {
if s.Units.Weight != "" {
weight = s.Units.Weight
}
if s.Units.Energy != "" {
energy = s.Units.Energy
}
}
return
}
// === Series extraction ===
type weightPoint struct {
date time.Time
value float64
}
// calorieBarData is one bar: a day in per-day mode, or an ISO week of mean
// daily values after weekly aggregation (days > 0 marks the weekly case).
type calorieBarData struct {
date time.Time
gross float64
net float64
days int
}
// extractSeries computes the weight and calories-per-day series from the
// export. Duplicate dates (shouldn't happen) — last one wins. Items whose id
// resolves to nothing in foodList/recipes are skipped silently.
func extractSeries(ex *wlExport) ([]weightPoint, []calorieBarData) {
foods := make(map[int]*wlFood, len(ex.FoodList))
for i := range ex.FoodList {
if ex.FoodList[i].ID.ok {
foods[int(ex.FoodList[i].ID.val)] = &ex.FoodList[i]
}
}
recipes := make(map[int]*wlRecipe, len(ex.Recipes))
for i := range ex.Recipes {
if ex.Recipes[i].ID.ok {
recipes[int(ex.Recipes[i].ID.val)] = &ex.Recipes[i]
}
}
weightByDate := map[time.Time]float64{}
calByDate := map[time.Time]calorieBarData{}
for _, e := range ex.Diary {
day, ok := exportDate(e.DateTime)
if !ok {
continue
}
if e.Stats.Weight.ok {
weightByDate[day] = e.Stats.Weight.val
}
gross, net, counted := dayCalories(e.Items, foods, recipes)
if counted > 0 {
calByDate[day] = calorieBarData{date: day, gross: gross, net: net}
}
}
weights := make([]weightPoint, 0, len(weightByDate))
for d, v := range weightByDate {
weights = append(weights, weightPoint{d, v})
}
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
calories := make([]calorieBarData, 0, len(calByDate))
for _, c := range calByDate {
calories = append(calories, c)
}
sort.Slice(calories, func(i, j int) bool { return calories[i].date.Before(calories[j].date) })
return weights, calories
}
// dayCalories sums one diary day. Gross counts only positive item calories;
// net includes negative quick-adds (logged exercise). counted is the number
// of items that resolved — a day with no countable items gets no bar.
func dayCalories(items []wlItem, foods map[int]*wlFood, recipes map[int]*wlRecipe) (gross, net float64, counted int) {
for _, it := range items {
cal, ok := itemCalories(it, foods, recipes)
if !ok {
continue
}
counted++
net += cal
if cal > 0 {
gross += cal
}
}
return
}
// itemCalories computes one diary item's calories. The formula is inferred
// from export data (Waistline does not document it):
// - food with unit g/ml: nutrition is per 100 → calories * portion/100 * quantity
// - food with any other unit: nutrition is per portion → calories * quantity
// (this also covers quick-adds: the "Quick Add" food has calories=1, so
// quantity is the kcal value, negative for exercise)
// - recipe: recipe calories are for the whole recipe → calories / recipe
// portion * quantity
func itemCalories(it wlItem, foods map[int]*wlFood, recipes map[int]*wlRecipe) (float64, bool) {
if !it.ID.ok || !it.Quantity.ok {
return 0, false
}
id := int(it.ID.val)
qty := it.Quantity.val
if it.Type == "recipe" {
rec := recipes[id]
if rec == nil || !rec.Nutrition.Calories.ok || !rec.Portion.ok || rec.Portion.val == 0 {
return 0, false
}
return rec.Nutrition.Calories.val / rec.Portion.val * qty, true
}
food := foods[id]
if food == nil || !food.Nutrition.Calories.ok {
return 0, false
}
switch food.Unit {
case "g", "ml":
if !it.Portion.ok {
return 0, false
}
return food.Nutrition.Calories.val * it.Portion.val / 100 * qty, true
}
return food.Nutrition.Calories.val * qty, true
}
func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
}
// aggregateWeekly buckets days into ISO weeks (Monday start). Bar values are
// mean *daily* gross/net over the days in the bucket that have logged items,
// so the daily goal line stays directly comparable. Partial weeks at the
// range edges keep whatever days they have.
func aggregateWeekly(days []calorieBarData) []calorieBarData {
byWeek := map[time.Time]*calorieBarData{}
for _, d := range days {
w := mondayOf(d.date)
a := byWeek[w]
if a == nil {
a = &calorieBarData{date: w}
byWeek[w] = a
}
a.gross += d.gross
a.net += d.net
a.days++
}
out := make([]calorieBarData, 0, len(byWeek))
for _, a := range byWeek {
a.gross /= float64(a.days)
a.net /= float64(a.days)
out = append(out, *a)
}
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
// viewBox + width:100%, so these only set proportions and text size.
const (
chartW = 560.0
chartH = 240.0
chartLeft = 46.0
chartRight = 8.0
chartTop = 10.0
chartBottom = 24.0
)
// svgNum formats a coordinate or display value: rounded to 2 decimals,
// trailing zeros trimmed.
func svgNum(v float64) string {
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
}
func kcalNum(v float64) string {
return strconv.Itoa(int(math.Round(v)))
}
type chartScale struct {
x0, x1 time.Time
y0, y1 float64
}
func (s chartScale) x(t time.Time) float64 {
span := s.x1.Sub(s.x0).Seconds()
if span <= 0 {
return chartLeft
}
return chartLeft + (chartW-chartLeft-chartRight)*t.Sub(s.x0).Seconds()/span
}
func (s chartScale) y(v float64) float64 {
span := s.y1 - s.y0
if span <= 0 {
return chartH - chartBottom
}
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 {
Value, Label string
Selected bool
}
type fitnessTickVM struct {
Pos, Label, Anchor string
}
type fitnessDotVM struct {
X, Y, Title string
}
type fitnessBarVM struct {
X, Y, W, H, X2, NetY, Title string
}
type fitnessGoalVM struct {
Y, LabelY, Label string
}
type fitnessChartVM struct {
Title string
Param string
Options []fitnessOptVM
Empty bool
ViewW, ViewH string
PlotX, PlotY, PlotR, PlotB string
YLabelX, XLabelY string
YTicks, XTicks []fitnessTickVM
Goal *fitnessGoalVM
Lines []string // polyline points attributes
Dots []fitnessDotVM
Bars []fitnessBarVM
}
type fitnessDashVM struct {
Notice string
Charts []fitnessChartVM
}
func newChartVM(title, param, sel string) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range fitnessRanges {
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
}
return fitnessChartVM{
Title: title, Param: param, Options: opts,
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14),
}
}
func yTickVMs(sc chartScale, n int) []fitnessTickVM {
var out []fitnessTickVM
for _, v := range niceTicks(sc.y0, sc.y1, n) {
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
}
return out
}
func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
gy := sc.y(goal)
ly := gy - 4
if ly < chartTop+10 {
ly = gy + 12
}
vm.Goal = &fitnessGoalVM{Y: svgNum(gy), LabelY: svgNum(ly), Label: label}
}
// === Chart builders ===
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit string, today time.Time) fitnessChartVM {
vm := newChartVM("Weight ("+unit+")", "weight", sel)
points := all
var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 {
x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0)
if len(points) == 0 {
vm.Empty = true
return vm
}
x1 = today
if last := points[len(points)-1].date; last.After(x1) {
x1 = last
}
} else {
if len(points) == 0 {
vm.Empty = true
return vm
}
x0 = points[0].date
x1 = points[len(points)-1].date
}
// Degenerate domain (single point on All): widen so the dot sits inside
// the plot instead of on its edge.
if !x0.Before(x1) {
x0 = x0.AddDate(0, 0, -1)
x1 = x1.AddDate(0, 0, 1)
}
lo, hi := points[0].value, points[0].value
for _, p := range points {
lo = min(lo, p.value)
hi = max(hi, p.value)
}
if hasGoal {
lo = min(lo, goal)
hi = max(hi, goal)
}
pad := (hi - lo) * 0.05
if pad == 0 {
pad = 1
}
sc := chartScale{x0, x1, lo - pad, hi + pad}
vm.YTicks = yTickVMs(sc, 4)
vm.XTicks = timeXTicks(sc, 4)
// Point markers carry the hover <title>. 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
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,
}
}
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)))
}
vm.Lines = append(vm.Lines, b.String())
} else if len(seg) == 1 && !markAll {
vm.Dots = append(vm.Dots, dot(seg[0]))
}
seg = seg[:0]
}
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 {
for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
}
if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal))
}
return vm
}
func buildCaloriesChart(all []calorieBarData, goal float64, hasGoal bool, sel, unit string, today time.Time) fitnessChartVM {
vm := newChartVM("Calories ("+unit+"/day)", "calories", sel)
days := all
var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 {
x0 = today.AddDate(0, -m, 0)
days = filterCalories(all, x0)
if len(days) == 0 {
vm.Empty = true
return vm
}
x1 = today
if last := days[len(days)-1].date; last.After(x1) {
x1 = last
}
} else {
if len(days) == 0 {
vm.Empty = true
return vm
}
x0 = days[0].date
x1 = days[len(days)-1].date
}
// Long ranges aggregate to weekly bars so they stay readable.
step := 1
if sel == "1y" || sel == "all" {
days = aggregateWeekly(days)
x0 = mondayOf(x0)
x1 = mondayOf(x1)
step = 7
}
slots := int(x1.Sub(x0).Hours()/24)/step + 1
lo, hi := 0.0, 0.0
for _, d := range days {
hi = max(hi, d.gross)
lo = min(lo, d.net)
}
if hasGoal {
hi = max(hi, goal)
}
if hi == 0 && lo == 0 {
hi = 1
}
span := hi - lo
hi += span * 0.05
if lo < 0 {
lo -= span * 0.05
}
sc := chartScale{x0, x1, lo, hi}
vm.YTicks = yTickVMs(sc, 4)
vm.XTicks = slotXTicks(x0, step, slots)
plotW := chartW - chartLeft - chartRight
slotW := plotW / float64(slots)
barW := slotW * 0.7
y0pos := sc.y(0)
for _, d := range days {
idx := int(d.date.Sub(x0).Hours()/24) / step
xs := chartLeft + (float64(idx)+0.15)*slotW
yTop := sc.y(d.gross)
vm.Bars = append(vm.Bars, fitnessBarVM{
X: svgNum(xs), W: svgNum(barW), X2: svgNum(xs + barW),
Y: svgNum(yTop), H: svgNum(max(0, y0pos-yTop)),
NetY: svgNum(sc.y(d.net)),
Title: barTitle(d, unit),
})
}
if hasGoal {
vm.setGoal(sc, goal, "goal "+kcalNum(goal))
}
return vm
}
func barTitle(d calorieBarData, unit string) string {
if d.days > 0 {
return fmt.Sprintf("Week of %s (%d days)\nintake ø %s %s/day\nnet ø %s %s/day",
d.date.Format("2006-01-02"), d.days,
kcalNum(d.gross), unit, kcalNum(d.net), unit)
}
return fmt.Sprintf("%s\nintake %s %s\nnet %s %s",
d.date.Format("2006-01-02"),
kcalNum(d.gross), unit, kcalNum(d.net), unit)
}
func filterWeights(points []weightPoint, from time.Time) []weightPoint {
var out []weightPoint
for _, p := range points {
if !p.date.Before(from) {
out = append(out, p)
}
}
return out
}
func filterCalories(days []calorieBarData, from time.Time) []calorieBarData {
var out []calorieBarData
for _, d := range days {
if !d.date.Before(from) {
out = append(out, d)
}
}
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
}
prev = label
anchor := "middle"
if i == n {
anchor = "end"
}
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
}
return out
}
// slotXTicks places up to 5 date labels at slot centers of a bar axis.
func slotXTicks(x0 time.Time, step, slots int) []fitnessTickVM {
n := 4
if slots-1 < n {
n = slots - 1
}
slotW := (chartW - chartLeft - chartRight) / float64(slots)
var out []fitnessTickVM
prevIdx := -1
for i := 0; i <= n; i++ {
idx := slots - 1
if n > 0 {
idx = int(math.Round(float64(i) * float64(slots-1) / float64(n)))
}
if idx == prevIdx {
continue
}
prevIdx = idx
anchor := "middle"
if i == n {
anchor = "end"
}
out = append(out, fitnessTickVM{
Pos: svgNum(chartLeft + (float64(idx)+0.5)*slotW),
Label: x0.AddDate(0, 0, idx*step).Format("2006-01-02"),
Anchor: anchor,
})
}
return out
}
// === Rendering ===
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath, weightSel, calSel string) template.HTML {
data := buildFitnessDash(fsPath, weightSel, calSel, time.Now())
var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err)
return ""
}
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, calSel 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()}
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights, calories := extractSeries(&ex)
weightUnit, energyUnit := exportUnits(ex.Settings)
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
cGoal, hasCGoal := goalValue(ex.Settings, "calories")
return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel, weightUnit, today),
buildCaloriesChart(calories, cGoal, hasCGoal, calSel, energyUnit, today),
}}
}