From 2a148aa2a847ffe3df7ebf2c5903ff19c4759d97 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 22:42:30 +0300 Subject: [PATCH 1/3] Make releases push what they built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release reported success while uploading the previous one a second time. Three separate faults, one of which hid the others. release declared image and push as prerequisites. Make runs targets in parallel by default here (-j16), so push resolved a tag and uploaded :latest before image had finished building and tagging. They are sub-makes now, as check already was. push re-derived the tag with `git tag --sort=-creatordate | head -1`. That is ambiguous when two tags point at the same commit, so it could pick the wrong one even without a race — and re-deriving is what made the race possible at all. image now records what it built in .release-tag and push reads it. Nothing compared what was built against what arrived, so the failure was silent: the build log said "Successfully tagged v...-3" while the registry received the older image. push now pulls each tag back afterwards and compares image ids, failing if the registry serves something else. The tag ambiguity surfaced because a test of the failure path did not fail. That was worth more than the fix it was checking. --- .gitignore | 3 +++ Makefile | 42 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index cf5bd37..356629e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Build output /foodster +# The tag `make image` last built, handed to `make push`. +/.release-tag + # Generated by `templ generate` during the container build. *_templ.go diff --git a/Makefile b/Makefile index 47af48c..6a5b8da 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,11 @@ BIN := foodster PKG := ./cmd/foodster STATIC := cmd/foodster/static +# What `make image` last built. push reads it rather than re-deriving the tag: +# sorting tags by date is ambiguous when two point at the same commit, and +# re-deriving is what let a parallel make push the wrong one. +TAGFILE := .release-tag + # Vendored Datastar client. Bump, run `make vendor`, commit the result. DATASTAR_VERSION ?= v1.0.3 SEED ?= seeds/testi.json @@ -94,16 +99,41 @@ 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 "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . ; \ + echo "$$tag" > $(TAGFILE) -push: ## Push the newest tag and :latest +# Pushing reported success while uploading the previous release once, because +# nothing compared what was built against what arrived. So afterwards, ask the +# 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; } - @tag=$$(git tag -l 'v*' --sort=-creatordate | head -n1); \ - test -n "$$tag" || { echo "no tags yet - run make image"; 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) || \ + { echo "no local image tagged $$tag - run make image"; exit 1; }; \ podman push "$(FOODSTER_REPO):$$tag"; \ - podman push "$(FOODSTER_REPO):latest" + podman push "$(FOODSTER_REPO):latest"; \ + echo "==> verifying $$tag"; \ + for ref in "$$tag" latest; do \ + podman pull -q "$(FOODSTER_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}}'); \ + if [ "$$served" != "$$built" ]; then \ + echo " FAIL $$ref serves $$served"; \ + echo " expected $$built"; \ + exit 1; \ + fi; \ + echo " ok $$ref"; \ + done -release: image push ## Build, tag and push in one go +# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on +# at least one machine here — these run concurrently, so push resolves the +# newest tag and uploads :latest before image has finished building and +# tagging. That silently ships the previous release a second time. +release: ## Build, tag and push in one go + @$(MAKE) --no-print-directory image + @$(MAKE) --no-print-directory push up: ## Start the stack @mkdir -p data # or the engine creates it root-owned and the app cannot write -- 2.54.0 From d743327cd2ce0bbf51cae30a0b3fb0963ea3d422 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:11:58 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Add=20T=C3=A4hteet,=20and=20the=20real=20ho?= =?UTF-8?q?usehold=20dish=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tähteet is leftovers. It is not food: it exists so a day can be recorded as "we ate what was already there" without inventing a meal nobody cooked. Modelled as a row flagged `special` rather than a nullable main_dish_id on the log, so every foreign key and join carries on working. Migration 0002 creates it; the household never adds, edits or deletes it, and it never appears in the catalog. It is loggable exactly like a real meal and sits on the board apart from the categories, muted and at fixed size — it will be among the most-logged entries and should not tower over the actual cooking. It carries no category, which is why it could not be an ordinary main: those must have at least one. That also exposed a bug in CategoryKey, which returned the mixed Sekalaiset mark for anything that was not exactly one category — so *no* categories drew the same icon as *several*. Zero now means not food and gets its own mark, a lidded tub in grey rather than a category colour. The fix reaches the board, the sides step, the logged card and the history at once, since they all ask the same function. PRD §6 records the hard requirement: the stage 2 suggester must never propose it, so cooldown, coverage and weighting all skip it despite how often it is logged. The seed bundle is now the household's real list, taken from a JSON file written in December 2024: 37 mains and 9 sides. Letut and Pannari were dropped as not being dinners, Kanakintut folded into Broilerin koipireidet, and has_sides assigned by rule — soups, casseroles, laatikko, kiusaus, risotto and pasta dishes take none; sauces, patties and roasts do. Two tests failed immediately on the migration because they hardcoded id 1, which the new row now occupies. Moved clear of it. --- PRD.md | 17 +++++++ cmd/foodster/catalog_test.go | 49 ++++++++++++++++++++ cmd/foodster/handlers.go | 10 +++++ cmd/foodster/main_test.go | 16 ++++--- cmd/foodster/migrations/0002_tahteet.sql | 19 ++++++++ cmd/foodster/static/app.css | 16 +++++++ cmd/foodster/store.go | 33 +++++++++++--- cmd/foodster/views.templ | 35 +++++++++++++++ scripts/smoke.sh | 5 +++ seeds/kotiruoat.json | 57 +++++++++++++++++++----- 10 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 cmd/foodster/migrations/0002_tahteet.sql diff --git a/PRD.md b/PRD.md index 0f42fb0..6c4ee84 100644 --- a/PRD.md +++ b/PRD.md @@ -113,6 +113,23 @@ in English. Side dishes live in their own table and have no category. The pool is expected to stay small. +### Tähteet — leftovers (stage 1) + +A single built-in entry, flagged `special` on the main dish table. It is +**not food**: it exists so a day can be recorded as "we ate what was already +there" without inventing a meal that was never cooked. + +- No category, which is why it cannot be an ordinary main: those must have + at least one. +- Created by a migration. The household does not add, edit or delete it, and + it never appears in the Ruuat catalog. +- Loggable exactly like any other entry, and shown on the log board apart + from the categories. +- **The stage 2 suggester must never propose it.** It is excluded from the + eligible pool outright, so cooldown, category coverage (§8.1) and + frequency weighting (§8.2) all skip it — despite it being among the + most-logged entries. + ### Meal log entry ("what was actually eaten") (stage 1) - `id` - `date` — SQL `DATE`, day granularity only. There is no time-of-day field diff --git a/cmd/foodster/catalog_test.go b/cmd/foodster/catalog_test.go index d4ae0b1..622d441 100644 --- a/cmd/foodster/catalog_test.go +++ b/cmd/foodster/catalog_test.go @@ -131,6 +131,55 @@ func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) { } } +func TestTahteetIsLoggableButNotFood(t *testing.T) { + h := seeded(t) + + // The migration creates it; nobody adds it. + special, err := listSpecial(h.db, "") + if err != nil { + t.Fatalf("listSpecial: %v", err) + } + if len(special) != 1 || special[0].Name != "Tähteet" { + t.Fatalf("special = %+v, want exactly Tähteet", special) + } + + // It must not turn up among the dishes: not on the board's categories, + // not in the catalog, and not in whatever the suggester later draws from. + dishes, err := listDishes(h.db, "") + if err != nil { + t.Fatalf("listDishes: %v", err) + } + for _, d := range dishes { + if d.Name == "Tähteet" { + t.Fatal("Tähteet appears among the dishes") + } + } + + // It carries no category at all, which is why it cannot be an ordinary + // dish: those are required to have one. + if len(special[0].Categories) != 0 { + t.Errorf("categories = %v, want none", special[0].Categories) + } + // And it gets its own mark: no categories is not the same as several, so + // it must not fall through to the mixed Sekalaiset one. + if got := special[0].CategoryKey(); got != "tahteet" { + t.Errorf("CategoryKey = %q, want tahteet", got) + } + + // Logging it has to work exactly like logging a real meal. + date := day(t, "2026-09-05") + if err := saveEntry(h.db, date, special[0].ID, nil); err != nil { + t.Fatalf("saveEntry: %v", err) + } + entry, err := entryFor(h.db, date) + if err != nil || entry == nil { + t.Fatalf("entryFor: %v, %v", entry, err) + } + if entry.Main.Name != "Tähteet" { + t.Errorf("logged %q, want Tähteet", entry.Main.Name) + } +} + func TestSoftDeleteSideHidesItFromPickers(t *testing.T) { h := seeded(t) id := h.sideNamed(t, "Riisi") diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index 080a8ed..2f72e7e 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -86,6 +86,7 @@ type logView struct { ShowBoard bool Dishes []Dish // flat, only to know whether anything matched Groups []DishGroup // what the board actually renders + Special []Dish // Tähteet and the like: loggable, but not food Sides []Side New mainForm // inline "add the dish you were looking for" History HistoryPage @@ -142,6 +143,9 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { // listDishes already orders by frequency then name, so grouping keeps // the favourites at the top of each category. v.Groups = groupDishes(v.Dishes) + if v.Special, err = listSpecial(a.db, v.Search); err != nil { + log.Printf("list special: %v", err) + } // Seed the inline add form with whatever was searched for, so a miss // turns straight into "add it" without retyping. v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} @@ -210,6 +214,9 @@ func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) { } v.Dishes = dishes v.Groups = groupDishes(dishes) + if v.Special, err = listSpecial(a.db, v.Search); err != nil { + log.Printf("search special: %v", err) + } v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} fragment(w, r, boardList(v)) @@ -293,6 +300,9 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) { 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) + } 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 { diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go index edebd4c..75bd898 100644 --- a/cmd/foodster/main_test.go +++ b/cmd/foodster/main_test.go @@ -246,14 +246,16 @@ func TestMealLogOneEntryPerDate(t *testing.T) { } defer db.Close() - if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil { + // Ids well clear of anything the migrations create. + if _, err := db.Exec( + `INSERT INTO main_dishes (id, name) VALUES (101, 'Lohikeitto'), (102, 'Lihapullat')`); err != nil { t.Fatalf("seed: %v", err) } - if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil { + if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); err != nil { t.Fatalf("first entry: %v", err) } // PRD §6: a second dinner for the same day must be refused. - if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil { + if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 102)`); err == nil { t.Error("second entry for the same date was accepted, want a unique violation") } } @@ -265,18 +267,18 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) { } defer db.Close() - if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil { + if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (101, 'Kanacurry')`); err != nil { t.Fatalf("first insert: %v", err) } - if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil { + if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err == nil { t.Error("case-variant duplicate was accepted, want a unique violation") } // Soft-deleting the original frees the name again (PRD §7.3). - if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil { + if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 101`); err != nil { t.Fatalf("soft delete: %v", err) } - if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil { + if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err != nil { t.Errorf("name still blocked after soft delete: %v", err) } } diff --git a/cmd/foodster/migrations/0002_tahteet.sql b/cmd/foodster/migrations/0002_tahteet.sql new file mode 100644 index 0000000..85df255 --- /dev/null +++ b/cmd/foodster/migrations/0002_tahteet.sql @@ -0,0 +1,19 @@ +-- Tähteet: leftovers. +-- +-- Not a dish. It exists so a day can be recorded as "we ate what was already +-- there" without inventing a meal that was never cooked. It has no category, +-- it is not something the household adds or edits, and the stage 2 suggester +-- must never propose it (PRD §8). +-- +-- Modelled as a flagged row in main_dishes rather than a nullable +-- main_dish_id on meal_log: the log keeps one shape, and every foreign key +-- and join carries on working untouched. + +ALTER TABLE main_dishes + ADD COLUMN special INTEGER NOT NULL DEFAULT 0 CHECK (special IN (0, 1)); + +-- OR IGNORE in case a household already typed a dish by this name: the unique +-- index on lower(name) would otherwise fail the migration. Their row stays as +-- an ordinary dish, which is wrong but harmless and fixable by hand. +INSERT OR IGNORE INTO main_dishes (name, has_sides, special) +VALUES ('Tähteet', 0, 1); diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css index 4990e15..30de39d 100644 --- a/cmd/foodster/static/app.css +++ b/cmd/foodster/static/app.css @@ -134,6 +134,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; } .c-kana { color: var(--kana); } .c-kala { color: var(--kala); } .c-kasvis { color: var(--kasvis); } +/* Not a category, so not a category colour. */ +.c-tahteet { color: var(--muted); } /* Day switcher */ .dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; } @@ -208,6 +210,20 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; } font-size: 10px; color: var(--muted); } +/* Not food: set apart from the categories, and deliberately quiet. */ +.special { + margin-top: 22px; + padding-top: 16px; + border-top: 1px dashed var(--line); +} +.pill.plain { + font-size: 16px; + padding: 12px 16px; + background: var(--sunk); + color: var(--muted); + font-weight: 600; +} + .pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; } .pill.lg { font-size: 18px; padding: 12px 16px; } .pill.md { font-size: 15.5px; padding: 11px 14px; } diff --git a/cmd/foodster/store.go b/cmd/foodster/store.go index 8369440..161fae7 100644 --- a/cmd/foodster/store.go +++ b/cmd/foodster/store.go @@ -28,13 +28,24 @@ type Dish struct { TimesEaten int } -// CategoryKey is the class suffix for the colour dot. A dish covering several -// categories (tortillas, build-your-own pizza) gets the mixed marker. +// Rows flagged `special` in the database — Tähteet — are loggable but are not +// food. They carry no category, never appear in the catalog, and PRD §8 +// excludes them from the suggester: cooldown, coverage and weighting all skip +// them. dishByID deliberately does not filter on the flag, because logging one +// has to work like logging anything else. + +// CategoryKey picks the mark for a dish. One covering several categories +// (tortillas, build-your-own pizza) gets the mixed one; carrying none at all +// means it is not food, and Tähteet is not a mixture of anything. func (d Dish) CategoryKey() string { - if len(d.Categories) == 1 { + switch len(d.Categories) { + case 0: + return "tahteet" + case 1: return categoryFI[d.Categories[0]] + default: + return "sek" } - return "sek" } // Size buckets the dish by how often it has been eaten. The board draws @@ -77,6 +88,17 @@ func (e Entry) SidesLabel() string { // listDishes returns live mains ordered by how often they have been eaten. // An empty search matches everything. func listDishes(db *sql.DB, search string) ([]Dish, error) { + return queryDishes(db, search, false) +} + +// listSpecial returns the entries that are not food — Tähteet and anything +// like it. They are loggable but never suggested, and never appear in the +// catalog, so they are fetched deliberately rather than by accident. +func listSpecial(db *sql.DB, search string) ([]Dish, error) { + return queryDishes(db, search, true) +} + +func queryDishes(db *sql.DB, search string, special bool) ([]Dish, error) { rows, err := db.Query(` SELECT m.id, m.name, m.has_sides, coalesce((SELECT group_concat(c.category) @@ -85,8 +107,9 @@ func listDishes(db *sql.DB, search string) ([]Dish, error) { (SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id) FROM main_dishes m WHERE m.deleted_at IS NULL + AND m.special = ? AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%') - ORDER BY 5 DESC, m.name`, search, search) + ORDER BY 5 DESC, m.name`, special, search, search) if err != nil { return nil, err } diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index 5be62d6..3cd4d00 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -183,6 +183,10 @@ templ categoryIcon(key string) { @glyphKasvis() + case "tahteet": + + @glyphTahteet() + default: @glyphSekalaiset() @@ -190,6 +194,15 @@ templ categoryIcon(key string) { } } +// A lidded tub. Tähteet is not food and not a mixture of categories, so it +// gets neither a category colour nor the quartered mark. +templ glyphTahteet() { + +} + // A steak, its bone knocked out with fill-rule so the hole is transparent on // whatever background the icon lands on. templ glyphLiha() { @@ -385,6 +398,28 @@ templ boardList(v logView) { } } + // Tähteet is not food, so it sits apart from the categories rather + // than inside one. Fixed size: it will be among the most-logged + // entries, and it should not tower over the actual cooking. + if len(v.Special) > 0 { +
+ for _, d := range v.Special { + { strconv.Itoa(d.TimesEaten) } + } + + } +
+ } + // Tähteet always matches an empty search, so the add card keys off the + // real dishes only: otherwise a fresh install would show leftovers and + // no way to add anything. if len(v.Dishes) == 0 { @quickAddCard(v) } diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 985b711..e991721 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -114,6 +114,11 @@ check "malformed JSON is explained" \ board=$(curl -s -u ":$pass" "http://$addr/") check "board lists imported dishes" "$board" "Lihapullat" +# Tähteet is loggable but is not food: on the board, never in the catalog. +check "leftovers are on the board" "$board" "Tähteet" +refute "leftovers are not in the catalog" \ + "$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet" + # Pull a real dish id out of the board rather than assuming one. ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2) if [ -z "$ruoka" ]; then diff --git a/seeds/kotiruoat.json b/seeds/kotiruoat.json index 6296a9b..d050f67 100644 --- a/seeds/kotiruoat.json +++ b/seeds/kotiruoat.json @@ -1,19 +1,56 @@ { "mains": [ - {"name": "Uunilohi", "categories": ["fish"], "has_sides": true}, - {"name": "Lasagnette", "categories": ["meat"], "has_sides": false}, - {"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true}, - {"name": "Risotto", "categories": ["vegetarian"], "has_sides": false}, - {"name": "Pakastepizza", "categories": ["meat"], "has_sides": false}, - {"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false}, - {"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false} + {"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false}, + {"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true}, + {"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false}, + {"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true}, + {"name": "Kebab", "categories": ["meat"], "has_sides": true}, + {"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false}, + {"name": "Lasagnette", "categories": ["meat"], "has_sides": false}, + {"name": "Lihapullat/pihvit", "categories": ["meat"], "has_sides": true}, + {"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false}, + {"name": "Makaronimössö", "categories": ["meat"], "has_sides": false}, + {"name": "Maksalaatikko", "categories": ["meat"], "has_sides": false}, + {"name": "Nachopelti", "categories": ["meat"], "has_sides": false}, + {"name": "Nakkikeitto", "categories": ["meat"], "has_sides": false}, + {"name": "Pakastepizza", "categories": ["meat"], "has_sides": false}, + {"name": "Possunsuikalekastike", "categories": ["meat"], "has_sides": true}, + {"name": "Possurisotto", "categories": ["meat"], "has_sides": false}, + {"name": "Pyttipannu", "categories": ["meat"], "has_sides": false}, + {"name": "Uuniliha", "categories": ["meat"], "has_sides": true}, + {"name": "Uunimakkara", "categories": ["meat"], "has_sides": true}, + + {"name": "Broilerin koipireidet", "categories": ["chicken"], "has_sides": true}, + {"name": "Kanakastike", "categories": ["chicken"], "has_sides": true}, + {"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false}, + {"name": "Kanamakaronilaatikko", "categories": ["chicken"], "has_sides": false}, + {"name": "Kanapasta", "categories": ["chicken"], "has_sides": false}, + {"name": "Kanarisotto", "categories": ["chicken"], "has_sides": false}, + + {"name": "Kalakeitto", "categories": ["fish"], "has_sides": false}, + {"name": "Lohicuscus-salaatti", "categories": ["fish"], "has_sides": false}, + {"name": "Lohipyörykät", "categories": ["fish"], "has_sides": true}, + {"name": "Uunilohi", "categories": ["fish"], "has_sides": true}, + {"name": "Uuniperunat (lohitäytteellä)", "categories": ["fish"], "has_sides": false}, + + {"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false}, + {"name": "Italianpata (lihaton)", "categories": ["vegetarian"], "has_sides": true}, + {"name": "Kasvispihvit", "categories": ["vegetarian"], "has_sides": true}, + {"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false}, + {"name": "Pinaattiletut", "categories": ["vegetarian"], "has_sides": false}, + {"name": "Risotto", "categories": ["vegetarian"], "has_sides": false}, + + {"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false} ], "sides": [ {"name": "Keitetyt perunat"}, - {"name": "Ranskalaiset"}, {"name": "Lohkoperunat"}, - {"name": "Muussi"}, + {"name": "Muusi"}, + {"name": "Pasta"}, + {"name": "Ranskalaiset"}, {"name": "Riisi"}, - {"name": "Pasta"} + {"name": "Spagetti"}, + {"name": "Tillikastike"}, + {"name": "Wokkivihannekset"} ] } -- 2.54.0 From 768ab50140a55637e03894f060b1b4526a6a271c Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:21:26 +0300 Subject: [PATCH 3/3] 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 { +
+ + + + + } 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 { } 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") -- 2.54.0