505 lines
14 KiB
Go
505 lines
14 KiB
Go
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
|
|
}
|
|
q := r.URL.Query()
|
|
return &specialPage{
|
|
Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
|
|
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
|
|
}
|
|
|
|
// fitnessView is one chart's view state from the query string: ?<param>=<range>
|
|
// and ?<param>-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",
|
|
}
|
|
}
|
|
|
|
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 = <calendar> 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),
|
|
}}
|
|
}
|