diff --git a/assets/fitness/fitness.js b/assets/fitness/fitness.js
new file mode 100644
index 0000000..5be9373
--- /dev/null
+++ b/assets/fitness/fitness.js
@@ -0,0 +1,10 @@
+// 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.
+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);
+ window.location.href = url.toString();
+});
diff --git a/assets/fitness/main.html b/assets/fitness/main.html
new file mode 100644
index 0000000..1dc7777
--- /dev/null
+++ b/assets/fitness/main.html
@@ -0,0 +1,50 @@
+{{define "fitnessChart"}}
+
+
+ {{if .Empty}}
+ No data in this range.
+ {{else}}
+
+ {{range .YTicks}}
+
+ {{.Label}}
+ {{end}}
+ {{range .XTicks}}
+ {{.Label}}
+ {{end}}
+
+
+ {{range .Bars}}
+
+ {{.Title}}
+
+
+
+ {{end}}
+ {{range .Lines}}
+
+ {{end}}
+ {{range .Dots}}
+ {{.Title}}
+ {{end}}
+ {{if .Goal}}
+
+ {{.Goal.Label}}
+ {{end}}
+
+ {{end}}
+
+{{end}}
+
+{{if .Notice}}
+
{{.Notice}}
+{{else}}
+ {{range .Charts}}{{template "fitnessChart" .}}{{end}}
+{{end}}
+
+
diff --git a/assets/style.css b/assets/style.css
index db21ece..b7b59d8 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -70,6 +70,7 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
+.space-between { justify-content: space-between; }
.divider-dashed { border-bottom: var(--border-dashed); }
/* === Page layout ===
@@ -654,6 +655,30 @@ aside.sidebar:empty { display: none; }
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
.btn-block.cal-current { color: var(--primary-hover); }
+/* === Fitness dashboard ===
+ 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-header { margin-bottom: var(--space-2); }
+.fitness-range { width: auto; font-size: var(--font-sm); }
+.fitness-empty {
+ border: var(--border-dashed);
+ color: var(--text-muted);
+ text-align: center;
+ padding: var(--space-5);
+}
+.fitness-svg { display: block; width: 100%; height: auto; }
+.fitness-svg .chart-grid { stroke: var(--bg-panel-hover); }
+.fitness-svg .chart-axis { stroke: var(--text-muted); }
+.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-bar { fill: var(--secondary); }
+.fitness-svg .chart-net { stroke: var(--link); stroke-width: 1.5; }
+.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); }
+
/* === Responsive === */
@media (max-width: 1100px) {
.page-wrap { grid-template-columns: 1fr; }
diff --git a/diary.go b/diary.go
index 24723a2..b7921b7 100644
--- a/diary.go
+++ b/diary.go
@@ -159,7 +159,7 @@ func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok b
return "", "", "", false
}
-func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
+func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
if !ok {
return nil
@@ -544,7 +544,6 @@ func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntr
return weeks
}
-
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
type diaryPhoto struct {
Date time.Time
diff --git a/fitness.go b/fitness.go
new file mode 100644
index 0000000..b88611f
--- /dev/null
+++ b/fitness.go
@@ -0,0 +1,790 @@
+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),
+ }}
+}
diff --git a/main.go b/main.go
index f5c1fe7..ff71b00 100644
--- a/main.go
+++ b/main.go
@@ -58,7 +58,9 @@ type specialPage struct {
}
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
-// handle returns nil when the handler does not apply to the given path.
+// handle returns nil when the handler does not apply to the given path. The
+// request is passed read-only (e.g. query params selecting a view variant);
+// mutations belong in the POST flow, not here.
// redirect returns ok=true with an absolute URL when the request should be
// short-circuited with a 302 redirect (e.g. persistent date links in a diary,
// or virtual diary URLs in edit mode that delegate to the year file's editor).
@@ -66,7 +68,7 @@ type specialPage struct {
// When adding a new hook, prefer a sibling method here over folding logic
// into main.go or render.go.
type pageTypeHandler interface {
- handle(root, fsPath, urlPath string) *specialPage
+ handle(root, fsPath, urlPath string, r *http.Request) *specialPage
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
}
@@ -264,7 +266,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
var special *specialPage
if !editMode {
for _, ph := range pageTypeHandlers {
- if special = ph.handle(h.root, fsPath, urlPath); special != nil {
+ if special = ph.handle(h.root, fsPath, urlPath, r); special != nil {
break
}
}