Ruokien kirjaus tulee tähän.
+ switch { + case v.Chosen != nil: + @sidesStep(v) + case v.Entry != nil: + @loggedCard(v) + default: + @board(v) + }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", "/") {
Ruokien kirjaus tulee tähän.Mitä syötiin?
-
+
+ @daySwitch(v)
Ruokalista on tyhjä.
+Tuo ruokia Ruoat-välilehdellä, niin ne ilmestyvät tähän.
+ Siirry ruokiin +Ei osumia haulle { v.Search }.
+ } ++ if v.Date.Equal(v.Today) { + Tänään kirjattu + } else { + { dayLabelFI(v.Date) } kirjattu + } +
++ + { v.Entry.Main.Name } +
+{ v.Entry.SidesLabel() }
+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 { +