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 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..d14ace6 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} @@ -152,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 @@ -210,6 +234,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,11 +320,10 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) { log.Printf("list dishes: %v", err) } v.Groups = groupDishes(v.Dishes) - 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) + 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)) } @@ -307,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. @@ -345,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/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..415d8ca 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; } @@ -310,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/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..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 { @@ -183,6 +206,10 @@ templ categoryIcon(key string) { @glyphKasvis() + case "tahteet": + + @glyphTahteet() + default: @glyphSekalaiset() @@ -190,6 +217,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() { @@ -268,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 { @@ -385,6 +427,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) } @@ -438,7 +502,7 @@ templ quickAddCard(v logView) { templ dishPill(d Dish, v logView) { Tallenna - Peruuta + Peruuta
} @@ -501,17 +565,17 @@ templ loggedCard(v logView) { - Peruuta + Peruuta } else {
Muokkaa Poista
} diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 985b711..05c9d46 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 @@ -132,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") 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"} ] }