From 768ab50140a55637e03894f060b1b4526a6a271c Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:21:26 +0300 Subject: [PATCH] Expand the selected day where it sits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing a day from the list made the page jump around. Two causes, both structural. The editing panel lived above the list, so opening a day swapped content in at the top and pushed everything down. And the selected day was then skipped when rendering the list — to avoid showing it twice — so every row below it slid up under the tap. The day list is now the page. The selected day expands in place and every other row stays exactly where it was. Anchors carry the viewport to the day rather than the top of the document, and links inside the open day keep the anchor, so picking a dish or cancelling does not throw the page back up. Saving and deleting redirect to the same anchor, leaving the viewport where the work was happening. The history window stretches to reach the selected day, which would otherwise have no row to open in. The open day is marked with a bar down its left side rather than rules above and below: the rows either side already draw a bottom border, so a horizontal rule doubled up with it on any day but the first. --- cmd/foodster/handlers.go | 44 +++++++++----- cmd/foodster/static/app.css | 22 ++++++- cmd/foodster/views.templ | 117 ++++++++++++++++++++++-------------- scripts/smoke.sh | 12 +++- 4 files changed, 133 insertions(+), 62 deletions(-) diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index 2f72e7e..d14ace6 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -156,13 +156,33 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { } } + a.loadDays(r, &v) + render(w, r, logPage(v)) +} + +// loadDays fills the day list. The selected day expands inside it rather than +// in a panel above it, so choosing a day from the list does not reorder the +// list underneath the tap. +func (a *app) loadDays(r *http.Request, v *logView) { v.HistoryDays = historyWindow(r) + + // The window has to reach the selected day, or it would have nowhere to + // expand. + if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays { + v.HistoryDays = min(reach, maxHistoryDays) + } v.HistoryMore = v.HistoryDays + historyDays - if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil { + + page, err := history(a.db, a.loc, v.Today, v.HistoryDays) + if err != nil { log.Printf("history: %v", err) } - - render(w, r, logPage(v)) + // Nothing logged ever: the selected day is still the one being worked on, + // so it needs a row of its own to open in. + if len(page.Rows) == 0 { + page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}} + } + v.History = page } // searchSignals is what Datastar sends back: for a GET it JSON-encodes the @@ -303,11 +323,7 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) { if v.Special, err = listSpecial(a.db, v.Search); err != nil { log.Printf("list special: %v", err) } - v.HistoryDays = historyWindow(r) - v.HistoryMore = v.HistoryDays + historyDays - if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil { - log.Printf("history: %v", err) - } + a.loadDays(r, &v) render(w, r, logPage(v)) } @@ -317,7 +333,9 @@ 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), http.StatusSeeOther) + http.Redirect(w, r, + target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date), + http.StatusSeeOther) } // save records the meal and redirects, so a refresh cannot double-post. @@ -355,12 +373,10 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) { a.redirectToDay(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. func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) { - target := "/" - if !date.Equal(today(a.loc)) { - target += "?pvm=" + date.Format(dateLayout) - } - http.Redirect(w, r, target, http.StatusSeeOther) + http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther) } // mainForm and sideForm carry what the user typed, so a rejected submission diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css index 30de39d..415d8ca 100644 --- a/cmd/foodster/static/app.css +++ b/cmd/foodster/static/app.css @@ -326,8 +326,26 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; } text-transform: uppercase; color: var(--muted); } -/* History sits under the logger on the same page, so whole rows are links. */ -.history { margin-top: 8px; } +/* The day list is the page; rows are links and the selected one expands. */ +.history { margin-top: 4px; } + +/* Marked with a bar down the side, not rules above and below: the rows either + side already draw a bottom border, so a horizontal rule here doubled up. + scroll-margin keeps the anchor off the viewport edge. */ +.open { + scroll-margin-top: 12px; + margin: 8px 0 18px; + padding: 10px 0 4px 13px; + border-left: 3px solid var(--accent); +} +.openday { + margin: 0 0 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); +} .entry, .gapline { display: flex; gap: 12px; diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index 3cd4d00..fa33b29 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -43,6 +43,18 @@ func dayURL(base string, d, now time.Time) string { return base + "?pvm=" + isoDate(d) } +// 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) +} + // pickSeparator joins a dish onto a day URL, which already carries ?pvm= for // any day but today. func pickSeparator(v logView) string { @@ -52,6 +64,17 @@ 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. +func stepURL(v logView, param string) string { + url := dayURL("/", v.Date, v.Today) + if param != "" { + url += pickSeparator(v) + param + } + return url + "#" + dayAnchor(v.Date) +} + // jsString renders a Go string as a JavaScript literal, for the data-signals // attribute that seeds the search box. func jsString(s string) string { @@ -281,51 +304,57 @@ templ logPage(v logView) { @daySwitch(v)
- switch { - case v.Chosen != nil: - @sidesStep(v) - case v.ShowBoard: - @board(v) - default: - @loggedCard(v) - } - @historyList(v) + @dayList(v)
} } -// historyList sits under the day being logged: the two were always one thing, -// since every row here is a link back into the logger above it. -templ historyList(v logView) { +// dayList is the whole page: every day back through the window, with the +// selected one expanded where it sits. Opening a day used to swap in a panel +// 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) {
-

Aiemmin

- if len(v.History.Rows) == 0 { -

Ei vielä merkintöjä.

- } for i, row := range v.History.Rows { - if !row.Date.Equal(v.Date) { - if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() { -

{ monthFI(row.Date) }

- } - if row.Entry != nil { - - -
-
- @categoryIcon(row.Entry.Main.CategoryKey()) - { row.Entry.Main.Name } -
-
{ row.Entry.SidesLabel() }
+ if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() { +

{ monthFI(row.Date) }

+ } + if row.Date.Equal(v.Date) { +
+

+ if row.Date.Equal(v.Today) { + Tänään + } else { + { longDateFI(row.Date) } + } +

+ switch { + case v.Chosen != nil: + @sidesStep(v) + case v.ShowBoard: + @board(v) + default: + @loggedCard(v) + } +
+ } else if row.Entry != nil { +
+ +
+
+ @categoryIcon(row.Entry.Main.CategoryKey()) + { row.Entry.Main.Name }
- -
- } else { - - - Ei merkintää - Merkitse - - } +
{ row.Entry.SidesLabel() }
+
+ + + } else { + + + Ei merkintää + Merkitse + } } if v.History.More { @@ -406,7 +435,7 @@ templ boardList(v logView) { for _, d := range v.Special { Tallenna - Peruuta + Peruuta
} @@ -536,17 +565,17 @@ templ loggedCard(v logView) { - Peruuta + Peruuta } else {
Muokkaa Poista
} diff --git a/scripts/smoke.sh b/scripts/smoke.sh index e991721..05c9d46 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -137,8 +137,16 @@ check "saving redirects back to the day" \ check "the saved day shows what was eaten" \ "$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu" -check "history is on the same page as the logger" \ - "$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin" +# 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 "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" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "#paiva-2026-09-04" # 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")