package main import ( "bytes" "fmt" "html/template" "log" "math" "net/http" "net/url" "sort" "strconv" "strings" "time" ) func init() { pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{}) } 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 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 } weightView, weeklyView := fitnessViews(r.URL.Query()) return &specialPage{ Content: renderFitnessDashboard(fsPath, weightView, weeklyView), SuppressTOC: true, } } // serveFragment answers a chart's controls (htmx GET ?chart= with // every chart's view state) with just that chart, and points the address bar // at the canonical dashboard URL so the view stays bookmarkable. func (f *fitnessHandler) serveFragment(w http.ResponseWriter, r *http.Request, root, fsPath, urlPath string) bool { q := r.URL.Query() param := q.Get("chart") if param == "" { return false } if s := readPageSettings(fsPath); s == nil || s.Type != "fitness" { return false } weightView, weeklyView := fitnessViews(q) dash := buildFitnessDash(fsPath, weightView, weeklyView, time.Now()) for _, c := range dash.Charts { if c.Param != param { continue } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Replace-Url", fitnessURL(urlPath, weightView, weeklyView)) if err := fitnessTmpl.ExecuteTemplate(w, "fitnessChart", c); err != nil { log.Printf("fitness template: %v", err) } return true } http.Error(w, "Unknown chart: "+param, http.StatusNotFound) return 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 } // fitnessView is one chart's view state from the query string: ?= // and ?-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", } } // Default ranges of the two charts. const ( weightDefaultRange = "3m" weeklyDefaultRange = "1y" ) func fitnessViews(q url.Values) (weight, weekly fitnessView) { return chartViewFrom(q, "weight", weightDefaultRange), chartViewFrom(q, "weekly", weeklyDefaultRange) } // fitnessURL is the dashboard URL for the given views; defaults are left out. func fitnessURL(urlPath string, weight, weekly fitnessView) string { q := url.Values{} add := func(param, fallback string, v fitnessView) { if v.Range != fallback { q.Set(param, v.Range) } if v.Absolute { q.Set(param+"-absolute", "1") } } add("weight", weightDefaultRange, weight) add("weekly", weeklyDefaultRange, weekly) target := pageURL(urlPath) if len(q) > 0 { target += "?" + q.Encode() } return target } func rangeMonths(v string) int { for _, r := range fitnessRanges { if r.Value == v { return r.Months } } return 0 } // === Series extraction === type weightPoint struct { date time.Time value float64 days int // >0: weekly mean over this many measured days } // 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 } 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)) } // weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday // start) and averages over the days that have a measurement. Weeks without // any measurement produce no point. func weeklyMeanWeights(points []weightPoint) []weightPoint { type acc struct { sum float64 n int } byWeek := map[time.Time]*acc{} for _, p := range points { w := mondayOf(p.date) a := byWeek[w] if a == nil { a = &acc{} byWeek[w] = a } a.sum += p.value a.n++ } out := make([]weightPoint, 0, len(byWeek)) for w, a := range byWeek { out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n}) } 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) } 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 } // === View models === type fitnessOptVM struct { Value, Label string Selected bool } type fitnessTickVM struct { Pos, Label, Anchor string } // fitnessDotVM is one data point; Date and Value feed the hover tooltip. type fitnessDotVM struct { X, Y, Date, Value string } type fitnessGoalVM struct { Y, LabelY, Label string } type fitnessChartVM struct { Title string Param string Options []fitnessOptVM Absolute bool 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 Dense bool // too many points to draw markers } type fitnessDashVM struct { Notice string Charts []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 == view.Range} } return fitnessChartVM{ 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), YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14), } } // yTickVMs places gridlines at fixed multiples of step across the y domain // (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 { 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 === // 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, 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(view.Range); 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} yStep := 5.0 if view.Absolute { sc.y0 = 0 sc.y1 = hi + hi*0.05 yStep = 20 } 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. 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 { 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, } 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 for i, p := range points { 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()) } 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)) } return vm } 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 } // 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 } } out := make([]fitnessTickVM, 0, len(ticks)) for i, t := range ticks { x := sc.x(t) anchor := "middle" switch { case x-chartLeft < 24: anchor = "start" case chartW-chartRight-x < 24: anchor = "end" } 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 } // === Rendering === var fitnessTmpl = template.Must(template.New("main.html").Funcs(assetFuncs).ParseFS(assets, "assets/fitness/main.html")) 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) return "" } return template.HTML(buf.String()) } // 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 = in the calendars folder's .page-settings."} } today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) weights := fitnessWeights(today) wGoal, hasWGoal := weightGoal(fsPath) const unit = "kg" return fitnessDashVM{Charts: []fitnessChartVM{ buildWeightChart(weights, wGoal, hasWGoal, weightView, "weight", "Weight ("+unit+")", unit, today), buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView, "weekly", "Weekly average weight ("+unit+")", unit, today), }} }