From e4503e8433064e95c71640b8e5f5644c3846fc3a Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 18:29:38 +0300 Subject: [PATCH] Build the Kirjaa log flow and Historia list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1's point is collecting eating history, so the logging path is the one that has to be frictionless: pick a dish, tick sides, done. Kirjaa: - dishes ordered and sized by how often they have been eaten, so the likely answer is the biggest target on the screen - picking a dish opens the sides step; a dish with has_sides false says so instead of offering an empty list - saving redirects, so a refresh cannot double-post - a day already logged shows the entry with edit and delete. Editing reopens the sides step with the existing sides ticked, which makes editing and creating the same screen - day switcher and a server-side search over the catalog Historia walks back day by day to the oldest entry, so a day nobody wrote down appears as an explicit gap rather than quietly missing. No JavaScript is involved: every interaction is a link or a form, and the checked-chip styling is :has(input:checked). Datastar stays loaded but unused until an interaction genuinely needs to avoid a page load. Also fix a Makefile ordering bug: lint did not depend on generate, so `go vet` could run against templ output that was being rewritten. check now runs its phases as sub-makes so `make -j` cannot interleave them. Records two features that are specified but not built: dish CRUD in the UI (§7.3, only import exists today) and a per-device light/dark switch (§7.4). The switch must show the state that is active, not the one clicking produces. --- Makefile | 9 +- PRD.md | 16 +++ README.md | 16 ++- cmd/foodster/handlers.go | 121 +++++++++++++++- cmd/foodster/main.go | 2 + cmd/foodster/static/app.css | 222 +++++++++++++++++++++++++++++ cmd/foodster/store.go | 273 ++++++++++++++++++++++++++++++++++++ cmd/foodster/store_test.go | 267 +++++++++++++++++++++++++++++++++++ cmd/foodster/views.templ | 196 +++++++++++++++++++++++++- scripts/smoke.sh | 36 +++++ 10 files changed, 1146 insertions(+), 12 deletions(-) create mode 100644 cmd/foodster/store.go create mode 100644 cmd/foodster/store_test.go diff --git a/Makefile b/Makefile index b760e6d..083c7f1 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,12 @@ test: generate ## Run unit tests smoke: generate ## End-to-end check: auth, static assets, bundle import ./scripts/smoke.sh -check: lint test smoke ## Everything that must pass before a commit +# Sub-makes rather than prerequisites: these must run in order even under +# `make -j`, and a parallel run vets generated code that is being rewritten. +check: ## Everything that must pass before a commit + @$(MAKE) --no-print-directory lint + @$(MAKE) --no-print-directory test + @$(MAKE) --no-print-directory smoke @echo "check: all passed" vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3) @@ -51,7 +56,7 @@ vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3) "https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js" @head -1 cmd/foodster/static/datastar.js -lint: ## go vet, gofmt check, golangci-lint when installed +lint: generate ## go vet, gofmt check, golangci-lint when installed go vet ./... @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ if [ -n "$$bad" ]; then echo "gofmt needed:"; echo "$$bad"; exit 1; fi diff --git a/PRD.md b/PRD.md index 5fbc821..daf108b 100644 --- a/PRD.md +++ b/PRD.md @@ -200,6 +200,22 @@ regenerates or swaps. - Settings page for the cooldown window (default **14 days**, configurable). *(Stage 2 — only relevant once the suggester exists.)* +### 7.4 Appearance (stage 1) + +- A light / dark theme switch, remembered **per device**. The instance is + shared and has no accounts, so this is a device preference in + `localStorage`, never a server-side setting — the phone in the kitchen and + the laptop must be able to disagree. +- Three states: light, dark, and follow the system. System is the default. +- The control **shows the state that is currently active**, not the state + clicking it would produce. A segmented control with the active option + marked, not a single button labelled with its opposite. A lightswitch does + not read "off" while the lights are on. +- Implementation is `color-scheme` on the root element: `light dark` for the + system-following default, `only light` or `only dark` when overridden. + Because the palette is built from `light-dark()` custom properties, no + other CSS changes. + ## 8. Suggestion algorithm (stage 2) Produce seven mains, then attach sides per meal. diff --git a/README.md b/README.md index 6680bcf..d8cf241 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,21 @@ See [PRD.md](PRD.md) for the full specification. log come first, because the suggester is worthless until there are a few weeks of real history to weight against. -Stage 2 — the seven-meal suggester — starts once that history exists. +Working: + +- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized + by how often they are eaten. Edit or delete the day's entry. +- **Historia** — every day back to the first entry, with unlogged days shown + as explicit gaps. +- **Ruoat** — import a bundle of dishes by paste or file upload. + +Still to build: + +- Adding, editing and deleting dishes **in the UI** (PRD §7.3). Right now the + catalog can only be filled by importing a bundle. +- Light / dark theme switch, saved per device (PRD §7.4). +- Stage 2: the seven-meal suggester, which starts once there is history to + weight against. ## Stack diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index c21799b..d9af2be 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -5,6 +5,7 @@ import ( "io" "log" "net/http" + "strconv" "strings" "time" @@ -15,6 +16,10 @@ import ( // kilobytes; a megabyte is already absurd generosity. const maxUpload = 1 << 20 +// historyDays is how far back the Historia list walks. Long enough to see a +// couple of months, short enough to stay one scroll. +const historyDays = 60 + type app struct { db *sql.DB loc *time.Location @@ -27,12 +32,124 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) { } } +// date reads the ?pvm= parameter, falling back to today. An unparseable value +// is treated as today rather than an error: a mangled URL should not be a +// dead end. +func (a *app) date(r *http.Request) time.Time { + if raw := r.FormValue("pvm"); raw != "" { + if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil { + return d + } + } + return today(a.loc) +} + +// logView is everything the Kirjaa screen needs. +type logView struct { + Date time.Time + Today time.Time + Search string + Entry *Entry // what is already logged for Date, if anything + Chosen *Dish // dish picked, so the sides step is showing + Checked map[int64]bool // sides ticked in that step + Dishes []Dish + Sides []Side +} + func (a *app) index(w http.ResponseWriter, r *http.Request) { - render(w, r, indexPage(today(a.loc))) + date := a.date(r) + v := logView{ + Date: date, + Today: today(a.loc), + Search: strings.TrimSpace(r.URL.Query().Get("haku")), + Checked: map[int64]bool{}, + } + + entry, err := entryFor(a.db, date) + if err != nil { + log.Printf("entry for %s: %v", date.Format(dateLayout), err) + } + v.Entry = entry + + // ?ruoka= opens the sides step for that dish. When it is the dish already + // logged, the existing sides come back ticked, which makes editing an + // entry the same screen as creating one. + if raw := r.URL.Query().Get("ruoka"); raw != "" { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + if dish, err := dishByID(a.db, id); err == nil { + v.Chosen = dish + if entry != nil && entry.Main.ID == id { + for _, s := range entry.Sides { + v.Checked[s.ID] = true + } + } + } + } + } + + if v.Chosen == nil && v.Entry == nil { + if v.Dishes, err = listDishes(a.db, v.Search); err != nil { + log.Printf("list dishes: %v", err) + } + } + if v.Chosen != nil && v.Chosen.HasSides { + if v.Sides, err = listSides(a.db); err != nil { + log.Printf("list sides: %v", err) + } + } + + render(w, r, logPage(v)) +} + +// save records the meal and redirects, so a refresh cannot double-post. +func (a *app) save(w http.ResponseWriter, r *http.Request) { + date := a.date(r) + + mainID, err := strconv.ParseInt(r.FormValue("ruoka"), 10, 64) + if err != nil { + http.Error(w, "tuntematon ruoka", http.StatusBadRequest) + return + } + + var sideIDs []int64 + for _, raw := range r.Form["lisuke"] { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + sideIDs = append(sideIDs, id) + } + } + + if err := saveEntry(a.db, date, mainID, sideIDs); err != nil { + log.Printf("save entry: %v", err) + http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError) + return + } + a.redirectToDay(w, r, date) +} + +func (a *app) delete(w http.ResponseWriter, r *http.Request) { + date := a.date(r) + if err := deleteEntry(a.db, date); err != nil { + log.Printf("delete entry: %v", err) + http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) + return + } + a.redirectToDay(w, r, date) +} + +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) } func (a *app) history(w http.ResponseWriter, r *http.Request) { - render(w, r, historyPage()) + rows, err := history(a.db, a.loc, historyDays) + if err != nil { + log.Printf("history: %v", err) + } + render(w, r, historyPage(rows)) } func (a *app) catalog(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 1b846e6..c6cbb1c 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -145,6 +145,8 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler { mux := http.NewServeMux() mux.Handle("GET /static/", http.FileServerFS(staticFS)) mux.HandleFunc("GET /{$}", a.index) + mux.HandleFunc("POST /kirjaa", a.save) + mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("GET /historia", a.history) mux.HandleFunc("GET /ruoat", a.catalog) mux.HandleFunc("POST /ruoat/tuonti", a.importDishes) diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css index 3941a09..a5ac067 100644 --- a/cmd/foodster/static/app.css +++ b/cmd/foodster/static/app.css @@ -75,6 +75,228 @@ button, input, select { font: inherit; } } .tabbar a[aria-current] { color: var(--accent); } +.dot { + width: 9px; + height: 9px; + border-radius: 2px; + flex: none; + display: inline-block; +} +.d-liha { background: var(--liha); } +.d-kana { background: var(--kana); } +.d-kala { background: var(--kala); } +.d-kasvis { background: var(--kasvis); } +.d-sek { + background: conic-gradient(var(--liha) 0 25%, var(--kana) 25% 50%, + var(--kala) 50% 75%, var(--kasvis) 75% 100%); +} + +/* Day switcher */ +.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; } +.dayseg .seg { + min-height: 42px; + display: flex; + align-items: center; + padding: 0 16px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--card); + color: var(--ink); + text-decoration: none; + font-size: 14px; + font-weight: 600; +} +.dayseg .seg[aria-current] { + background: var(--ink); + border-color: var(--ink); + color: var(--paper); +} +.daypick { display: flex; gap: 6px; flex: 1; min-width: 190px; } +.daypick input { + flex: 1; + min-width: 0; + font-size: 16px; + background: var(--card); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 9px; + padding: 0 10px; +} +.daypick button { + background: var(--card); + color: var(--muted); + border: 1px solid var(--line); + border-radius: 9px; + padding: 0 14px; + font-size: 14px; + cursor: pointer; +} + +/* Tap board */ +.searchrow { margin-bottom: 14px; } +.filter { + width: 100%; + min-height: var(--tap); + font-size: 16px; + background: var(--card); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 10px; + padding: 0 14px; +} +.board { display: flex; flex-wrap: wrap; gap: 8px; } +.pill { + display: flex; + align-items: center; + gap: 8px; + min-height: var(--tap); + background: var(--card); + border: 1px solid var(--line); + border-radius: 11px; + color: var(--ink); + text-decoration: none; + font-weight: 600; + letter-spacing: -0.022em; +} +.pill .n { + font-family: ui-monospace, monospace; + font-weight: 400; + font-size: 10px; + color: var(--muted); +} +.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; } +.pill.sm { font-size: 14px; padding: 10px 13px; color: var(--muted); } + +/* Sides step */ +.q { margin: 14px 0 10px; font-size: 13.5px; color: var(--muted); } +.chips { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 16px; } +.chip { + display: flex; + align-items: center; + min-height: 42px; + padding: 0 14px; + background: var(--sunk); + border: 1px solid var(--line); + border-radius: 999px; + font-size: 14.5px; + cursor: pointer; +} +.chip input { position: absolute; opacity: 0; pointer-events: none; } +.chip:has(input:checked) { + background: var(--accent); + border-color: var(--accent); + color: var(--onacc); +} +.chip:has(input:focus-visible) { outline: 2.5px solid var(--accent); outline-offset: 2px; } + +/* Logged card */ +.logged .k { + margin: 0 0 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +.logged .nm { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 3px; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.035em; +} +.logged .sd { margin: 0 0 16px; font-size: 14.5px; color: var(--muted); } +.pair { display: flex; gap: 8px; } +.pair form { flex: 1; display: flex; } +.btn { + flex: 1; + min-height: var(--tap); + display: flex; + align-items: center; + justify-content: center; + background: var(--sunk); + border: 1px solid var(--line); + border-radius: 10px; + color: var(--ink); + text-decoration: none; + font-size: 15px; + font-weight: 600; + cursor: pointer; +} +.btn.del { color: var(--liha); } + +.ghost { + display: block; + width: 100%; + min-height: var(--tap); + margin-top: 4px; + background: none; + border: 0; + color: var(--muted); + font-size: 15px; + text-align: center; + line-height: var(--tap); + text-decoration: none; + cursor: pointer; +} +.empty { text-align: center; } +.empty p { margin: 0 0 8px; } +.empty .primary { margin-top: 12px; line-height: var(--tap); text-decoration: none; } + +/* Historia */ +.monthrule { + margin: 22px 0 5px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} +.entry, .gapline { + display: flex; + gap: 12px; + align-items: center; + min-height: var(--tap); + padding: 14px 0; + border-bottom: 1px solid var(--line); +} +.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); } +.entry time, .gapline time { + flex: none; + min-width: 54px; + font-family: ui-monospace, monospace; + font-size: 11px; + color: var(--muted); +} +.entry .nm { + display: flex; + align-items: center; + gap: 8px; + font-size: 17px; + font-weight: 600; + letter-spacing: -0.025em; +} +.entry .sd { margin-top: 1px; font-size: 12.5px; color: var(--muted); } +.entry .chev { + margin-left: auto; + flex: none; + padding: 0 6px; + font-size: 19px; + color: var(--muted); + text-decoration: none; +} +.gapline a { + margin-left: auto; + padding: 0 4px; + color: var(--accent); + font-weight: 600; + text-decoration: none; +} + .card { background: var(--card); border: 1px solid var(--line); diff --git a/cmd/foodster/store.go b/cmd/foodster/store.go new file mode 100644 index 0000000..e0698ed --- /dev/null +++ b/cmd/foodster/store.go @@ -0,0 +1,273 @@ +package main + +import ( + "database/sql" + "strings" + "time" +) + +// dateLayout is how calendar days are stored and passed around in URLs. +const dateLayout = "2006-01-02" + +// categoryFI maps the stored English category onto the Finnish key used for +// CSS custom properties and labels. +var categoryFI = map[string]string{ + "meat": "liha", + "chicken": "kana", + "fish": "kala", + "vegetarian": "kasvis", +} + +type Dish struct { + ID int64 + Name string + HasSides bool + Categories []string + 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. +func (d Dish) CategoryKey() string { + if len(d.Categories) == 1 { + return categoryFI[d.Categories[0]] + } + return "sek" +} + +// Size buckets the dish by how often it has been eaten. The board draws +// favourites as bigger targets, so the likely answer is the easiest to hit. +func (d Dish) Size() string { + switch { + case d.TimesEaten >= 10: + return "xl" + case d.TimesEaten >= 6: + return "lg" + case d.TimesEaten >= 3: + return "md" + default: + return "sm" + } +} + +type Side struct { + ID int64 + Name string +} + +type Entry struct { + Date time.Time + Main Dish + Sides []Side +} + +func (e Entry) SidesLabel() string { + if len(e.Sides) == 0 { + return "Ei lisukkeita" + } + names := make([]string, len(e.Sides)) + for i, s := range e.Sides { + names[i] = s.Name + } + return strings.Join(names, ", ") +} + +// 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) { + rows, err := db.Query(` + SELECT m.id, m.name, m.has_sides, + coalesce((SELECT group_concat(c.category) + FROM main_dish_categories c + WHERE c.main_dish_id = m.id), ''), + (SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id) + FROM main_dishes m + WHERE m.deleted_at IS NULL + AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%') + ORDER BY 5 DESC, m.name`, search, search) + if err != nil { + return nil, err + } + defer rows.Close() + + var dishes []Dish + for rows.Next() { + var d Dish + var cats string + if err := rows.Scan(&d.ID, &d.Name, &d.HasSides, &cats, &d.TimesEaten); err != nil { + return nil, err + } + if cats != "" { + d.Categories = strings.Split(cats, ",") + } + dishes = append(dishes, d) + } + return dishes, rows.Err() +} + +func dishByID(db *sql.DB, id int64) (*Dish, error) { + var d Dish + var cats string + err := db.QueryRow(` + SELECT m.id, m.name, m.has_sides, + coalesce((SELECT group_concat(c.category) + FROM main_dish_categories c + WHERE c.main_dish_id = m.id), ''), + (SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id) + FROM main_dishes m + WHERE m.id = ? AND m.deleted_at IS NULL`, id, + ).Scan(&d.ID, &d.Name, &d.HasSides, &cats, &d.TimesEaten) + if err != nil { + return nil, err + } + if cats != "" { + d.Categories = strings.Split(cats, ",") + } + return &d, nil +} + +func listSides(db *sql.DB) ([]Side, error) { + rows, err := db.Query( + `SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var sides []Side + for rows.Next() { + var s Side + if err := rows.Scan(&s.ID, &s.Name); err != nil { + return nil, err + } + sides = append(sides, s) + } + return sides, rows.Err() +} + +// entryFor returns the meal logged on a date, or nil when nothing is. Dishes +// are resolved even if they have since been soft-deleted, so history keeps +// displaying its names (PRD §6). +func entryFor(db *sql.DB, date time.Time) (*Entry, error) { + e := &Entry{Date: date} + var logID int64 + var cats string + + err := db.QueryRow(` + SELECT l.id, m.id, m.name, m.has_sides, + coalesce((SELECT group_concat(c.category) + FROM main_dish_categories c + WHERE c.main_dish_id = m.id), '') + FROM meal_log l + JOIN main_dishes m ON m.id = l.main_dish_id + WHERE l.date = ?`, date.Format(dateLayout), + ).Scan(&logID, &e.Main.ID, &e.Main.Name, &e.Main.HasSides, &cats) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if cats != "" { + e.Main.Categories = strings.Split(cats, ",") + } + + rows, err := db.Query(` + SELECT s.id, s.name + FROM meal_log_sides ls + JOIN side_dishes s ON s.id = ls.side_dish_id + WHERE ls.meal_log_id = ? + ORDER BY s.name`, logID) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var s Side + if err := rows.Scan(&s.ID, &s.Name); err != nil { + return nil, err + } + e.Sides = append(e.Sides, s) + } + return e, rows.Err() +} + +// saveEntry records what was eaten, replacing whatever was there. The unique +// constraint allows one entry per date, so editing is delete-then-insert; the +// old sides go with it through ON DELETE CASCADE. +func saveEntry(db *sql.DB, date time.Time, mainID int64, sideIDs []int64) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + day := date.Format(dateLayout) + if _, err := tx.Exec(`DELETE FROM meal_log WHERE date = ?`, day); err != nil { + return err + } + res, err := tx.Exec( + `INSERT INTO meal_log (date, main_dish_id) VALUES (?, ?)`, day, mainID) + if err != nil { + return err + } + logID, err := res.LastInsertId() + if err != nil { + return err + } + for _, id := range sideIDs { + if _, err := tx.Exec( + `INSERT OR IGNORE INTO meal_log_sides (meal_log_id, side_dish_id) VALUES (?, ?)`, + logID, id, + ); err != nil { + return err + } + } + return tx.Commit() +} + +func deleteEntry(db *sql.DB, date time.Time) error { + _, err := db.Exec(`DELETE FROM meal_log WHERE date = ?`, date.Format(dateLayout)) + return err +} + +// HistoryRow is one calendar day: either what was eaten or an unfilled gap. +type HistoryRow struct { + Date time.Time + Entry *Entry +} + +// history walks back day by day from today, so a day nobody wrote down shows +// up as an explicit gap rather than silently missing. It stops at the first +// entry ever recorded — before that there is no history to be missing. +func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) { + var first string + err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first) + if err == sql.ErrNoRows || first == "" { + return nil, nil + } + if err != nil { + return nil, err + } + firstDate, err := time.ParseInLocation(dateLayout, first, loc) + if err != nil { + return nil, err + } + + now := today(loc) + oldest := now.AddDate(0, 0, -days) + if firstDate.After(oldest) { + oldest = firstDate + } + + var rows []HistoryRow + for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) { + entry, err := entryFor(db, d) + if err != nil { + return nil, err + } + rows = append(rows, HistoryRow{Date: d, Entry: entry}) + } + return rows, nil +} diff --git a/cmd/foodster/store_test.go b/cmd/foodster/store_test.go new file mode 100644 index 0000000..4ea0bed --- /dev/null +++ b/cmd/foodster/store_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "database/sql" + "fmt" + "os" + "strings" + "testing" + "time" +) + +// dbHandle wraps a scratch database with lookups the tests need, so a test +// reads as "log Lohikeitto" rather than juggling row ids. +type dbHandle struct{ db *sql.DB } + +func (h *dbHandle) mainNamed(t *testing.T, name string) int64 { + t.Helper() + var id int64 + if err := h.db.QueryRow( + `SELECT id FROM main_dishes WHERE name = ?`, name).Scan(&id); err != nil { + t.Fatalf("main dish %q: %v", name, err) + } + return id +} + +func (h *dbHandle) sideNamed(t *testing.T, name string) int64 { + t.Helper() + var id int64 + if err := h.db.QueryRow( + `SELECT id FROM side_dishes WHERE name = ?`, name).Scan(&id); err != nil { + t.Fatalf("side dish %q: %v", name, err) + } + return id +} + +func containsFold(s, sub string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(sub)) +} + +// seeded opens a scratch database populated from the committed bundle. +func seeded(t *testing.T) *dbHandle { + t.Helper() + + db, err := openDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("openDB: %v", err) + } + t.Cleanup(func() { db.Close() }) + + f, err := os.Open("../../seeds/testi.json") + if err != nil { + t.Fatalf("open seed: %v", err) + } + defer f.Close() + + if _, err := importBundle(db, f); err != nil { + t.Fatalf("import seed: %v", err) + } + return &dbHandle{db} +} + +func day(t *testing.T, s string) time.Time { + t.Helper() + d, err := time.ParseInLocation(dateLayout, s, time.UTC) + if err != nil { + t.Fatalf("bad date %q: %v", s, err) + } + return d +} + +func TestSaveAndReadEntry(t *testing.T) { + h := seeded(t) + date := day(t, "2026-09-05") + + if e, err := entryFor(h.db, date); err != nil || e != nil { + t.Fatalf("empty day: entry = %v, err = %v; want nil, nil", e, err) + } + + main := h.mainNamed(t, "Lohikeitto") + side := h.sideNamed(t, "Ruisleipä") + + if err := saveEntry(h.db, date, main, []int64{side}); err != nil { + t.Fatalf("saveEntry: %v", err) + } + + e, err := entryFor(h.db, date) + if err != nil || e == nil { + t.Fatalf("entryFor: %v, %v", e, err) + } + if e.Main.Name != "Lohikeitto" { + t.Errorf("main = %q, want Lohikeitto", e.Main.Name) + } + if e.SidesLabel() != "Ruisleipä" { + t.Errorf("sides = %q, want Ruisleipä", e.SidesLabel()) + } +} + +func TestSaveEntryReplacesTheDay(t *testing.T) { + h := seeded(t) + date := day(t, "2026-09-05") + + if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"), + []int64{h.sideNamed(t, "Ruisleipä")}); err != nil { + t.Fatalf("first save: %v", err) + } + // PRD §6 allows one entry per date, so a second save is an edit. + if err := saveEntry(h.db, date, h.mainNamed(t, "Lihapullat"), + []int64{h.sideNamed(t, "Perunamuusi"), h.sideNamed(t, "Vihersalaatti")}); err != nil { + t.Fatalf("second save: %v", err) + } + + var rows int + if err := h.db.QueryRow(`SELECT count(*) FROM meal_log WHERE date = ?`, + date.Format(dateLayout)).Scan(&rows); err != nil { + t.Fatalf("count: %v", err) + } + if rows != 1 { + t.Errorf("%d rows for one date, want 1", rows) + } + + e, _ := entryFor(h.db, date) + if e.Main.Name != "Lihapullat" { + t.Errorf("main = %q, want Lihapullat", e.Main.Name) + } + if len(e.Sides) != 2 { + t.Errorf("%d sides, want 2 (%s)", len(e.Sides), e.SidesLabel()) + } + + // The replaced entry's sides must go with it, not linger orphaned. + var orphans int + if err := h.db.QueryRow(`SELECT count(*) FROM meal_log_sides ls + WHERE NOT EXISTS (SELECT 1 FROM meal_log l WHERE l.id = ls.meal_log_id)`, + ).Scan(&orphans); err != nil { + t.Fatalf("orphan check: %v", err) + } + if orphans != 0 { + t.Errorf("%d orphaned sides after replace", orphans) + } +} + +func TestDeleteEntry(t *testing.T) { + h := seeded(t) + date := day(t, "2026-09-05") + + if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"), nil); err != nil { + t.Fatalf("save: %v", err) + } + if err := deleteEntry(h.db, date); err != nil { + t.Fatalf("delete: %v", err) + } + if e, _ := entryFor(h.db, date); e != nil { + t.Errorf("entry survived delete: %v", e) + } +} + +func TestListDishesOrdersByTimesEaten(t *testing.T) { + h := seeded(t) + lihapullat := h.mainNamed(t, "Lihapullat") + + for i := 1; i <= 3; i++ { + d := day(t, fmt.Sprintf("2026-09-%02d", i)) + if err := saveEntry(h.db, d, lihapullat, nil); err != nil { + t.Fatalf("save %d: %v", i, err) + } + } + + dishes, err := listDishes(h.db, "") + if err != nil { + t.Fatalf("listDishes: %v", err) + } + if len(dishes) == 0 { + t.Fatal("no dishes") + } + if dishes[0].Name != "Lihapullat" { + t.Errorf("first dish = %q, want the most-eaten Lihapullat", dishes[0].Name) + } + if dishes[0].TimesEaten != 3 { + t.Errorf("timesEaten = %d, want 3", dishes[0].TimesEaten) + } + if got := dishes[0].Size(); got != "md" { + t.Errorf("size for 3 = %q, want md", got) + } +} + +func TestListDishesSearchIsCaseInsensitive(t *testing.T) { + h := seeded(t) + + dishes, err := listDishes(h.db, "KEITTO") + if err != nil { + t.Fatalf("listDishes: %v", err) + } + if len(dishes) == 0 { + t.Fatal("no matches for KEITTO, want the soups") + } + for _, d := range dishes { + if !containsFold(d.Name, "keitto") { + t.Errorf("%q does not match the search", d.Name) + } + } +} + +func TestMultiCategoryDishUsesMixedMarker(t *testing.T) { + h := seeded(t) + + tortillat, err := dishByID(h.db, h.mainNamed(t, "Tortillat")) + if err != nil { + t.Fatalf("dishByID: %v", err) + } + if len(tortillat.Categories) != 4 { + t.Fatalf("%d categories, want 4", len(tortillat.Categories)) + } + if got := tortillat.CategoryKey(); got != "sek" { + t.Errorf("CategoryKey = %q, want sek", got) + } + + lohi, err := dishByID(h.db, h.mainNamed(t, "Lohikeitto")) + if err != nil { + t.Fatalf("dishByID: %v", err) + } + if got := lohi.CategoryKey(); got != "kala" { + t.Errorf("CategoryKey = %q, want kala", got) + } +} + +func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) { + h := seeded(t) + loc := time.UTC + now := today(loc) + + // Log today and three days ago, leaving two gaps between them. + if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil { + t.Fatalf("save today: %v", err) + } + if err := saveEntry(h.db, now.AddDate(0, 0, -3), h.mainNamed(t, "Lihapullat"), nil); err != nil { + t.Fatalf("save -3: %v", err) + } + + rows, err := history(h.db, loc, 60) + if err != nil { + t.Fatalf("history: %v", err) + } + // Walks back to the oldest entry only: today, -1, -2, -3. + if len(rows) != 4 { + t.Fatalf("%d rows, want 4", len(rows)) + } + if rows[0].Entry == nil || rows[0].Entry.Main.Name != "Lohikeitto" { + t.Errorf("first row should be today's Lohikeitto, got %+v", rows[0].Entry) + } + if rows[1].Entry != nil || rows[2].Entry != nil { + t.Error("the two unlogged days should be gaps") + } + if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" { + t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry) + } +} + +func TestHistoryEmptyWithoutEntries(t *testing.T) { + h := seeded(t) + + rows, err := history(h.db, time.UTC, 60) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(rows) != 0 { + t.Errorf("%d rows for an empty log, want 0", len(rows)) + } +} diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index e86a3b6..bc1c36a 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -2,15 +2,19 @@ package main import ( "fmt" + "strconv" "time" ) -// Finnish weekday names. Go formats dates in English only, and this app has -// exactly one locale (PRD §5). +// Finnish weekday and month names. Go formats dates in English only, and this +// app has exactly one locale (PRD §5). var ( weekdaysFI = [...]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko", "torstai", "perjantai", "lauantai"} weekdayAbbrFI = [...]string{"su", "ma", "ti", "ke", "to", "pe", "la"} + monthsFI = [...]string{"", "tammikuu", "helmikuu", "maaliskuu", "huhtikuu", + "toukokuu", "kesäkuu", "heinäkuu", "elokuu", "syyskuu", "lokakuu", + "marraskuu", "joulukuu"} ) func longDateFI(t time.Time) string { @@ -21,6 +25,31 @@ func dayLabelFI(t time.Time) string { return weekdayAbbrFI[t.Weekday()] + " " + t.Format("2.1.") } +func monthFI(t time.Time) string { + return monthsFI[int(t.Month())] +} + +func isoDate(t time.Time) string { + return t.Format(dateLayout) +} + +// dayURL links to a specific day, leaving today as the bare path. +func dayURL(base string, d, now time.Time) string { + if d.Equal(now) { + return base + } + return base + "?pvm=" + isoDate(d) +} + +// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for +// any day but today. +func pickSeparator(v logView) string { + if v.Date.Equal(v.Today) { + return "?" + } + return "&" +} + // countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive // after every number except one. func countFI(n int, one, many string) string { @@ -64,29 +93,182 @@ templ tab(href, label, current string) { } } -templ indexPage(now time.Time) { +// ---------------------------------------------------------------- Kirjaa +templ logPage(v logView) { @page("Foodster", "/") {

