Build the Kirjaa log flow and Historia list

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.
This commit is contained in:
Esa Kataja
2026-09-05 18:29:38 +03:00
parent c6f44bb427
commit e4503e8433
10 changed files with 1146 additions and 12 deletions
+7 -2
View File
@@ -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
+16
View File
@@ -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.
+15 -1
View File
@@ -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
+119 -2
View File
@@ -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) {
+2
View File
@@ -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)
+222
View File
@@ -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);
+273
View File
@@ -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
}
+267
View File
@@ -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))
}
}
+189 -7
View File
@@ -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", "/") {
<header class="appbar">
<h2>Mitä syötiin?</h2>
<p class="meta">{ longDateFI(now) }</p>
<p class="meta">{ longDateFI(v.Date) }</p>
@daySwitch(v)
</header>
<main class="pad">
<p class="muted">Ruokien kirjaus tulee tähän.</p>
switch {
case v.Chosen != nil:
@sidesStep(v)
case v.Entry != nil:
@loggedCard(v)
default:
@board(v)
}
</main>
}
}
templ historyPage() {
templ daySwitch(v logView) {
<div class="dayseg">
@dayButton("Tänään", v.Today, v.Date, v.Today)
@dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
<form method="get" action="/" class="daypick">
<input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/>
<button type="submit">Näytä</button>
</form>
</div>
}
templ dayButton(label string, target, selected, now time.Time) {
if target.Equal(selected) {
<a class="seg" aria-current="true" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
} else {
<a class="seg" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
}
}
templ board(v logView) {
if len(v.Dishes) == 0 && v.Search == "" {
<section class="card empty">
<p>Ruokalista on tyhjä.</p>
<p class="muted small">Tuo ruokia Ruoat-välilehdellä, niin ne ilmestyvät tähän.</p>
<a class="primary" href="/ruoat">Siirry ruokiin</a>
</section>
} else {
<form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
}
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi" aria-label="Etsi"/>
</form>
if len(v.Dishes) == 0 {
<p class="muted">Ei osumia haulle { v.Search }.</p>
}
<div class="board">
for _, d := range v.Dishes {
@dishPill(d, v)
}
</div>
}
}
templ dishPill(d Dish, v logView) {
<a
class={ "pill", d.Size() }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
>
<i class={ "dot", "d-" + d.CategoryKey() }></i>
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
templ sidesStep(v logView) {
<section class="card">
<h3>
<i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i>
{ v.Chosen.Name }
</h3>
<form method="post" action="/kirjaa">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
if v.Chosen.HasSides && len(v.Sides) > 0 {
<p class="q">Lisukkeita?</p>
<div class="chips">
for _, s := range v.Sides {
<label class="chip">
if v.Checked[s.ID] {
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) } checked/>
} else {
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) }/>
}
<span>{ s.Name }</span>
</label>
}
</div>
} else {
<p class="q">Tarjoillaan sellaisenaan.</p>
}
<button class="primary" type="submit">Tallenna</button>
</form>
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
</section>
}
templ loggedCard(v logView) {
<section class="card logged">
<p class="k">
if v.Date.Equal(v.Today) {
Tänään kirjattu
} else {
{ dayLabelFI(v.Date) } kirjattu
}
</p>
<p class="nm">
<i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i>
{ v.Entry.Main.Name }
</p>
<p class="sd">{ v.Entry.SidesLabel() }</p>
<div class="pair">
<a
class="btn"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
>Muokkaa</a>
<form method="post" action="/poista">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Poista</button>
</form>
</div>
</section>
}
// ---------------------------------------------------------------- Historia
templ historyPage(rows []HistoryRow) {
@page("Historia — Foodster", "/historia") {
<header class="appbar">
<h2>Historia</h2>
</header>
<main class="pad">
<p class="muted">Merkinnät tulevat tähän.</p>
if len(rows) == 0 {
<p class="muted">Ei vielä merkintöjä.</p>
}
for i, row := range rows {
if i == 0 || rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<div class="entry">
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</div>
<a class="chev" href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) } aria-label="Muokkaa"></a>
</div>
} else {
<div class="gapline">
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<a href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) }>Merkitse</a>
</div>
}
}
</main>
}
}
// ---------------------------------------------------------------- Ruoat
templ catalogPage(mains, sides int, report *ImportReport) {
@page("Ruoat — Foodster", "/ruoat") {
<header class="appbar">
+36
View File
@@ -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