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..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 . 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), }} }