Patch the day list in place instead of navigating

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.
This commit is contained in:
Esa Kataja
2026-09-05 23:45:53 +03:00
parent e5e020457a
commit b8a46cdd30
4 changed files with 146 additions and 64 deletions
+69 -37
View File
@@ -98,16 +98,56 @@ type logView struct {
Confirming bool Confirming bool
} }
func (a *app) index(w http.ResponseWriter, r *http.Request) { // logOptions is what the Kirjaa screen is being asked to show. Pulled out of
date := a.date(r) // the request for a page load or a patch, and set directly after a write,
v := logView{ // where the answer is simply "that day, nothing else open".
Date: date, type logOptions struct {
Today: today(a.loc), Date time.Time
Search: strings.TrimSpace(r.URL.Query().Get("haku")), Dish string // ?ruoka=, opening the sides step
Checked: map[int64]bool{}, 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) entry, err := entryFor(a.db, date)
if err != nil { 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 // ?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 // logged, the existing sides come back ticked, which makes editing an
// entry the same screen as creating one. // entry the same screen as creating one.
if raw := r.URL.Query().Get("ruoka"); raw != "" { if o.Dish != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil { if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil {
if dish, err := dishByID(a.db, id); err == nil { if dish, err := dishByID(a.db, id); err == nil {
v.Chosen = dish v.Chosen = dish
if entry != nil && entry.Main.ID == id { 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 // 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, // 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. // 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) { if v.Chosen == nil && (v.Entry == nil || changing) {
v.ShowBoard = true v.ShowBoard = true
if v.Dishes, err = listDishes(a.db, v.Search); err != nil { 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) a.loadDays(r, &v)
render(w, r, logPage(v)) return v
} }
// loadDays fills the day list. The selected day expands inside it rather than // 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) log.Printf("quick add: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: 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) a.redirectToPick(w, r, date, id)
return 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 // Rejected: back to the board with the form filled in and the search
// still narrowed, so the add card stays on screen. // still narrowed, so the add card stays on screen.
v := logView{ v := a.buildLog(r, logOptions{Date: date, Search: form.Name})
Date: date, v.New = form
Today: today(a.loc), if isDatastar(r) {
Search: form.Name, fragment(w, r, dayList(v))
Checked: map[int64]bool{}, return
New: form,
ShowBoard: true,
} }
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)) 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, "?") { if strings.Contains(target, "?") {
sep = "&" sep = "&"
} }
http.Redirect(w, r, http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date),
http.StatusSeeOther)
} }
// save records the meal and redirects, so a refresh cannot double-post. // 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) http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
return return
} }
a.redirectToDay(w, r, date) a.finishDay(w, r, date)
} }
func (a *app) delete(w http.ResponseWriter, r *http.Request) { 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) http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return return
} }
a.redirectToDay(w, r, date) a.finishDay(w, r, date)
} }
// redirectToDay returns to the day in the list, anchor included, so saving or // redirectToDay is the no-JavaScript path back after a write.
// deleting leaves the viewport where the work was happening.
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) { 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 // mainForm and sideForm carry what the user typed, so a rejected submission
+1
View File
@@ -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 /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd) mux.HandleFunc("POST /lisaa", a.quickAdd)
mux.HandleFunc("GET /etsi", a.searchBoard) mux.HandleFunc("GET /etsi", a.searchBoard)
mux.HandleFunc("GET /paiva", a.day)
mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
+54 -23
View File
@@ -49,16 +49,15 @@ func showURL(pageURL string) string {
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1) return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
} }
// dayAnchor names the element a day expands into. // dayPatch is the endpoint behind every link in the day list. The href beside
func dayAnchor(d time.Time) string { // it stays a real page URL for anyone without JavaScript; Datastar calls this
return "paiva-" + isoDate(d) // instead and swaps the list where it stands.
} func dayPatch(d time.Time, param string) string {
url := "/paiva?pvm=" + isoDate(d)
// dayLink opens a day and lands the viewport on it. Without the fragment the if param != "" {
// browser would jump to the top of a page whose selected day might be far url += "&" + param
// down the list. }
func dayLink(d, now time.Time) string { return url
return dayURL("/", d, now) + "#" + dayAnchor(d)
} }
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for // pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
@@ -70,15 +69,14 @@ func pickSeparator(v logView) string {
return "&" return "&"
} }
// stepURL is any link inside the open day. It keeps the anchor, so picking a // stepURL is the page URL for a link inside the open day: the fallback when
// dish or cancelling stays where the day is instead of throwing the viewport // there is no JavaScript to intercept it.
// back to the top of the list.
func stepURL(v logView, param string) string { func stepURL(v logView, param string) string {
url := dayURL("/", v.Date, v.Today) url := dayURL("/", v.Date, v.Today)
if param != "" { if param != "" {
url += pickSeparator(v) + 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 // 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 // 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. // under the tap. Now nothing moves — the row grows.
templ dayList(v logView) { templ dayList(v logView) {
<section class="history"> <section class="history" id="paivat">
for i, row := range v.History.Rows { for i, row := range v.History.Rows {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() { if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p> <p class="monthrule">{ monthFI(row.Date) }</p>
} }
if row.Date.Equal(v.Date) { if row.Date.Equal(v.Date) {
<div class="open" id={ dayAnchor(row.Date) }> <div class="open">
<p class="openday"> <p class="openday">
if row.Date.Equal(v.Today) { if row.Date.Equal(v.Today) {
Tänään Tänään
@@ -344,7 +342,11 @@ templ dayList(v logView) {
} }
</div> </div>
} else if row.Entry != nil { } else if row.Entry != nil {
<a class="entry" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }> <a
class="entry"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<time>{ dayLabelFI(row.Date) }</time> <time>{ dayLabelFI(row.Date) }</time>
<div> <div>
<div class="nm"> <div class="nm">
@@ -356,7 +358,11 @@ templ dayList(v logView) {
<span class="chev"></span> <span class="chev"></span>
</a> </a>
} else { } else {
<a class="gapline" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }> <a
class="gapline"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<time>{ dayLabelFI(row.Date) }</time> <time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span> <span>Ei merkintää</span>
<span class="act">Merkitse</span> <span class="act">Merkitse</span>
@@ -367,6 +373,7 @@ templ dayList(v logView) {
<a <a
class="more" class="more"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) } href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "paivat="+strconv.Itoa(v.HistoryMore)) + "')" }
>Näytä lisää</a> >Näytä lisää</a>
} }
</section> </section>
@@ -442,6 +449,7 @@ templ boardList(v logView) {
<a <a
class="pill plain" class="pill plain"
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) } href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
> >
@categoryIcon(d.CategoryKey()) @categoryIcon(d.CategoryKey())
{ d.Name } { d.Name }
@@ -477,7 +485,11 @@ templ quickAddCard(v logView) {
if v.New.Err != "" { if v.New.Err != "" {
<p class="formerr">{ v.New.Err }</p> <p class="formerr">{ v.New.Err }</p>
} }
<form method="post" action="/lisaa"> <form
method="post"
action="/lisaa"
data-on:submit__prevent="@post('/lisaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<label class="field"> <label class="field">
<span>Nimi</span> <span>Nimi</span>
@@ -509,6 +521,7 @@ templ dishPill(d Dish, v logView) {
<a <a
class={ "pill", d.Size() } class={ "pill", d.Size() }
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) } href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
> >
@categoryIcon(d.CategoryKey()) @categoryIcon(d.CategoryKey())
{ d.Name } { d.Name }
@@ -524,7 +537,11 @@ templ sidesStep(v logView) {
@categoryIcon(v.Chosen.CategoryKey()) @categoryIcon(v.Chosen.CategoryKey())
{ v.Chosen.Name } { v.Chosen.Name }
</h3> </h3>
<form method="post" action="/kirjaa"> <form
method="post"
action="/kirjaa"
data-on:submit__prevent="@post('/kirjaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/> <input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
if v.Chosen.HasSides && len(v.Sides) > 0 { if v.Chosen.HasSides && len(v.Sides) > 0 {
@@ -546,7 +563,11 @@ templ sidesStep(v logView) {
} }
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
<a class="ghost" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a> <a
class="ghost"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</section> </section>
} }
@@ -567,21 +588,31 @@ templ loggedCard(v logView) {
if v.Confirming { if v.Confirming {
<p class="q">Poistetaanko merkintä?</p> <p class="q">Poistetaanko merkintä?</p>
<div class="pair"> <div class="pair">
<form method="post" action="/poista"> <form
method="post"
action="/poista"
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button> <button class="btn del" type="submit">Kyllä, poista</button>
</form> </form>
<a class="btn" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a> <a
class="btn"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</div> </div>
} else { } else {
<div class="pair"> <div class="pair">
<a <a
class="btn" class="btn"
href={ templ.SafeURL(stepURL(v, "muuta=1")) } href={ templ.SafeURL(stepURL(v, "muuta=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
>Muokkaa</a> >Muokkaa</a>
<a <a
class="btn del" class="btn del"
href={ templ.SafeURL(stepURL(v, "poista=1")) } href={ templ.SafeURL(stepURL(v, "poista=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" }
>Poista</a> >Poista</a>
</div> </div>
} }
+22 -4
View File
@@ -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, # 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. # so the rows below do not shift when one is tapped.
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05") 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 "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" "<html"
check "picking a dish patches to the sides step" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/paiva?pvm=2026-09-05&ruoka=$ruoka")" "Tallenna"
check "saving from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
check "deleting from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04" "http://$addr/poista")" 'id="paivat"'
# Without the header it must still redirect, for no JavaScript.
check "a plain save still redirects to the day" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "#paiva-2026-09-04" -d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=2026-09-04"
# Deleting a logged meal drops the row outright, so it asks first. # Deleting a logged meal drops the row outright, so it asks first.
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1") saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")