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