Use HTMX fragements on the fitness dashboard
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user