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
|
// Fitness dashboard chart hover. The range dropdowns and "absolute"
|
||||||
// reloads the page with that chart's query parameter updated. Plain GET
|
// checkboxes are htmx (assets/fitness/main.html): they swap in only their
|
||||||
// navigation — each view is distinct and bookmarkable, so no
|
// chart.
|
||||||
// postReplace/history handling is needed.
|
|
||||||
// Boosted navigation re-runs this script on every visit; the listeners
|
// Boosted navigation re-runs this script on every visit; the listeners
|
||||||
// are delegated to document, so register them once.
|
// are delegated to document, so register them once.
|
||||||
if (!window.fitnessWired) {
|
if (!window.fitnessWired) {
|
||||||
window.fitnessWired = true;
|
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
|
// 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
|
// are sorted by x, so a binary search finds it; the tooltip sits above the
|
||||||
// point and is clamped to the chart panel.
|
// 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"}}
|
{{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">
|
<div class="fitness-chart-header row space-between">
|
||||||
<span class="caption">{{.Title}}</span>
|
<span class="caption">{{.Title}}</span>
|
||||||
<div class="row fitness-controls">
|
<div class="row fitness-controls">
|
||||||
<label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
|
<label class="fitness-absolute"><input type="checkbox" name="{{.Param}}-absolute" value="1"{{if .Absolute}} checked{{end}}> Absolute</label>
|
||||||
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
<select class="input fitness-range" name="{{.Param}}" aria-label="{{.Title}} time range">
|
||||||
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+61
-2
@@ -34,13 +34,42 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *
|
|||||||
if s == nil || s.Type != "fitness" {
|
if s == nil || s.Type != "fitness" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
weightView, weeklyView := fitnessViews(r.URL.Query())
|
||||||
return &specialPage{
|
return &specialPage{
|
||||||
Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
|
Content: renderFitnessDashboard(fsPath, weightView, weeklyView),
|
||||||
SuppressTOC: true,
|
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 ===
|
// === Time ranges ===
|
||||||
|
|
||||||
type fitnessRange struct {
|
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 {
|
func rangeMonths(v string) int {
|
||||||
for _, r := range fitnessRanges {
|
for _, r := range fitnessRanges {
|
||||||
if r.Value == v {
|
if r.Value == v {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"path/filepath"
|
"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)
|
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().
|
// pageTypeHandlers is the registry. Each type registers itself via init().
|
||||||
var pageTypeHandlers []pageTypeHandler
|
var pageTypeHandlers []pageTypeHandler
|
||||||
|
|
||||||
@@ -306,6 +314,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
return
|
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 {
|
for _, ph := range pageTypeHandlers {
|
||||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
|
|||||||
Reference in New Issue
Block a user