diff --git a/assets/fitness/fitness.js b/assets/fitness/fitness.js index 8b120e8..95ba7a9 100644 --- a/assets/fitness/fitness.js +++ b/assets/fitness/fitness.js @@ -1,27 +1,11 @@ -// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one -// reloads the page with that chart's query parameter updated. Plain GET -// navigation — each view is distinct and bookmarkable, so no -// postReplace/history handling is needed. +// Fitness dashboard chart hover. The range dropdowns and "absolute" +// checkboxes are htmx (assets/fitness/main.html): they swap in only their +// chart. // Boosted navigation re-runs this script on every visit; the listeners // are delegated to document, so register them once. if (!window.fitnessWired) { window.fitnessWired = true; -document.addEventListener('change', function (e) { - var url = new URL(window.location.href); - var sel = e.target.closest('[data-fitness-range]'); - var abs = e.target.closest('[data-fitness-absolute]'); - if (sel) { - url.searchParams.set(sel.dataset.fitnessRange, sel.value); - } else if (abs) { - if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1'); - else url.searchParams.delete(abs.dataset.fitnessAbsolute); - } else { - return; - } - window.location.href = url.toString(); -}); - // Hover tooltip: the pointer snaps to the data point nearest in time. Dots // are sorted by x, so a binary search finds it; the tooltip sits above the // point and is clamped to the chart panel. diff --git a/assets/fitness/main.html b/assets/fitness/main.html index dae78a7..72c8b80 100644 --- a/assets/fitness/main.html +++ b/assets/fitness/main.html @@ -1,10 +1,12 @@ +{{/* A chart's controls re-fetch only that chart (fitnessHandler.serveFragment), +sending every chart's controls so the server can update the page URL. */}} {{define "fitnessChart"}} -
+
{{.Title}}
- - Absolute +
diff --git a/fitness.go b/fitness.go index 641b079..5e91ff9 100644 --- a/fitness.go +++ b/fitness.go @@ -34,13 +34,42 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) * if s == nil || s.Type != "fitness" { return nil } - q := r.URL.Query() + weightView, weeklyView := fitnessViews(r.URL.Query()) return &specialPage{ - Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")), + 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 { @@ -79,6 +108,36 @@ func chartViewFrom(q url.Values, param, fallback string) fitnessView { } } +// 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 { diff --git a/fitness_test.go b/fitness_test.go index 98f4ed2..e63ffc8 100644 --- a/fitness_test.go +++ b/fitness_test.go @@ -1,6 +1,7 @@ package main import ( + "net/http/httptest" "os" "strings" "path/filepath" @@ -74,3 +75,56 @@ func TestTimeXTicks(t *testing.T) { } } } + +func TestFitnessChartFragment(t *testing.T) { + withBerlin(t) + root := t.TempDir() + cals := filepath.Join(root, "Calendars") + fit := filepath.Join(root, "My Fitness") + for _, d := range []string{cals, fit} { + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + } + prev := calendarsDir + calendarsDir = cals + t.Cleanup(func() { calendarsDir = prev }) + if err := os.WriteFile(filepath.Join(cals, ".page-settings"), []byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fit, ".page-settings"), []byte("type = fitness\n"), 0644); err != nil { + t.Fatal(err) + } + writeCal(t, cals, "Gewicht", `UID:1 + SUMMARY:96 kg + DTSTART;VALUE=DATE:20200101`) + + h := &handler{root: root} + get := func(query string, hx bool) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/My%20Fitness/?"+query, nil) + if hx { + r.Header.Set("HX-Request", "true") + } + w := httptest.NewRecorder() + h.serveDir(w, r, "/My Fitness", fit) + return w + } + + w := get("chart=weekly&weight=3m&weekly=all&weekly-absolute=1", true) + body := w.Body.String() + if w.Code != 200 || strings.Contains(body, "