Use HTMX fragements on the fitness dashboard

This commit is contained in:
2026-10-02 17:42:29 +02:00
parent aa90a04f91
commit 7749d7c237
5 changed files with 139 additions and 24 deletions
+3 -19
View File
@@ -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.
+5 -3
View File
@@ -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"}}
<section class="fitness-chart panel">
<section class="fitness-chart panel" hx-get="./" hx-vals='{"chart": "{{.Param}}"}' hx-trigger="change" hx-include="closest .fitness-dash" hx-target="this" hx-swap="outerHTML" hx-sync="this:replace">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<div class="row fitness-controls">
<label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
<label class="fitness-absolute"><input type="checkbox" name="{{.Param}}-absolute" value="1"{{if .Absolute}} checked{{end}}> Absolute</label>
<select class="input fitness-range" name="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
+61 -2
View File
@@ -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=<param> 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 {
+54
View File
@@ -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, "<html") || strings.Count(body, `class="fitness-chart panel"`) != 1 ||
!strings.Contains(body, `name="weekly"`) || !strings.Contains(body, `value="all" selected`) {
t.Errorf("weekly fragment: %d\n%s", w.Code, body)
}
if got, want := w.Header().Get("HX-Replace-Url"), "/My%20Fitness/?weekly=all&weekly-absolute=1"; got != want {
t.Errorf("HX-Replace-Url = %q, want %q", got, want)
}
if w := get("chart=bogus", true); w.Code != 404 {
t.Errorf("unknown chart: got %d, want 404", w.Code)
}
// Without htmx the parameter is ignored and the full page renders.
if body := get("chart=weekly", false).Body.String(); strings.Count(body, `class="fitness-chart panel"`) != 2 {
t.Errorf("non-htmx request did not render the dashboard:\n%s", body)
}
}
+16
View File
@@ -64,6 +64,14 @@ type pageTypeHandler interface {
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
}
// fragmentHandler is optionally implemented by a pageTypeHandler whose page
// updates part of itself over htmx (e.g. one chart of a dashboard). It
// answers only htmx GETs outside the editor and returns false when the
// request is not for one of its fragments, so the full page renders.
type fragmentHandler interface {
serveFragment(w http.ResponseWriter, r *http.Request, root, fsPath, urlPath string) bool
}
// pageTypeHandlers is the registry. Each type registers itself via init().
var pageTypeHandlers []pageTypeHandler
@@ -306,6 +314,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
return
}
if isHX(r) && !editMode {
for _, ph := range pageTypeHandlers {
if fh, ok := ph.(fragmentHandler); ok && fh.serveFragment(w, r, h.root, fsPath, urlPath) {
return
}
}
}
for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
http.Redirect(w, r, target, http.StatusFound)