Compare commits
3 Commits
4f14b39d16
...
f870a12cd5
| Author | SHA1 | Date | |
|---|---|---|---|
| f870a12cd5 | |||
| 0b62cd50f3 | |||
| fde4eff12d |
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "fitnessChart"}}
|
||||||
|
<section class="fitness-chart panel">
|
||||||
|
<div class="fitness-chart-header row space-between">
|
||||||
|
<span class="caption">{{.Title}}</span>
|
||||||
|
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
||||||
|
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{if .Empty}}
|
||||||
|
<p class="fitness-empty is-empty">No data in this range.</p>
|
||||||
|
{{else}}
|
||||||
|
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
|
||||||
|
{{range .YTicks}}
|
||||||
|
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
|
||||||
|
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
{{range .XTicks}}
|
||||||
|
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
||||||
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
|
||||||
|
{{range .Lines}}
|
||||||
|
<polyline class="chart-line" points="{{.}}"/>
|
||||||
|
{{end}}
|
||||||
|
{{range .Dots}}
|
||||||
|
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
|
||||||
|
{{end}}
|
||||||
|
{{if .Goal}}
|
||||||
|
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
|
||||||
|
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
</svg>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<div class="fitness-dash col">
|
||||||
|
{{if .Notice}}
|
||||||
|
<p class="muted">{{.Notice}}</p>
|
||||||
|
{{else}}
|
||||||
|
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<script src="/_/fitness/fitness.js"></script>
|
||||||
@@ -17,7 +17,7 @@ function openViewSettings() {
|
|||||||
options.forEach(function (opt) {
|
options.forEach(function (opt) {
|
||||||
var b = document.createElement('button');
|
var b = document.createElement('button');
|
||||||
b.type = 'button';
|
b.type = 'button';
|
||||||
b.className = 'btn btn-small';
|
b.className = 'btn';
|
||||||
b.textContent = opt.label;
|
b.textContent = opt.label;
|
||||||
if (state[key] === opt.value) b.classList.add('is-active');
|
if (state[key] === opt.value) b.classList.add('is-active');
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
|||||||
+24
-1
@@ -70,6 +70,7 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
|
|||||||
.gap-2 { gap: var(--space-2); }
|
.gap-2 { gap: var(--space-2); }
|
||||||
.gap-3 { gap: var(--space-3); }
|
.gap-3 { gap: var(--space-3); }
|
||||||
.gap-4 { gap: var(--space-4); }
|
.gap-4 { gap: var(--space-4); }
|
||||||
|
.space-between { justify-content: space-between; }
|
||||||
.divider-dashed { border-bottom: var(--border-dashed); }
|
.divider-dashed { border-bottom: var(--border-dashed); }
|
||||||
|
|
||||||
/* === Page layout ===
|
/* === Page layout ===
|
||||||
@@ -166,7 +167,7 @@ footer {
|
|||||||
.btn::before { content: "["; color: var(--secondary); }
|
.btn::before { content: "["; color: var(--secondary); }
|
||||||
.btn::after { content: "]"; color: var(--secondary); }
|
.btn::after { content: "]"; color: var(--secondary); }
|
||||||
.btn:hover { color: var(--primary-hover); }
|
.btn:hover { color: var(--primary-hover); }
|
||||||
.btn-small { font-size: 0.65rem; font-weight: normal; vertical-align: middle; }
|
.btn-small { font-size: 0.8rem; font-weight: normal; vertical-align: middle; }
|
||||||
.btn-tool { padding: 0 0.15rem; }
|
.btn-tool { padding: 0 0.15rem; }
|
||||||
.btn-block {
|
.btn-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -654,6 +655,28 @@ aside.sidebar:empty { display: none; }
|
|||||||
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
||||||
.btn-block.cal-current { 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-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
|
||||||
|
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
||||||
|
|
||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.page-wrap { grid-template-columns: 1fr; }
|
.page-wrap { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok b
|
|||||||
return "", "", "", false
|
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)
|
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -544,7 +544,6 @@ func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntr
|
|||||||
return weeks
|
return weeks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||||
type diaryPhoto struct {
|
type diaryPhoto struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
|
|||||||
+495
@@ -0,0 +1,495 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"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")
|
||||||
|
return &specialPage{
|
||||||
|
Content: renderFitnessDashboard(fsPath, weightSel),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calorie tracking was removed pending a rethink of the (undocumented)
|
||||||
|
// per-item formula; only the weight series is read from the export.
|
||||||
|
type wlExport struct {
|
||||||
|
Diary []wlDiaryEntry `json:"diary"`
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wlDiaryEntry struct {
|
||||||
|
DateTime string `json:"dateTime"`
|
||||||
|
Stats struct {
|
||||||
|
Weight wlNum `json:"weight"`
|
||||||
|
} `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.<key>.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 exportWeightUnit(settings json.RawMessage) string {
|
||||||
|
var s struct {
|
||||||
|
Units struct {
|
||||||
|
Weight string `json:"weight"`
|
||||||
|
} `json:"units"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
|
||||||
|
return s.Units.Weight
|
||||||
|
}
|
||||||
|
return "kg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Series extraction ===
|
||||||
|
|
||||||
|
type weightPoint struct {
|
||||||
|
date time.Time
|
||||||
|
value float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWeights computes the weight series from the export: one point per
|
||||||
|
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
|
||||||
|
// one wins.
|
||||||
|
func extractWeights(ex *wlExport) []weightPoint {
|
||||||
|
byDate := map[time.Time]float64{}
|
||||||
|
for _, e := range ex.Diary {
|
||||||
|
day, ok := exportDate(e.DateTime)
|
||||||
|
if !ok || !e.Stats.Weight.ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byDate[day] = e.Stats.Weight.val
|
||||||
|
}
|
||||||
|
weights := make([]weightPoint, 0, len(byDate))
|
||||||
|
for d, v := range byDate {
|
||||||
|
weights = append(weights, weightPoint{d, v})
|
||||||
|
}
|
||||||
|
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||||
|
return weights
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <title>. 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Rendering ===
|
||||||
|
|
||||||
|
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
||||||
|
|
||||||
|
func renderFitnessDashboard(fsPath, weightSel string) template.HTML {
|
||||||
|
data := buildFitnessDash(fsPath, weightSel, 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 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 := extractWeights(&ex)
|
||||||
|
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
||||||
|
|
||||||
|
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||||
|
buildWeightChart(weights, wGoal, hasWGoal, weightSel, exportWeightUnit(ex.Settings), today),
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -58,7 +58,9 @@ type specialPage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
// 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
|
// 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,
|
// 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).
|
// 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
|
// When adding a new hook, prefer a sibling method here over folding logic
|
||||||
// into main.go or render.go.
|
// into main.go or render.go.
|
||||||
type pageTypeHandler interface {
|
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)
|
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
|
var special *specialPage
|
||||||
if !editMode {
|
if !editMode {
|
||||||
for _, ph := range pageTypeHandlers {
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user