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"}} +
+
+ {{.Title}} + +
+ {{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 } }