1 Commits
Author SHA1 Message Date
KessinenandEsa Kataja 174652778b Release: leftovers, the real dish list, and a day list that stays put (#2)
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 nobody cooked. A special-flagged row created by migration 0002; never in the catalog, never editable, and excluded from the stage-2 suggester outright. It gets its own mark, a grey lidded tub — which exposed a bug where no categories drew the same icon as several.

The real dish list. The seed bundle is now your December 2024 list: 37 mains, 9 sides. Letut and Pannari dropped as not-dinners, Kanakintut folded into Broilerin koipireidet, has_sides assigned by rule.

The day list stays put. Choosing a day used to swap a panel in above the list and drop that day out of it, so rows below jumped up under the tap. The list is now the page; the selected day expands where it sits, with anchors so the viewport lands on the day rather than the top.

Release pipeline fixed. The last release silently pushed the previous image: release declared image and push as prerequisites and your make runs -j16, so they raced. Also, push re-derived the tag by date-sorting, which is ambiguous when two tags share a commit. It now reads what image recorded, and verifies afterwards that the registry serves what was built.

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #2
2026-09-05 20:24:02 +00:00
10 changed files with 366 additions and 82 deletions
+17
View File
@@ -113,6 +113,23 @@ in English.
Side dishes live in their own table and have no category. The pool is Side dishes live in their own table and have no category. The pool is
expected to stay small. 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) ### Meal log entry ("what was actually eaten") (stage 1)
- `id` - `id`
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field - `date` — SQL `DATE`, day granularity only. There is no time-of-day field
+49
View File
@@ -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) { func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
h := seeded(t) h := seeded(t)
id := h.sideNamed(t, "Riisi") id := h.sideNamed(t, "Riisi")
+39 -13
View File
@@ -86,6 +86,7 @@ type logView struct {
ShowBoard bool ShowBoard bool
Dishes []Dish // flat, only to know whether anything matched Dishes []Dish // flat, only to know whether anything matched
Groups []DishGroup // what the board actually renders Groups []DishGroup // what the board actually renders
Special []Dish // Tähteet and the like: loggable, but not food
Sides []Side Sides []Side
New mainForm // inline "add the dish you were looking for" New mainForm // inline "add the dish you were looking for"
History HistoryPage 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 // listDishes already orders by frequency then name, so grouping keeps
// the favourites at the top of each category. // the favourites at the top of each category.
v.Groups = groupDishes(v.Dishes) 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 // Seed the inline add form with whatever was searched for, so a miss
// turns straight into "add it" without retyping. // turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} 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) 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 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) log.Printf("history: %v", err)
} }
// Nothing logged ever: the selected day is still the one being worked on,
render(w, r, logPage(v)) // 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 // 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.Dishes = dishes
v.Groups = groupDishes(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} v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
fragment(w, r, boardList(v)) 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) log.Printf("list dishes: %v", err)
} }
v.Groups = groupDishes(v.Dishes) v.Groups = groupDishes(v.Dishes)
v.HistoryDays = historyWindow(r) if v.Special, err = listSpecial(a.db, v.Search); err != nil {
v.HistoryMore = v.HistoryDays + historyDays log.Printf("list special: %v", err)
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)) 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, "?") { if strings.Contains(target, "?") {
sep = "&" 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. // 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) 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) { func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
target := "/" http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther)
if !date.Equal(today(a.loc)) {
target += "?pvm=" + date.Format(dateLayout)
}
http.Redirect(w, r, target, http.StatusSeeOther)
} }
// mainForm and sideForm carry what the user typed, so a rejected submission // mainForm and sideForm carry what the user typed, so a rejected submission
+9 -7
View File
@@ -246,14 +246,16 @@ func TestMealLogOneEntryPerDate(t *testing.T) {
} }
defer db.Close() 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) 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) t.Fatalf("first entry: %v", err)
} }
// PRD §6: a second dinner for the same day must be refused. // 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") 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() 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) 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") t.Error("case-variant duplicate was accepted, want a unique violation")
} }
// Soft-deleting the original frees the name again (PRD §7.3). // 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) 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) t.Errorf("name still blocked after soft delete: %v", err)
} }
} }
+19
View File
@@ -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);
+36 -2
View File
@@ -134,6 +134,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
.c-kana { color: var(--kana); } .c-kana { color: var(--kana); }
.c-kala { color: var(--kala); } .c-kala { color: var(--kala); }
.c-kasvis { color: var(--kasvis); } .c-kasvis { color: var(--kasvis); }
/* Not a category, so not a category colour. */
.c-tahteet { color: var(--muted); }
/* Day switcher */ /* Day switcher */
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; } .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; font-size: 10px;
color: var(--muted); 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.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
.pill.lg { font-size: 18px; padding: 12px 16px; } .pill.lg { font-size: 18px; padding: 12px 16px; }
.pill.md { font-size: 15.5px; padding: 11px 14px; } .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; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
/* History sits under the logger on the same page, so whole rows are links. */ /* The day list is the page; rows are links and the selected one expands. */
.history { margin-top: 8px; } .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 { .entry, .gapline {
display: flex; display: flex;
gap: 12px; gap: 12px;
+28 -5
View File
@@ -28,13 +28,24 @@ type Dish struct {
TimesEaten int TimesEaten int
} }
// CategoryKey is the class suffix for the colour dot. A dish covering several // Rows flagged `special` in the database — Tähteet — are loggable but are not
// categories (tortillas, build-your-own pizza) gets the mixed marker. // 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 { 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]] return categoryFI[d.Categories[0]]
default:
return "sek"
} }
return "sek"
} }
// Size buckets the dish by how often it has been eaten. The board draws // 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. // listDishes returns live mains ordered by how often they have been eaten.
// An empty search matches everything. // An empty search matches everything.
func listDishes(db *sql.DB, search string) ([]Dish, error) { 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(` rows, err := db.Query(`
SELECT m.id, m.name, m.has_sides, SELECT m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category) 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) (SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
FROM main_dishes m FROM main_dishes m
WHERE m.deleted_at IS NULL WHERE m.deleted_at IS NULL
AND m.special = ?
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%') 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 { if err != nil {
return nil, err return nil, err
} }
+107 -43
View File
@@ -43,6 +43,18 @@ func dayURL(base string, d, now time.Time) string {
return base + "?pvm=" + isoDate(d) 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 // pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
// any day but today. // any day but today.
func pickSeparator(v logView) string { func pickSeparator(v logView) string {
@@ -52,6 +64,17 @@ func pickSeparator(v logView) string {
return "&" 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 // jsString renders a Go string as a JavaScript literal, for the data-signals
// attribute that seeds the search box. // attribute that seeds the search box.
func jsString(s string) string { func jsString(s string) string {
@@ -183,6 +206,10 @@ templ categoryIcon(key string) {
<span class="cat c-kasvis"> <span class="cat c-kasvis">
@glyphKasvis() @glyphKasvis()
</span> </span>
case "tahteet":
<span class="cat c-tahteet">
@glyphTahteet()
</span>
default: default:
<span class="cat"> <span class="cat">
@glyphSekalaiset() @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() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
<rect x="1.4" y="2.6" width="13.2" height="3" rx="1.3"></rect>
<path d="M2.8 6.8h10.4l-.9 6.6a1.6 1.6 0 0 1-1.6 1.4H5.3a1.6 1.6 0 0 1-1.6-1.4z"></path>
</svg>
}
// A steak, its bone knocked out with fill-rule so the hole is transparent on // A steak, its bone knocked out with fill-rule so the hole is transparent on
// whatever background the icon lands on. // whatever background the icon lands on.
templ glyphLiha() { templ glyphLiha() {
@@ -268,51 +304,57 @@ templ logPage(v logView) {
@daySwitch(v) @daySwitch(v)
</header> </header>
<main class="pad"> <main class="pad">
switch { @dayList(v)
case v.Chosen != nil:
@sidesStep(v)
case v.ShowBoard:
@board(v)
default:
@loggedCard(v)
}
@historyList(v)
</main> </main>
} }
} }
// historyList sits under the day being logged: the two were always one thing, // dayList is the whole page: every day back through the window, with the
// since every row here is a link back into the logger above it. // selected one expanded where it sits. Opening a day used to swap in a panel
templ historyList(v logView) { // 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) {
<section class="history"> <section class="history">
<h3 class="sechead">Aiemmin</h3>
if len(v.History.Rows) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p>
}
for i, row := range v.History.Rows { 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() {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() { <p class="monthrule">{ monthFI(row.Date) }</p>
<p class="monthrule">{ monthFI(row.Date) }</p> }
} if row.Date.Equal(v.Date) {
if row.Entry != nil { <div class="open" id={ dayAnchor(row.Date) }>
<a class="entry" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }> <p class="openday">
<time>{ dayLabelFI(row.Date) }</time> if row.Date.Equal(v.Today) {
<div> Tänään
<div class="nm"> } else {
@categoryIcon(row.Entry.Main.CategoryKey()) { longDateFI(row.Date) }
{ row.Entry.Main.Name } }
</div> </p>
<div class="sd">{ row.Entry.SidesLabel() }</div> switch {
case v.Chosen != nil:
@sidesStep(v)
case v.ShowBoard:
@board(v)
default:
@loggedCard(v)
}
</div>
} else if row.Entry != nil {
<a class="entry" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
@categoryIcon(row.Entry.Main.CategoryKey())
{ row.Entry.Main.Name }
</div> </div>
<span class="chev"></span> <div class="sd">{ row.Entry.SidesLabel() }</div>
</a> </div>
} else { <span class="chev"></span>
<a class="gapline" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }> </a>
<time>{ dayLabelFI(row.Date) }</time> } else {
<span>Ei merkintää</span> <a class="gapline" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
<span class="act">Merkitse</span> <time>{ dayLabelFI(row.Date) }</time>
</a> <span>Ei merkintää</span>
} <span class="act">Merkitse</span>
</a>
} }
} }
if v.History.More { if v.History.More {
@@ -385,6 +427,28 @@ templ boardList(v logView) {
} }
</div> </div>
} }
// 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 {
<div class="special">
for _, d := range v.Special {
<a
class="pill plain"
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
>
@categoryIcon(d.CategoryKey())
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
</div>
}
// 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 { if len(v.Dishes) == 0 {
@quickAddCard(v) @quickAddCard(v)
} }
@@ -438,7 +502,7 @@ templ quickAddCard(v logView) {
templ dishPill(d Dish, v logView) { templ dishPill(d Dish, v logView) {
<a <a
class={ "pill", d.Size() } class={ "pill", d.Size() }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) } href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
> >
@categoryIcon(d.CategoryKey()) @categoryIcon(d.CategoryKey())
{ d.Name } { d.Name }
@@ -476,7 +540,7 @@ templ sidesStep(v logView) {
} }
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a> <a class="ghost" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
</section> </section>
} }
@@ -501,17 +565,17 @@ templ loggedCard(v logView) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button> <button class="btn del" type="submit">Kyllä, poista</button>
</form> </form>
<a class="btn" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a> <a class="btn" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
</div> </div>
} else { } else {
<div class="pair"> <div class="pair">
<a <a
class="btn" class="btn"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "muuta=1") } href={ templ.SafeURL(stepURL(v, "muuta=1")) }
>Muokkaa</a> >Muokkaa</a>
<a <a
class="btn del" class="btn del"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "poista=1") } href={ templ.SafeURL(stepURL(v, "poista=1")) }
>Poista</a> >Poista</a>
</div> </div>
} }
+15 -2
View File
@@ -114,6 +114,11 @@ check "malformed JSON is explained" \
board=$(curl -s -u ":$pass" "http://$addr/") board=$(curl -s -u ":$pass" "http://$addr/")
check "board lists imported dishes" "$board" "Lihapullat" 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. # 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) ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$ruoka" ]; then if [ -z "$ruoka" ]; then
@@ -132,8 +137,16 @@ check "saving redirects back to the day" \
check "the saved day shows what was eaten" \ check "the saved day shows what was eaten" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu" "$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
check "history is on the same page as the logger" \ # The selected day expands inside the list rather than in a panel above it,
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin" # 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. # 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") saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
+47 -10
View File
@@ -1,19 +1,56 @@
{ {
"mains": [ "mains": [
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true}, {"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false},
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false}, {"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true}, {"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false},
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false}, {"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true},
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false}, {"name": "Kebab", "categories": ["meat"], "has_sides": true},
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false}, {"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "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": [ "sides": [
{"name": "Keitetyt perunat"}, {"name": "Keitetyt perunat"},
{"name": "Ranskalaiset"},
{"name": "Lohkoperunat"}, {"name": "Lohkoperunat"},
{"name": "Muussi"}, {"name": "Muusi"},
{"name": "Pasta"},
{"name": "Ranskalaiset"},
{"name": "Riisi"}, {"name": "Riisi"},
{"name": "Pasta"} {"name": "Spagetti"},
{"name": "Tillikastike"},
{"name": "Wokkivihannekset"}
] ]
} }