Mitä syötiin?

-

{ longDateFI(now) }

+

{ longDateFI(v.Date) }

+ @daySwitch(v)
-

Ruokien kirjaus tulee tähän.

+ switch { + case v.Chosen != nil: + @sidesStep(v) + case v.Entry != nil: + @loggedCard(v) + default: + @board(v) + }
} } -templ historyPage() { +templ daySwitch(v logView) { +
+ @dayButton("Tänään", v.Today, v.Date, v.Today) + @dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today) +
+ + +
+
+} + +templ dayButton(label string, target, selected, now time.Time) { + if target.Equal(selected) { + { label } + } else { + { label } + } +} + +templ board(v logView) { + if len(v.Dishes) == 0 && v.Search == "" { +
+

Ruokalista on tyhjä.

+

Tuo ruokia Ruoat-välilehdellä, niin ne ilmestyvät tähän.

+ Siirry ruokiin +
+ } else { +
+ if !v.Date.Equal(v.Today) { + + } + +
+ if len(v.Dishes) == 0 { +

Ei osumia haulle { v.Search }.

+ } +
+ for _, d := range v.Dishes { + @dishPill(d, v) + } +
+ } +} + +templ dishPill(d Dish, v logView) { + + { d.Name } + if d.TimesEaten > 0 { + { strconv.Itoa(d.TimesEaten) } + } + +} + +templ sidesStep(v logView) { +
+

+ + { v.Chosen.Name } +

+
+ + + if v.Chosen.HasSides && len(v.Sides) > 0 { +

Lisukkeita?

+
+ for _, s := range v.Sides { + + } +
+ } else { +

Tarjoillaan sellaisenaan.

+ } + +
+ Peruuta +
+} + +templ loggedCard(v logView) { +
+

+ if v.Date.Equal(v.Today) { + Tänään kirjattu + } else { + { dayLabelFI(v.Date) } kirjattu + } +

+

+ + { v.Entry.Main.Name } +

+

{ v.Entry.SidesLabel() }

+ +
+} + +// ---------------------------------------------------------------- Historia +templ historyPage(rows []HistoryRow) { @page("Historia — Foodster", "/historia") {

