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() {
-
}
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")