From f8b908b39ef63d0214075bc4747bb3b3bf3fc604 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:35:45 +0300 Subject: [PATCH 1/6] Fix catalog redirects pointing at the old /ruoat Adding, editing or deleting a dish redirected to /ruoat, which stopped existing when the tab was renamed to Ruuat. Every one of those actions ended on a 404. Shipped in v20260905-4. The rename was done with a scripted replace across views.templ, main.go and the smoke script; handlers.go was not in the list. The tests did not catch it because they asserted only that the response was a 303. A redirect to a dead URL is still a 303. They now assert the target. --- cmd/foodster/handlers.go | 6 +++--- scripts/smoke.sh | 23 ++++++++++++++--------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index d14ace6..3ffbcf7 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -508,7 +508,7 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { log.Printf("save main: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + http.Redirect(w, r, "/ruuat", http.StatusSeeOther) return } } @@ -537,7 +537,7 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { log.Printf("save side: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + http.Redirect(w, r, "/ruuat", http.StatusSeeOther) return } } @@ -565,7 +565,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) { http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) return } - http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + http.Redirect(w, r, "/ruuat", http.StatusSeeOther) } // importDishes takes a bundle either pasted into the textarea or uploaded as a diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 05c9d46..1f1fa3d 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -220,9 +220,12 @@ check "the quick-added dish is on the board" \ # ---- catalog CRUD from the UI ------------------------------------------- -check "adding a main redirects" \ - "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ - -d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303" +# Assert where it redirects, not just that it does: these pointed at the old +# /ruoat spelling for a while and every 303-only check was happy. +check "adding a main redirects back to the catalog" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \ + "/ruuat" catalog=$(curl -s -u ":$pass" "http://$addr/ruuat") check "the new main is listed, sentence-cased" "$catalog" "Uunikala" @@ -239,9 +242,10 @@ check "a nameless dish is refused" \ "$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "Anna nimi." -check "adding a side redirects" \ - "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ - -d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303" +check "adding a side redirects back to the catalog" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \ + "/ruuat" check "the new side is listed" \ "$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat" @@ -264,9 +268,10 @@ check "the bin asks before deleting" \ check "the dish is still there while it asks" \ "$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala" -check "confirming the delete redirects" \ - "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ - -d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303" +check "confirming the delete redirects back to the catalog" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \ + "/ruuat" refute "the dish is gone once confirmed" \ "$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala" -- 2.54.0 From e5e020457af2cce923bea77b0eac55a87e737279 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:40:22 +0300 Subject: [PATCH 2/6] Patch the catalog in place instead of navigating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a dish partway down the list sent the browser back to the top. The first attempt at fixing it used anchors, which cannot work: the browser positions the element with no knowledge of where the page was scrolled, so it still jumps. Datastar was already loaded and doing nothing but search. Every catalog action now patches. The bin, the pencil and Peruuta stay real links; the forms stay real forms. Datastar intercepts them with data-on:click__prevent and data-on:submit__prevent, and the same handlers redirect when the Datastar-Request header is absent, so none of it requires JavaScript. Posting with {contentType: 'form'} sends the enclosing form as FormData, which means the handlers keep reading r.FormValue and no input had to be rewritten as a signal. The response is one SSE event carrying three elements: the list and both forms. They have to move together — opening an edit form also has to clear a delete that was mid-confirmation — and a text/html response can only replace one element. The writer is twenty lines rather than re-adding the SDK and the four modules it brings for a generator we would otherwise never call. Smoke checks assert the wire format: that these answer with an event stream carrying all three elements, that the deleted dish is absent from the patched list, and that a header-less post still redirects. --- cmd/foodster/handlers.go | 100 ++++++++++++++++++++++++++++++++++++--- cmd/foodster/main.go | 1 + cmd/foodster/views.templ | 42 +++++++++++++--- scripts/smoke.sh | 29 +++++++++++- 4 files changed, 156 insertions(+), 16 deletions(-) diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index 3ffbcf7..3428392 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -215,6 +215,50 @@ func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) { } } +// isDatastar reports whether the request came from the client library, which +// tags its own. Everything below keeps working without JavaScript: the same +// handlers redirect instead of patching when the header is absent. +func isDatastar(r *http.Request) bool { + return r.Header.Get("Datastar-Request") != "" +} + +// patchElements sends one Datastar event carrying several elements, each +// matched to the page by its id. A text/html response can only replace one +// element, and the catalog has to move its list and its forms together — +// opening an edit form also has to un-highlight whatever was open before. +// +// ponytail: about twenty lines instead of the SDK, which brought four modules +// for an SSE generator we would otherwise never call. +func patchElements(w http.ResponseWriter, r *http.Request, components ...templ.Component) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + + var out strings.Builder + out.WriteString("event: datastar-patch-elements\n") + for _, c := range components { + var html strings.Builder + if err := c.Render(r.Context(), &html); err != nil { + log.Printf("patch %s: %v", r.URL.Path, err) + return + } + // One `data: elements` line per line of HTML, as the protocol wants. + for _, line := range strings.Split(html.String(), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + out.WriteString("data: elements ") + out.WriteString(line) + out.WriteString("\n") + } + } + out.WriteString("\n") + + io.WriteString(w, out.String()) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + // searchBoard re-renders the dish board as the search box is typed into. func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) { var signals searchSignals @@ -410,7 +454,20 @@ type catalogView struct { DeleteKind string } +// catalog renders the whole page. show patches the same state in place, so +// nothing navigates: both build the view the same way. func (a *app) catalog(w http.ResponseWriter, r *http.Request) { + a.renderCatalog(w, r, a.catalogState(r)) +} + +// show is what every catalog link actually calls. It patches the list and both +// forms rather than loading a page, so opening an edit form or asking to +// delete a row leaves the scroll position exactly where it was. +func (a *app) show(w http.ResponseWriter, r *http.Request) { + a.patchCatalog(w, r, a.catalogState(r)) +} + +func (a *app) catalogState(r *http.Request) catalogView { v := catalogView{ Main: mainForm{Categories: map[string]bool{}, HasSides: true}, Search: strings.TrimSpace(r.URL.Query().Get("haku")), @@ -446,10 +503,11 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) { } } - a.renderCatalog(w, r, v) + return v } -func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { +// fillCatalog loads the lists into a view built from the request. +func (a *app) fillCatalog(v *catalogView) { if v.Main.Categories == nil { v.Main.Categories = map[string]bool{} } @@ -465,9 +523,20 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie if v.Sides, err = listSides(a.db, v.Search); err != nil { log.Printf("list sides: %v", err) } +} + +func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { + a.fillCatalog(&v) render(w, r, catalogPage(v)) } +// patchCatalog swaps the list and both forms in one event. They move together: +// opening an edit form also has to clear whatever delete was being confirmed. +func (a *app) patchCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { + a.fillCatalog(&v) + patchElements(w, r, catalogList(v), mainForm_(v.Main), sideForm_(v.Side)) +} + // saveMain adds or updates a main dish. A rejected form is re-rendered with // the values still in it; a good one redirects, so refresh cannot re-submit. func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { @@ -508,11 +577,28 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { log.Printf("save main: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruuat", http.StatusSeeOther) + // Saved: hand back a blank form so it collapses, and a list with + // the dish in it. + a.finishCatalog(w, r, catalogView{}) return } } - a.renderCatalog(w, r, catalogView{Main: form}) + a.finishCatalog(w, r, catalogView{Main: form}) +} + +// finishCatalog answers a catalog write: a patch for Datastar, a redirect for +// a plain form post. Without the redirect, submitting with JavaScript off +// would leave the browser sitting on a POST it could not reload. +func (a *app) finishCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { + if isDatastar(r) { + a.patchCatalog(w, r, v) + return + } + if v.Main.Err != "" || v.Side.Err != "" { + a.renderCatalog(w, r, v) + return + } + http.Redirect(w, r, "/ruuat", http.StatusSeeOther) } func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { @@ -537,11 +623,11 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { log.Printf("save side: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruuat", http.StatusSeeOther) + a.finishCatalog(w, r, catalogView{}) return } } - a.renderCatalog(w, r, catalogView{Side: form}) + a.finishCatalog(w, r, catalogView{Side: form}) } // deleteDish soft-deletes, so log entries keep resolving the name (PRD §6). @@ -565,7 +651,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) { http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) return } - http.Redirect(w, r, "/ruuat", http.StatusSeeOther) + a.finishCatalog(w, r, catalogView{}) } // importDishes takes a bundle either pasted into the textarea or uploaded as a diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index b2182a9..d6d9051 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -162,6 +162,7 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler { mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) + mux.HandleFunc("GET /ruuat/nayta", a.show) mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain) mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) mux.HandleFunc("POST /ruuat/poista", a.deleteDish) diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index fa33b29..da2f85d 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -43,6 +43,12 @@ func dayURL(base string, d, now time.Time) string { return base + "?pvm=" + isoDate(d) } +// showURL turns a catalog page link into the patch endpoint behind it, so the +// href and the Datastar call never drift apart. +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) @@ -689,25 +695,39 @@ templ emptyNote(search string) { // rowActions is a pencil and a bin, until the bin is tapped: then the row // asks. An icon is a smaller target to hit by accident than a word, and the // dish disappears from every picker the moment it goes. +// Every control here is a real link or form, so the page still works without +// JavaScript. Datastar intercepts them and patches the list in place instead, +// which is the whole point: a delete confirmation halfway down a long list +// must not send the browser back to the top. templ rowActions(v catalogView, editURL string, id int64, kind string) { if v.DeleteID == id && v.DeleteKind == kind {
Poista? -
+
- Peruuta + Peruuta
} else {
- + @iconPencil() @@ -746,7 +766,11 @@ templ mainForm_(f mainForm) { if f.Err != "" {

{ f.Err }

} -
+ if f.ID != 0 { } @@ -774,7 +798,7 @@ templ mainForm_(f mainForm) {
if f.ID != 0 { -
Peruuta + Peruuta } } @@ -803,7 +827,11 @@ templ sideForm_(f sideForm) { if f.Err != "" {

{ f.Err }

} -
+ if f.ID != 0 { } @@ -814,7 +842,7 @@ templ sideForm_(f sideForm) {
if f.ID != 0 { - Peruuta + Peruuta } } diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 1f1fa3d..2005054 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -268,9 +268,34 @@ check "the bin asks before deleting" \ check "the dish is still there while it asks" \ "$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala" -check "confirming the delete redirects back to the catalog" \ - "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ +# ---- the catalog patches in place instead of navigating ----------------- + +# A delete confirmation halfway down a long list must not send the browser +# back to the top, so these answer with a Datastar patch rather than a page. +patch=$(curl -s -u ":$pass" -H 'Datastar-Request: true' \ + "http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa") +check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements" +check "the patch carries the list" "$patch" 'id="ruokalista"' +check "and both forms, so an open one closes" "$patch" 'id="paaruoka"' +check "the row it patches in is asking" "$patch" "Poista?" + +check "patches are served as an event stream" \ + "$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" -H 'Datastar-Request: true' \ + "http://$addr/ruuat/nayta")" "text/event-stream" + +check "deleting from Datastar patches too" \ + "$(curl -s -u ":$pass" -H 'Datastar-Request: true' \ -d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \ + "event: datastar-patch-elements" + +refute "and the dish is gone from the patched list" \ + "$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \ + "Uunikala" + +# Without the header it must still be an ordinary redirect, for no JavaScript. +check "a plain form post still redirects" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "/ruuat" refute "the dish is gone once confirmed" \ -- 2.54.0 From b8a46cdd30df41e272d3cad359d080f43394c805 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:45:53 +0300 Subject: [PATCH 3/6] 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 { } 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" " Date: Sat, 5 Sep 2026 23:55:25 +0300 Subject: [PATCH 4/6] Tag non-production tabs, and drop the FOODSTER_ prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With dev and prod open side by side in the same browser, the tabs were indistinguishable. ENV is written into the title of every page unless it says prod, so "dev · Foodster" picks itself out. The value is used verbatim, so ENV=staging labels itself too, and prod and production both count as unmarked so a stray capital cannot tag the real instance. Environment variables lose their prefix: PASSWORD, DB, ENV, ADDR, HOST, REPO, TAG. The container namespaces them already, and this matches how the other services here are configured. PUID/PGID are the exception rather than UID/GID. UID is read-only in bash, so a value set in .env would be silently replaced by the invoking shell's own and compose's user: would ignore what was asked for. Breaking for a running instance: the deployed .env has to be rewritten in the same deploy, or the app will refuse to start on an unset PASSWORD. --- .env.example | 21 ++++++++++++++------- Makefile | 18 +++++++++--------- PRD.md | 22 +++++++++++++--------- README.md | 24 +++++++++++++++--------- cmd/foodster/main.go | 29 +++++++++++++++++++++++------ cmd/foodster/main_test.go | 23 +++++++++++++++++++++++ cmd/foodster/views.templ | 12 +++++++++++- compose.yaml | 22 +++++++++++++--------- scripts/smoke.sh | 4 +++- 9 files changed, 124 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index 4ac06c0..4c4f00c 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,29 @@ # Copy to .env and fill in. .env is gitignored — the real registry hostname # must not end up in the repository. -# Image coordinates. FOODSTER_REPO carries no tag. -FOODSTER_REPO=registry.example.com/you/foodster -FOODSTER_TAG=latest +# Image coordinates. REPO carries no tag. +REPO=registry.example.com/you/foodster +TAG=latest # Shared household password. The app will not start without it. -FOODSTER_PASSWORD=changeme +PASSWORD=changeme # Hostname Traefik routes to. Kept here rather than in compose.yaml so no # infrastructure detail is committed. -FOODSTER_HOST=foodster.example.com +HOST=foodster.example.com + +# Anything other than prod is written into the browser tab title, so a dev +# instance open beside the real one can be told apart. +ENV=prod # The database lives in ./data, bind-mounted into the container. These must # match whoever owns that directory on the host, or the container cannot # write to it. `id -u` and `id -g` will tell you. -FOODSTER_UID=1000 -FOODSTER_GID=1000 +# +# Named PUID/PGID because UID is read-only in bash and a plain UID here would +# be quietly replaced by the invoking shell's own. +PUID=1000 +PGID=1000 # Used for every calendar-day calculation. Set it in development too: under # UTC the date rolls over three hours late, which is exactly when dinner diff --git a/Makefile b/Makefile index 6a5b8da..0468112 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ build: generate ## Build ./foodster -ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG) run: generate ## Run locally on :8080 (database in ./data) - FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG) + PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG) seed: ## Import a dish bundle (SEED=seeds/testi.json) go run $(PKG) -import $(SEED) @@ -85,7 +85,7 @@ fix: ## Format Go and templ sources, tidy go.mod go mod tidy image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. - @test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } + @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; } @# A release tag must point into main, or the tag records a commit that @# was never released. @branch=$$(git symbolic-ref --short HEAD); \ @@ -99,7 +99,7 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. echo "==> $$tag"; \ git tag "$$tag"; \ podman build --platform linux/amd64 --build-arg VERSION="$$tag" \ - -t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . ; \ + -t "$(REPO):$$tag" -t "$(REPO):latest" . ; \ echo "$$tag" > $(TAGFILE) # Pushing reported success while uploading the previous release once, because @@ -107,18 +107,18 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. # registry what it actually serves for each tag and fail if it is not the # image we just built. push: ## Push the newest tag and :latest, then verify the registry - @test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } + @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; } @test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \ tag=$$(cat $(TAGFILE)); \ - built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \ + built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \ { echo "no local image tagged $$tag - run make image"; exit 1; }; \ - podman push "$(FOODSTER_REPO):$$tag"; \ - podman push "$(FOODSTER_REPO):latest"; \ + podman push "$(REPO):$$tag"; \ + podman push "$(REPO):latest"; \ echo "==> verifying $$tag"; \ for ref in "$$tag" latest; do \ - podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \ + podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \ { echo " FAIL $$ref is not in the registry"; exit 1; }; \ - served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \ + served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \ if [ "$$served" != "$$built" ]; then \ echo " FAIL $$ref serves $$served"; \ echo " expected $$built"; \ diff --git a/PRD.md b/PRD.md index 6c4ee84..c7716d2 100644 --- a/PRD.md +++ b/PRD.md @@ -351,7 +351,7 @@ build and no asset bundler. is imported because the runtime image carries no zoneinfo. All date logic uses that location explicitly and never `time.Local`. - **Auth**: HTTP Basic with one shared household password read from - `FOODSTER_PASSWORD`; the username is ignored. Compared using + `PASSWORD`; the username is ignored. Compared using `subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor its length leaks through timing. `/healthz` is the only route outside auth. - **Exposure**: the app is served on a public hostname behind Traefik, which @@ -395,21 +395,25 @@ on the server and run with Docker Compose. `/data/foodster.db`, bind-mounted from `./data` on the host rather than kept in a named volume, so the file can be listed and copied without going through the container engine. Backup is `cp -r data`. Because the image - runs as UID 65534, compose sets `user:` from `FOODSTER_UID`/`FOODSTER_GID` + runs as UID 65534, compose sets `user:` from `PUID`/`PGID` to match whoever owns that directory. `restart: unless-stopped`. - **Configuration**, entirely through environment variables (see `.env.example`): - - `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates. - - `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to - start without it. - - `FOODSTER_DB` — database file path, default `./data/foodster.db`. The - directory is created on startup if missing. - - `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`. + Names carry no application prefix: the container namespaces them already. + - `REPO` and `TAG` — image coordinates. + - `PASSWORD` — the shared password. Required; the app refuses to start + without it. + - `DB` — database file path, default `./data/foodster.db`. The directory is + created on startup if missing. + - `ENV` — anything but `prod` is prefixed to the browser tab title, so a + dev instance open beside the real one can be told apart. + - `PUID` / `PGID` — host owner of `./data`. Not `UID`, which is read-only + in bash and would be replaced by the invoking shell's own value. - `TZ` — default `Europe/Helsinki`. - The registry hostname exists only in `.env`, which is gitignored, because §11 leaves open the possibility of publishing this repository. - **Routing**: Traefik on an external `traefik` network, matching on - `FOODSTER_HOST` and terminating TLS. The container publishes no ports — + `HOST` and terminating TLS. The container publishes no ports — doing so would put an unencrypted copy of the app on the host, bypassing the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no infrastructure detail is committed. diff --git a/README.md b/README.md index 41eaf01..696f88b 100644 --- a/README.md +++ b/README.md @@ -173,13 +173,19 @@ Everything is environment variables. `.env` is gitignored; start from | Variable | Default | Purpose | |---|---|---| -| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. | -| `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. | -| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. | +| `PASSWORD` | *required* | Shared password. The app will not start without it. | +| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. | +| `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). | +| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. | +| `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. | | `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. | -| `FOODSTER_REPO` | *required to build* | Image repository, no tag. | -| `FOODSTER_TAG` | `latest` | Tag to run under compose. | -| `FOODSTER_PORT` | `8080` | Host port to publish. | + +Names carry no prefix: the container gives them their own namespace already. +`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set +in `.env` would be silently replaced by the invoking shell's own. +| `REPO` | *required to build* | Image repository, no tag. | +| `TAG` | `latest` | Tag to run under compose. | +| `HOST` | *required to run* | Hostname Traefik routes to. | Set `TZ` in development too. Under UTC the date rolls over three hours late, which is exactly when dinner gets logged. @@ -204,7 +210,7 @@ the container, so a backup is `cp -r data` and you can inspect the file with any sqlite client without going through the engine. That directory must exist and be owned by the user compose runs as — `make up` -creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who +creates it, and `PUID`/`PGID` in `.env` tell the container who that is. Get them from `id -u` and `id -g`. If the app exits with `cannot open /data/foodster.db ... unable to open @@ -213,7 +219,7 @@ bind-mount directory as root, and the container is not root: ```sh ls -ldn data # whose is it? -sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID +sudo chown -R 1000:1000 data # match PUID / PGID docker compose restart ``` @@ -235,7 +241,7 @@ counting it would lock the household out for simply opening the app. address, meaning it arrived through the proxy. A client connecting directly could otherwise forge a new address per attempt and skip the limiter. -**None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes +**None of this replaces a strong `PASSWORD`.** Rate limiting removes brute force as a practical route; it does not make a guessable password safe. ## Mockups diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 0ed12ee..96d1078 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -35,6 +35,21 @@ var staticFS embed.FS // version is replaced at build time with the CalVer tag (see `make image`). var version = "dev" +// envTag marks the browser tab of anything that is not production, so a dev +// instance and the real one open side by side are told apart at a glance. +// Empty in production, which is the default. +var envTag string + +func setEnvTag(value string) { + value = strings.TrimSpace(value) + if value == "" || strings.EqualFold(value, "prod") || strings.EqualFold(value, "production") { + envTag = "" + return + } + // Whatever it says, so ENV=staging labels itself too. + envTag = strings.ToLower(value) +} + const ( listenAddr = ":8080" defaultTZ = "Europe/Helsinki" @@ -55,7 +70,7 @@ func run() error { "import a JSON dish bundle (PRD §7.3 shape) and exit") flag.Parse() - db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB)) + db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB)) if err != nil { return err } @@ -66,9 +81,9 @@ func run() error { return runImport(db, *importPath) } - password := os.Getenv("FOODSTER_PASSWORD") + password := os.Getenv("PASSWORD") if password == "" { - return errors.New("FOODSTER_PASSWORD is not set") + return errors.New("PASSWORD is not set") } // Fail rather than fall back to UTC: a silently wrong zone shifts logged @@ -79,9 +94,11 @@ func run() error { return fmt.Errorf("TZ: %w", err) } - // The container always publishes :8080; FOODSTER_ADDR exists so tests and - // a second local instance can pick another port. - addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr) + setEnvTag(os.Getenv("ENV")) + + // The container always publishes :8080; ADDR exists so tests and a second + // local instance can pick another port. + addr := cmp.Or(os.Getenv("ADDR"), listenAddr) srv := &http.Server{ Addr: addr, diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go index 75bd898..0049626 100644 --- a/cmd/foodster/main_test.go +++ b/cmd/foodster/main_test.go @@ -105,6 +105,29 @@ func TestReadSignals(t *testing.T) { } } +func TestEnvTagMarksNonProduction(t *testing.T) { + t.Cleanup(func() { envTag = "" }) + + cases := []struct{ env, want string }{ + // Production is the default and must stay unmarked: the tag exists to + // pick the dev tab out of two identical ones. + {"", "Foodster"}, + {"prod", "Foodster"}, + {"PRODUCTION", "Foodster"}, + {" ", "Foodster"}, + {"dev", "dev · Foodster"}, + {"DEV", "dev · Foodster"}, + {"staging", "staging · Foodster"}, + } + + for _, c := range cases { + setEnvTag(c.env) + if got := pageTitle("Foodster"); got != c.want { + t.Errorf("ENV=%q: title = %q, want %q", c.env, got, c.want) + } + } +} + func TestMigrateCreatesSchema(t *testing.T) { db, err := openDB(t.TempDir() + "/test.db") if err != nil { diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index d93c09f..49f4dbd 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -108,6 +108,16 @@ func categoryLabels(d Dish) string { return strings.Join(names, ", ") } +// pageTitle prefixes the tab title on any instance that is not production. +// The tab is the only place a browser shows which of two identical apps you +// are looking at. +func pageTitle(title string) string { + if envTag == "" { + return title + } + return envTag + " · " + title +} + // countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive // after every number except one. func countFI(n int, one, many string) string { @@ -130,7 +140,7 @@ templ page(title, current string) { // header rather than butting against it. - { title } + { pageTitle(title) }