Compare commits
1 Commits
f870a12cd5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d719b53404 |
+73
-64
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
@@ -40,8 +41,9 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
||||||
|
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
|
||||||
return &specialPage{
|
return &specialPage{
|
||||||
Content: renderFitnessDashboard(fsPath, weightSel),
|
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
|
||||||
SuppressTOC: true,
|
SuppressTOC: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +175,7 @@ func exportWeightUnit(settings json.RawMessage) string {
|
|||||||
type weightPoint struct {
|
type weightPoint struct {
|
||||||
date time.Time
|
date time.Time
|
||||||
value float64
|
value float64
|
||||||
|
days int // >0: weekly mean over this many measured days
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractWeights computes the weight series from the export: one point per
|
// extractWeights computes the weight series from the export: one point per
|
||||||
@@ -189,12 +192,43 @@ func extractWeights(ex *wlExport) []weightPoint {
|
|||||||
}
|
}
|
||||||
weights := make([]weightPoint, 0, len(byDate))
|
weights := make([]weightPoint, 0, len(byDate))
|
||||||
for d, v := range byDate {
|
for d, v := range byDate {
|
||||||
weights = append(weights, weightPoint{d, v})
|
weights = append(weights, weightPoint{date: d, value: v})
|
||||||
}
|
}
|
||||||
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||||
return weights
|
return weights
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ===
|
// === SVG geometry ===
|
||||||
|
|
||||||
// Chart canvas in viewBox units. The SVG scales to container width via
|
// Chart canvas in viewBox units. The SVG scales to container width via
|
||||||
@@ -235,29 +269,6 @@ func (s chartScale) y(v float64) float64 {
|
|||||||
return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
|
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 ===
|
// === View models ===
|
||||||
|
|
||||||
type fitnessOptVM struct {
|
type fitnessOptVM struct {
|
||||||
@@ -311,9 +322,11 @@ func newChartVM(title, param, sel string) fitnessChartVM {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func yTickVMs(sc chartScale, n int) []fitnessTickVM {
|
// yTickVMs places gridlines at fixed multiples of step across the y domain
|
||||||
|
// (always 5 kg guides on weight charts, regardless of range).
|
||||||
|
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
|
||||||
var out []fitnessTickVM
|
var out []fitnessTickVM
|
||||||
for _, v := range niceTicks(sc.y0, sc.y1, n) {
|
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)})
|
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -330,8 +343,11 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
|
|||||||
|
|
||||||
// === Chart builders ===
|
// === Chart builders ===
|
||||||
|
|
||||||
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit string, today time.Time) fitnessChartVM {
|
// buildWeightChart renders a weight line chart. It serves both the per-day
|
||||||
vm := newChartVM("Weight ("+unit+")", "weight", sel)
|
// 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, sel, param, title, unit string, today time.Time) fitnessChartVM {
|
||||||
|
vm := newChartVM(title, param, sel)
|
||||||
|
|
||||||
points := all
|
points := all
|
||||||
var x0, x1 time.Time
|
var x0, x1 time.Time
|
||||||
@@ -376,45 +392,34 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit s
|
|||||||
}
|
}
|
||||||
sc := chartScale{x0, x1, lo - pad, hi + pad}
|
sc := chartScale{x0, x1, lo - pad, hi + pad}
|
||||||
|
|
||||||
vm.YTicks = yTickVMs(sc, 4)
|
vm.YTicks = yTickVMs(sc, 5)
|
||||||
vm.XTicks = timeXTicks(sc, 4)
|
vm.XTicks = timeXTicks(sc, 4)
|
||||||
|
|
||||||
// Point markers carry the hover <title>. On dense ranges (1y/All) the
|
// One continuous polyline through every point in range — days without a
|
||||||
// markers are dropped and the bare polyline stays legible; gaps in the
|
// measurement do not break the line. Point markers carry the hover
|
||||||
// data split the line into segments — no interpolation.
|
// <title>; on dense ranges they are dropped and the bare line stays
|
||||||
markAll := len(points) <= 100
|
// legible. A single point in range renders as a dot.
|
||||||
dot := func(p weightPoint) fitnessDotVM {
|
dot := func(p weightPoint) fitnessDotVM {
|
||||||
return fitnessDotVM{
|
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
|
||||||
X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)),
|
if p.days > 0 {
|
||||||
Title: p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit,
|
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
|
||||||
|
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
|
||||||
}
|
}
|
||||||
|
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
|
||||||
}
|
}
|
||||||
var seg []weightPoint
|
if len(points) >= 2 {
|
||||||
flush := func() {
|
var b strings.Builder
|
||||||
if len(seg) >= 2 {
|
for i, p := range points {
|
||||||
var b strings.Builder
|
if i > 0 {
|
||||||
for i, p := range seg {
|
b.WriteByte(' ')
|
||||||
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())
|
b.WriteString(svgNum(sc.x(p.date)))
|
||||||
} else if len(seg) == 1 && !markAll {
|
b.WriteByte(',')
|
||||||
vm.Dots = append(vm.Dots, dot(seg[0]))
|
b.WriteString(svgNum(sc.y(p.value)))
|
||||||
}
|
}
|
||||||
seg = seg[:0]
|
vm.Lines = append(vm.Lines, b.String())
|
||||||
}
|
}
|
||||||
for i, p := range points {
|
if len(points) <= 100 {
|
||||||
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 {
|
for _, p := range points {
|
||||||
vm.Dots = append(vm.Dots, dot(p))
|
vm.Dots = append(vm.Dots, dot(p))
|
||||||
}
|
}
|
||||||
@@ -462,8 +467,8 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
|||||||
|
|
||||||
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
||||||
|
|
||||||
func renderFitnessDashboard(fsPath, weightSel string) template.HTML {
|
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
||||||
data := buildFitnessDash(fsPath, weightSel, time.Now())
|
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
||||||
log.Printf("fitness template: %v", err)
|
log.Printf("fitness template: %v", err)
|
||||||
@@ -475,7 +480,7 @@ func renderFitnessDashboard(fsPath, weightSel string) template.HTML {
|
|||||||
// buildFitnessDash reads and parses the export per request — no caching, no
|
// buildFitnessDash reads and parses the export per request — no caching, no
|
||||||
// indexes. A read or parse failure (including a truncated mid-upload file)
|
// indexes. A read or parse failure (including a truncated mid-upload file)
|
||||||
// becomes an inline notice; the page itself always renders.
|
// becomes an inline notice; the page itself always renders.
|
||||||
func buildFitnessDash(fsPath, weightSel string, now time.Time) fitnessDashVM {
|
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
|
||||||
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
||||||
@@ -488,8 +493,12 @@ func buildFitnessDash(fsPath, weightSel string, now time.Time) fitnessDashVM {
|
|||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
weights := extractWeights(&ex)
|
weights := extractWeights(&ex)
|
||||||
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
||||||
|
unit := exportWeightUnit(ex.Settings)
|
||||||
|
|
||||||
return fitnessDashVM{Charts: []fitnessChartVM{
|
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||||
buildWeightChart(weights, wGoal, hasWGoal, weightSel, exportWeightUnit(ex.Settings), today),
|
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
|
||||||
|
"weight", "Weight ("+unit+")", unit, today),
|
||||||
|
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
|
||||||
|
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user