Historia

-

Merkinnät tulevat tähän.

+ if len(rows) == 0 { +

Ei vielä merkintöjä.

+ } + for i, row := range rows { + if i == 0 || rows[i-1].Date.Month() != row.Date.Month() { +

{ monthFI(row.Date) }

+ } + if row.Entry != nil { +
+ +
+
+ + { row.Entry.Main.Name } +
+
{ row.Entry.SidesLabel() }
+
+
+
+ } else { +
+ + Ei merkintää + diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 7f8ce4e..319b0a7 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -79,6 +79,42 @@ check "empty submit is explained" \ check "malformed JSON is explained" \ "$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa" +# ---- the log flow, against the dishes imported above -------------------- + +board=$(curl -s -u ":$pass" "http://$addr/") +check "board lists imported dishes" "$board" "Lihapullat" + +# 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 + echo " FAIL could not find a dish link on the board" + fail=1 + ruoka=1 +fi + +check "picking a dish opens the sides step" \ + "$(curl -s -u ":$pass" "http://$addr/?ruoka=$ruoka")" "Tallenna" + +check "saving redirects back to the day" \ + "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ + -d "pvm=2026-09-05&ruoka=$ruoka" "http://$addr/kirjaa")" "303" + +check "the saved day shows what was eaten" \ + "$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu" + +check "history lists the entry" \ + "$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu" + +check "deleting redirects back" \ + "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ + -d "pvm=2026-09-05" "http://$addr/poista")" "303" + +check "the day is empty again" \ + "$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "Etsi" + +check "search filters the board" \ + "$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto" + if [ "$fail" -ne 0 ]; then echo "smoke: FAILED" exit 1