From b8a46cdd30df41e272d3cad359d080f43394c805 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:45:53 +0300 Subject: [PATCH] Patch the day list in place instead of navigating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kirjaa now works like the catalog: opening a day, picking a dish, saving, deleting, cancelling and "Näytä lisää" all patch the list where it stands. Nothing loads a page, so the scroll position never moves. One builder serves all three paths. buildLog takes what the screen should show — the day, an open dish, whether the entry is being changed or a delete confirmed — and the page render, the patch and the post-write response all go through it. After a save it is called with only the date, so the day comes back closed rather than reopening the sides step it was just submitted from. Links stay links and forms stay forms, with data-on:click__prevent and data-on:submit__prevent layered over them, so it all still works with JavaScript off. Each response patches a single element, so plain text/html is enough here; the SSE writer is only needed by the catalog, where the list and both forms have to move together. The anchors added in the previous attempt are gone. They could never have worked: the browser positions an anchor without knowing where the page was scrolled, so it jumped regardless. --- cmd/foodster/handlers.go | 106 +++++++++++++++++++++++++-------------- cmd/foodster/main.go | 1 + cmd/foodster/views.templ | 77 +++++++++++++++++++--------- scripts/smoke.sh | 26 ++++++++-- 4 files changed, 146 insertions(+), 64 deletions(-) diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index 3428392..85062f7 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -98,16 +98,56 @@ type logView struct { Confirming bool } -func (a *app) index(w http.ResponseWriter, r *http.Request) { - date := a.date(r) - v := logView{ - Date: date, - Today: today(a.loc), - Search: strings.TrimSpace(r.URL.Query().Get("haku")), - Checked: map[int64]bool{}, - } +// logOptions is what the Kirjaa screen is being asked to show. Pulled out of +// the request for a page load or a patch, and set directly after a write, +// where the answer is simply "that day, nothing else open". +type logOptions struct { + Date time.Time + Dish string // ?ruoka=, opening the sides step + Changing bool // ?muuta=, swapping the dish on a logged day + Confirming bool // ?poista=, asking before deleting the entry + Search string +} - v.Confirming = r.URL.Query().Get("poista") != "" +func (a *app) logOptionsFrom(r *http.Request) logOptions { + q := r.URL.Query() + return logOptions{ + Date: a.date(r), + Dish: q.Get("ruoka"), + Changing: q.Get("muuta") != "", + Confirming: q.Get("poista") != "", + Search: strings.TrimSpace(q.Get("haku")), + } +} + +func (a *app) index(w http.ResponseWriter, r *http.Request) { + render(w, r, logPage(a.buildLog(r, a.logOptionsFrom(r)))) +} + +// day patches the list in place. Every link in it calls here rather than +// loading a page, so opening a day leaves the scroll position alone. +func (a *app) day(w http.ResponseWriter, r *http.Request) { + fragment(w, r, dayList(a.buildLog(r, a.logOptionsFrom(r)))) +} + +// finishDay answers a write: a patch for Datastar, a redirect otherwise. +func (a *app) finishDay(w http.ResponseWriter, r *http.Request, date time.Time) { + if isDatastar(r) { + fragment(w, r, dayList(a.buildLog(r, logOptions{Date: date}))) + return + } + a.redirectToDay(w, r, date) +} + +func (a *app) buildLog(r *http.Request, o logOptions) logView { + date := o.Date + v := logView{ + Date: date, + Today: today(a.loc), + Search: o.Search, + Checked: map[int64]bool{}, + Confirming: o.Confirming, + } entry, err := entryFor(a.db, date) if err != nil { @@ -118,8 +158,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { // ?ruoka= opens the sides step for that dish. When it is the dish already // logged, the existing sides come back ticked, which makes editing an // entry the same screen as creating one. - if raw := r.URL.Query().Get("ruoka"); raw != "" { - if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + if o.Dish != "" { + if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil { if dish, err := dishByID(a.db, id); err == nil { v.Chosen = dish if entry != nil && entry.Main.ID == id { @@ -134,7 +174,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { // The board shows when there is nothing logged yet, or when the entry is // being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here, // so swapping the dish and picking one for the first time are one path. - changing := r.URL.Query().Get("muuta") != "" || v.Search != "" + changing := o.Changing || v.Search != "" if v.Chosen == nil && (v.Entry == nil || changing) { v.ShowBoard = true if v.Dishes, err = listDishes(a.db, v.Search); err != nil { @@ -157,7 +197,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { } a.loadDays(r, &v) - render(w, r, logPage(v)) + return v } // loadDays fills the day list. The selected day expands inside it rather than @@ -344,6 +384,12 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) { log.Printf("quick add: %v", err) form.Err = "Tallennus epäonnistui." default: + // Created: straight on to its sides step. + opts := logOptions{Date: date, Dish: strconv.FormatInt(id, 10)} + if isDatastar(r) { + fragment(w, r, dayList(a.buildLog(r, opts))) + return + } a.redirectToPick(w, r, date, id) return } @@ -351,23 +397,12 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) { // Rejected: back to the board with the form filled in and the search // still narrowed, so the add card stays on screen. - v := logView{ - Date: date, - Today: today(a.loc), - Search: form.Name, - Checked: map[int64]bool{}, - New: form, - ShowBoard: true, + v := a.buildLog(r, logOptions{Date: date, Search: form.Name}) + v.New = form + if isDatastar(r) { + fragment(w, r, dayList(v)) + return } - var err error - if v.Dishes, err = listDishes(a.db, v.Search); err != nil { - log.Printf("list dishes: %v", err) - } - v.Groups = groupDishes(v.Dishes) - if v.Special, err = listSpecial(a.db, v.Search); err != nil { - log.Printf("list special: %v", err) - } - a.loadDays(r, &v) render(w, r, logPage(v)) } @@ -377,9 +412,7 @@ func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.T if strings.Contains(target, "?") { sep = "&" } - http.Redirect(w, r, - target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date), - http.StatusSeeOther) + http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther) } // save records the meal and redirects, so a refresh cannot double-post. @@ -404,7 +437,7 @@ func (a *app) save(w http.ResponseWriter, r *http.Request) { http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError) return } - a.redirectToDay(w, r, date) + a.finishDay(w, r, date) } func (a *app) delete(w http.ResponseWriter, r *http.Request) { @@ -414,13 +447,12 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) { http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) return } - a.redirectToDay(w, r, date) + a.finishDay(w, r, date) } -// redirectToDay returns to the day in the list, anchor included, so saving or -// deleting leaves the viewport where the work was happening. +// redirectToDay is the no-JavaScript path back after a write. func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) { - http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther) + http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther) } // mainForm and sideForm carry what the user typed, so a rejected submission diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index d6d9051..0ed12ee 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -159,6 +159,7 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler { mux.HandleFunc("POST /kirjaa", a.save) mux.HandleFunc("POST /lisaa", a.quickAdd) mux.HandleFunc("GET /etsi", a.searchBoard) + mux.HandleFunc("GET /paiva", a.day) mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index da2f85d..d93c09f 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -49,16 +49,15 @@ func showURL(pageURL string) string { return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1) } -// dayAnchor names the element a day expands into. -func dayAnchor(d time.Time) string { - return "paiva-" + isoDate(d) -} - -// dayLink opens a day and lands the viewport on it. Without the fragment the -// browser would jump to the top of a page whose selected day might be far -// down the list. -func dayLink(d, now time.Time) string { - return dayURL("/", d, now) + "#" + dayAnchor(d) +// dayPatch is the endpoint behind every link in the day list. The href beside +// it stays a real page URL for anyone without JavaScript; Datastar calls this +// instead and swaps the list where it stands. +func dayPatch(d time.Time, param string) string { + url := "/paiva?pvm=" + isoDate(d) + if param != "" { + url += "&" + param + } + return url } // pickSeparator joins a dish onto a day URL, which already carries ?pvm= for @@ -70,15 +69,14 @@ func pickSeparator(v logView) string { return "&" } -// stepURL is any link inside the open day. It keeps the anchor, so picking a -// dish or cancelling stays where the day is instead of throwing the viewport -// back to the top of the list. +// stepURL is the page URL for a link inside the open day: the fallback when +// there is no JavaScript to intercept it. func stepURL(v logView, param string) string { url := dayURL("/", v.Date, v.Today) if param != "" { url += pickSeparator(v) + param } - return url + "#" + dayAnchor(v.Date) + return url } // jsString renders a Go string as a JavaScript literal, for the data-signals @@ -320,13 +318,13 @@ templ logPage(v logView) { // above the list and drop that day out of it, so the rows below jumped up // under the tap. Now nothing moves — the row grows. templ dayList(v logView) { -
+
for i, row := range v.History.Rows { if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {

{ monthFI(row.Date) }

} if row.Date.Equal(v.Date) { -
@@ -442,6 +449,7 @@ templ boardList(v logView) { @categoryIcon(d.CategoryKey()) { d.Name } @@ -477,7 +485,11 @@ templ quickAddCard(v logView) { if v.New.Err != "" {

{ v.New.Err }

} -
+ @categoryIcon(d.CategoryKey()) { d.Name } @@ -524,7 +537,11 @@ templ sidesStep(v logView) { @categoryIcon(v.Chosen.CategoryKey()) { v.Chosen.Name } - + if v.Chosen.HasSides && len(v.Sides) > 0 { @@ -546,7 +563,11 @@ templ sidesStep(v logView) { } -
Peruuta + Peruuta
} @@ -567,21 +588,31 @@ templ loggedCard(v logView) { if v.Confirming {

Poistetaanko merkintä?

-
+
- Peruuta + Peruuta
} else {
Muokkaa Poista
} diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 2005054..1145601 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -140,13 +140,31 @@ check "the saved day shows what was eaten" \ # The selected day expands inside the list rather than in a panel above it, # so the rows below do not shift when one is tapped. day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05") -check "the selected day expands in place" "$day" 'id="paiva-2026-09-05"' +check "the selected day expands in place" "$day" 'class="open"' check "and stays in the list rather than being lifted out" "$day" "kirjattu" -check "links inside it keep the anchor" "$day" "#paiva-2026-09-05" -check "saving returns to the day, not the top" \ +# ---- the day list patches in place instead of navigating ---------------- + +dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=2026-09-05") +check "opening a day patches the list" "$dayp" 'id="paivat"' +refute "and returns a fragment, not a page" "$dayp" "