From e9754488dba65657bfabec62ad41611720af7e0f Mon Sep 17 00:00:00 2001
From: Esa Kataja
Date: Sat, 5 Sep 2026 19:28:54 +0000
Subject: [PATCH] Release: one log page, grouped dishes, live search (#1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Structure
- Kirjaa and Historia are one page. They were two views of the same thing — every history row already linked into the logger, and the logger had a day switcher. Two tabs instead of three. Also closed a gap: on an already-logged day there was no way to swap to a different dish, only to re-pick its sides.
- Ruoat → Ruuat, label and route.
- The catalog has a structure. It had no top-level headings at all — the mains simply began with "Liha". Both halves now carry a heading and a count, categories are visibly subordinate, and the add/edit forms collapse instead of filling the screen before any content.
Finding things
- Dishes grouped by category on both screens, Sekalaiset for multi-category ones. Derived from the stored set, not a fifth category, so one Tortillat still covers all four for the §8.1 suggester later.
- Live search on both lists, 250 ms after typing stops. Both remain plain GET forms, so they still filter with JavaScript off.
- History is paged 30 days at a time — it previously rendered every day back to the first entry, forever.
Correctness
- Future meals refused. The picker offered them and ?pvm= accepted them.
- today() wasn't midnight, so it never equalled a date parsed from ?pvm= — after saving, the card read "la 5.9. kirjattu" instead of "Tänään kirjattu".
- Deletes ask first, for dishes and logged meals. The meal is the more destructive: a dish is only soft-deleted.
- DB open failures name the path and uid, instead of unable to open database file (14).
Visual
- Category icons replace colour dots — steak, drumstick, fish, leaf, quartered circle.
- Row actions are a pencil and a bin; the header has a surface.
Housekeeping
- Datastar SDK dropped — one JSON decode was pulling in four modules including an HTTP compression stack. Five lines replace it.
- Release policy documented: main protected, releases arrive as PRs.
Co-authored-by: Esa Kataja
Reviewed-on: https://git.kessinen.com/Kessinen/foodster/pulls/1
---
Makefile | 8 +
PRD.md | 7 +-
README.md | 62 ++++-
cmd/foodster/catalog_test.go | 2 +-
cmd/foodster/handlers.go | 217 ++++++++++++---
cmd/foodster/main.go | 38 ++-
cmd/foodster/main_test.go | 75 ++++++
cmd/foodster/static/app.css | 119 ++++++--
cmd/foodster/store.go | 111 ++++++--
cmd/foodster/store_test.go | 59 +++-
cmd/foodster/views.templ | 506 +++++++++++++++++++++++++----------
mockups/icons/index.html | 145 ++++++++++
scripts/smoke.sh | 109 ++++++--
13 files changed, 1194 insertions(+), 264 deletions(-)
create mode 100644 mockups/icons/index.html
diff --git a/Makefile b/Makefile
index 2ad5146..47af48c 100644
--- a/Makefile
+++ b/Makefile
@@ -81,6 +81,14 @@ fix: ## Format Go and templ sources, tidy go.mod
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
+ @# A release tag must point into main, or the tag records a commit that
+ @# was never released.
+ @branch=$$(git symbolic-ref --short HEAD); \
+ if [ "$$branch" != "main" ]; then \
+ echo "releases are cut from main, not $$branch:"; \
+ echo " git switch main && git merge --ff-only dev"; \
+ exit 1; \
+ fi
@day=$$(date +%Y%m%d); \
tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \
echo "==> $$tag"; \
diff --git a/PRD.md b/PRD.md
index b53f1e6..0f42fb0 100644
--- a/PRD.md
+++ b/PRD.md
@@ -321,7 +321,7 @@ build and no asset bundler.
lines of `database/sql`. There are no down-migrations: restoring the
database file is the rollback for a single-household app.
- **Bundle import**: the §7.3 mass import is a live feature of the running
- app, on the Ruoat tab — paste JSON or upload a file, get a per-row report
+ app, on the Ruuat tab — paste JSON or upload a file, get a per-row report
back. A plain multipart form rather than a Datastar round trip, since the
response is a whole-page report and a form needs no client code. Uploads
are capped at 1 MiB. The same importer is also reachable as
@@ -360,7 +360,10 @@ on the server and run with Docker Compose.
- **Branches**: `main` carries released versions only, so its history is the
deployment history and every release tag points into it. Development happens
- on `dev` and merges into `main` when a release is cut.
+ on `dev`, and `main` is protected on the remote: it accepts no direct
+ pushes, so a release arrives as a pull request from `dev`. `make image`
+ additionally refuses to run outside `main` — that one has to be local,
+ because the tag and the image are made before anything reaches the remote.
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that
day. `make image` derives `N` by counting the day's existing git tags,
creates the new tag, and bakes the version into the binary through
diff --git a/README.md b/README.md
index 850b292..41eaf01 100644
--- a/README.md
+++ b/README.md
@@ -17,18 +17,29 @@ weeks of real history to weight against.
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** — add, edit and delete mains and sides, or import a whole bundle
- by paste or file upload. Deletes are soft, so old log entries keep showing
- the dish they used.
+- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered
+ and sized by how often they are eaten, so the likely answer is the biggest
+ target. The history sits on the same page underneath: every day back to the
+ first entry, unlogged days shown as explicit gaps, and every row a link that
+ loads that day into the logger above it.
+- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
+ by paste or file upload. Grouped by category and alphabetical inside, since
+ this is a list you manage rather than one you pick from. Deletes are soft,
+ so old log entries keep showing the dish they used.
- **Light / dark**, remembered per device, dark by default. The button shows
the theme that is on — moon while dark, sun while light — not the one a
click would bring.
Still to build:
+
+- Live search as you type, and paging for the history and catalog lists once
+ years of entries make them long. Both via Datastar.
+- Category icons instead of plain colour dots — colour and shape together, so
+ a red blob and a yellow blob are told apart by more than hue.
+- Edit and delete as icons in the catalog rows, and a confirmation step before
+ a delete actually happens.
+- A background for the header. Something subtle; the palette gets overhauled
+ later.
- Stage 2: the seven-meal suggester, which starts once there is history to
weight against.
@@ -50,16 +61,31 @@ One static Go binary. No Node.js, no bundler, no separate database server.
## Branches
`main` holds released versions only. Every release tag points at a commit on
-`main`, so its history is the deployment history.
+`main`, so its history is the deployment history. **Nothing is committed to
+`main` directly** — it moves only by fast-forwarding `dev` into it.
-All development happens on `dev`. Merge into `main` when cutting a release,
-then build and push the image from there.
+All development happens on `dev`. `main` is protected on the remote: it takes
+no direct pushes, so a release arrives through a pull request.
```sh
-git switch dev # where the work happens
-git switch main && git merge dev && make release
+git switch dev # where the work happens
+# ... commits ...
+make check # lint, unit tests, smoke
+git push origin dev
+
+tea pr create --base main --head dev # or open it in the forge
+# merge the pull request, then:
+
+git switch main && git pull --ff-only
+make release # builds, tags vYYYYMMDD-N, pushes the image
+git push origin --tags
```
+`make image` additionally refuses to run from any branch but `main`, so a
+release tag can never point at a commit that was not released. That check
+lives locally because it has to: tags and images are built before anything
+reaches the remote, so protection there cannot catch it.
+
## Quick start
```sh
@@ -84,7 +110,7 @@ make up/down/logs compose
## Importing dishes
-The **Ruoat** tab takes a bundle of mains and sides: paste the JSON or upload
+The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload
a file, and the app reports row by row what it did.
```json
@@ -181,6 +207,16 @@ That directory must exist and be owned by the user compose runs as — `make up`
creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
that is. Get them from `id -u` and `id -g`.
+If the app exits with `cannot open /data/foodster.db ... unable to open
+database file (14)`, the ownership does not match. Docker creates a missing
+bind-mount directory as root, and the container is not root:
+
+```sh
+ls -ldn data # whose is it?
+sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID
+docker compose restart
+```
+
## Security
Access is a single shared password over HTTP Basic — no accounts, no
diff --git a/cmd/foodster/catalog_test.go b/cmd/foodster/catalog_test.go
index 4f55a36..d4ae0b1 100644
--- a/cmd/foodster/catalog_test.go
+++ b/cmd/foodster/catalog_test.go
@@ -138,7 +138,7 @@ func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
if err := softDeleteSide(h.db, id); err != nil {
t.Fatalf("softDeleteSide: %v", err)
}
- sides, err := listSides(h.db)
+ sides, err := listSides(h.db, "")
if err != nil {
t.Fatalf("listSides: %v", err)
}
diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go
index f18f6e6..080a8ed 100644
--- a/cmd/foodster/handlers.go
+++ b/cmd/foodster/handlers.go
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
+ "encoding/json"
"errors"
"io"
"log"
@@ -17,9 +18,27 @@ 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
+const (
+ // historyDays is one window of the history under the logger, and the step
+ // that "show more" grows it by. Older days arrive a window at a time
+ // rather than all at once.
+ historyDays = 30
+
+ // maxHistoryDays caps what a hand-edited URL can ask for, so ?paivat=
+ // cannot be turned into a request to render a decade of rows.
+ maxHistoryDays = 366 * 5
+)
+
+// historyWindow reads ?paivat=, the number of days of history to show.
+func historyWindow(r *http.Request) int {
+ days := historyDays
+ if raw := r.URL.Query().Get("paivat"); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n > days {
+ days = min(n, maxHistoryDays)
+ }
+ }
+ return days
+}
type app struct {
db *sql.DB
@@ -36,26 +55,46 @@ 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.
+//
+// A future date is clamped to today. This is a record of what was eaten, so
+// there is nothing to write down for a dinner that has not happened, and a
+// stray entry dated next year would sit at the top of the history forever.
+// Every read and write goes through here, so the clamp covers them all.
func (a *app) date(r *http.Request) time.Time {
+ now := today(a.loc)
if raw := r.FormValue("pvm"); raw != "" {
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
+ if d.After(now) {
+ return now
+ }
return d
}
}
- return today(a.loc)
+ return now
}
-// logView is everything the Kirjaa screen needs.
+// logView is everything the log screen needs. Logging and history are one
+// page: every history row was already a link back into the logger, and the
+// day switcher made them two views of the same thing.
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
- New mainForm // inline "add the dish you were looking for"
+ 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
+ ShowBoard bool
+ Dishes []Dish // flat, only to know whether anything matched
+ Groups []DishGroup // what the board actually renders
+ Sides []Side
+ New mainForm // inline "add the dish you were looking for"
+ History HistoryPage
+ HistoryDays int // size of the window currently shown
+ HistoryMore int // the window size the "show more" link asks for
+
+ // Deleting a logged meal drops the row outright, unlike a dish which is
+ // only soft-deleted, so it asks first.
+ Confirming bool
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
@@ -67,6 +106,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
Checked: map[int64]bool{},
}
+ v.Confirming = r.URL.Query().Get("poista") != ""
+
entry, err := entryFor(a.db, date)
if err != nil {
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
@@ -89,23 +130,114 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
}
}
- if v.Chosen == nil && v.Entry == nil {
+ // The board shows when there is nothing logged yet, or when the entry is
+ // being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here,
+ // so swapping the dish and picking one for the first time are one path.
+ changing := r.URL.Query().Get("muuta") != "" || v.Search != ""
+ if v.Chosen == nil && (v.Entry == nil || changing) {
+ v.ShowBoard = true
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
}
+ // listDishes already orders by frequency then name, so grouping keeps
+ // the favourites at the top of each category.
+ v.Groups = groupDishes(v.Dishes)
// Seed the inline add form with whatever was searched for, so a miss
// turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
}
if v.Chosen != nil && v.Chosen.HasSides {
- if v.Sides, err = listSides(a.db); err != nil {
+ if v.Sides, err = listSides(a.db, ""); err != nil {
log.Printf("list sides: %v", err)
}
}
+ v.HistoryDays = historyWindow(r)
+ v.HistoryMore = v.HistoryDays + historyDays
+ if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
+ log.Printf("history: %v", err)
+ }
+
render(w, r, logPage(v))
}
+// searchSignals is what Datastar sends back: for a GET it JSON-encodes the
+// signals into the `datastar` query parameter.
+type searchSignals struct {
+ Haku string `json:"haku"`
+}
+
+// readSignals decodes that parameter.
+//
+// ponytail: the Datastar SDK does this too, but pulling it in for one JSON
+// decode dragged along four modules — an HTTP compression stack among them —
+// for an SSE generator this app never uses. Absent or empty is not an error:
+// the first request carries no signals.
+func readSignals(r *http.Request, into any) error {
+ raw := r.URL.Query().Get("datastar")
+ if raw == "" {
+ return nil
+ }
+ return json.Unmarshal([]byte(raw), into)
+}
+
+// fragment renders a piece of a page for Datastar to patch in. A plain
+// text/html response is enough — Datastar matches the returned element by its
+// id and replaces it, so there is no SSE stream to manage.
+func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := c.Render(r.Context(), w); err != nil {
+ log.Printf("fragment %s: %v", r.URL.Path, err)
+ }
+}
+
+// searchBoard re-renders the dish board as the search box is typed into.
+func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
+ var signals searchSignals
+ if err := readSignals(r, &signals); err != nil {
+ http.Error(w, "bad signals", http.StatusBadRequest)
+ return
+ }
+
+ v := logView{
+ Date: a.date(r),
+ Today: today(a.loc),
+ Search: strings.TrimSpace(signals.Haku),
+ }
+ dishes, err := listDishes(a.db, v.Search)
+ if err != nil {
+ log.Printf("search dishes: %v", err)
+ }
+ v.Dishes = dishes
+ v.Groups = groupDishes(dishes)
+ v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
+
+ fragment(w, r, boardList(v))
+}
+
+// searchCatalog re-renders the catalog lists as the search box is typed into.
+func (a *app) searchCatalog(w http.ResponseWriter, r *http.Request) {
+ var signals searchSignals
+ if err := readSignals(r, &signals); err != nil {
+ http.Error(w, "bad signals", http.StatusBadRequest)
+ return
+ }
+
+ v := catalogView{Search: strings.TrimSpace(signals.Haku)}
+ mains, err := listDishes(a.db, v.Search)
+ if err != nil {
+ log.Printf("search catalog: %v", err)
+ }
+ v.Mains = len(mains)
+ sortByName(mains)
+ v.Groups = groupDishes(mains)
+ if v.Sides, err = listSides(a.db, v.Search); err != nil {
+ log.Printf("search sides: %v", err)
+ }
+
+ fragment(w, r, catalogList(v))
+}
+
// quickAdd creates a dish from the Kirjaa screen and goes straight on to
// logging it. Hunting for something that is not in the catalog yet should not
// mean a detour through Ruoat and a lost train of thought.
@@ -149,16 +281,23 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
// Rejected: back to the board with the form filled in and the search
// still narrowed, so the add card stays on screen.
v := logView{
- Date: date,
- Today: today(a.loc),
- Search: form.Name,
- Checked: map[int64]bool{},
- New: form,
+ Date: date,
+ Today: today(a.loc),
+ Search: form.Name,
+ Checked: map[int64]bool{},
+ New: form,
+ ShowBoard: true,
}
var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
}
+ v.Groups = groupDishes(v.Dishes)
+ v.HistoryDays = historyWindow(r)
+ v.HistoryMore = v.HistoryDays + historyDays
+ if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
+ log.Printf("history: %v", err)
+ }
render(w, r, logPage(v))
}
@@ -214,14 +353,6 @@ func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Ti
http.Redirect(w, r, target, http.StatusSeeOther)
}
-func (a *app) history(w http.ResponseWriter, r *http.Request) {
- rows, err := history(a.db, a.loc, historyDays)
- if err != nil {
- log.Printf("history: %v", err)
- }
- render(w, r, historyPage(rows))
-}
-
// mainForm and sideForm carry what the user typed, so a rejected submission
// comes back filled in rather than blank.
type mainForm struct {
@@ -239,16 +370,24 @@ type sideForm struct {
}
type catalogView struct {
- Mains []Dish
+ Groups []DishGroup
Sides []Side
Main mainForm
Side sideForm
Report *ImportReport
+ Mains int // count, for the header
+ Search string
+
+ // The row awaiting a delete confirmation, if any. A trash icon is easy to
+ // hit by accident, so the row asks before anything happens.
+ DeleteID int64
+ DeleteKind string
}
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
v := catalogView{
- Main: mainForm{Categories: map[string]bool{}, HasSides: true},
+ Main: mainForm{Categories: map[string]bool{}, HasSides: true},
+ Search: strings.TrimSpace(r.URL.Query().Get("haku")),
}
// ?muokkaa= loads a dish into its form; the same form adds and edits.
@@ -274,6 +413,12 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
}
}
}
+ if raw := r.URL.Query().Get("poista"); raw != "" {
+ if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
+ v.DeleteID = id
+ v.DeleteKind = r.URL.Query().Get("tyyppi")
+ }
+ }
a.renderCatalog(w, r, v)
}
@@ -283,11 +428,15 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
v.Main.Categories = map[string]bool{}
}
- var err error
- if v.Mains, err = listDishes(a.db, ""); err != nil {
+ mains, err := listDishes(a.db, v.Search)
+ if err != nil {
log.Printf("list mains: %v", err)
}
- if v.Sides, err = listSides(a.db); err != nil {
+ v.Mains = len(mains)
+ sortByName(mains) // the catalog is managed, so position should be predictable
+ v.Groups = groupDishes(mains)
+
+ if v.Sides, err = listSides(a.db, v.Search); err != nil {
log.Printf("list sides: %v", err)
}
render(w, r, catalogPage(v))
diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go
index 59f47e5..b2182a9 100644
--- a/cmd/foodster/main.go
+++ b/cmd/foodster/main.go
@@ -128,6 +128,17 @@ func openDB(path string) (*sql.DB, error) {
// sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend.
db.SetMaxOpenConns(1)
+ // sql.Open is lazy, so without this the first failure surfaces from
+ // whatever query ran first and says nothing useful. The usual cause is a
+ // bind-mounted directory owned by a different user than the container
+ // runs as, so name the path and the uid.
+ if err := db.Ping(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf(
+ "cannot open %s as uid %d gid %d: %w (is that directory writable by this user?)",
+ path, os.Getuid(), os.Getgid(), err)
+ }
+
if err := migrate(db); err != nil {
db.Close()
return nil, err
@@ -147,13 +158,14 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd)
+ mux.HandleFunc("GET /etsi", a.searchBoard)
mux.HandleFunc("POST /poista", a.delete)
- mux.HandleFunc("GET /historia", a.history)
- mux.HandleFunc("GET /ruoat", a.catalog)
- mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
- mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
- mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
- mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
+ mux.HandleFunc("GET /ruuat", a.catalog)
+ mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
+ mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
+ mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
+ mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
+ mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
root := http.NewServeMux()
@@ -202,11 +214,17 @@ func challenge(w http.ResponseWriter) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
-// today is the current calendar day in the configured location. Every date in
-// this app goes through here rather than time.Local, which would be UTC
-// whenever TZ is unset and quietly shift evening entries to the day before.
+// today is the current calendar day in the configured location, truncated to
+// midnight. Every date in this app goes through here rather than time.Local,
+// which would be UTC whenever TZ is unset and quietly shift evening entries to
+// the day before.
+//
+// The truncation matters: dates parsed from ?pvm= are midnight, so a today
+// carrying a time of day would never compare equal to one of them, and the UI
+// would stop recognising today as today the moment the date was explicit.
func today(loc *time.Location) time.Time {
- return time.Now().In(loc)
+ now := time.Now().In(loc)
+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
}
// normalizeName collapses whitespace and capitalises the first letter for
diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go
index a335ff6..edebd4c 100644
--- a/cmd/foodster/main_test.go
+++ b/cmd/foodster/main_test.go
@@ -3,6 +3,7 @@ package main
import (
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"strings"
"testing"
@@ -30,6 +31,80 @@ func TestNormalizeName(t *testing.T) {
}
}
+func TestTodayIsMidnight(t *testing.T) {
+ now := today(time.UTC)
+ if h, m, s := now.Clock(); h != 0 || m != 0 || s != 0 {
+ t.Errorf("today() = %s, want midnight", now)
+ }
+ // A date parsed from a URL must compare equal to it, or the UI stops
+ // recognising today as today whenever the date is spelled out.
+ parsed, err := time.ParseInLocation(dateLayout, now.Format(dateLayout), time.UTC)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if !parsed.Equal(now) {
+ t.Errorf("parsed %s != today %s", parsed, now)
+ }
+}
+
+func TestDateRejectsTheFuture(t *testing.T) {
+ a := &app{loc: time.UTC}
+ now := today(time.UTC)
+
+ cases := []struct {
+ name string
+ pvm string
+ want time.Time
+ }{
+ {"no parameter", "", now},
+ {"today", now.Format(dateLayout), now},
+ {"yesterday", now.AddDate(0, 0, -1).Format(dateLayout), now.AddDate(0, 0, -1)},
+ // Nothing was eaten tomorrow, and a stray entry dated next year would
+ // sit at the top of the history forever.
+ {"tomorrow", now.AddDate(0, 0, 1).Format(dateLayout), now},
+ {"next year", now.AddDate(1, 0, 0).Format(dateLayout), now},
+ {"nonsense", "eilen", now},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "/?pvm="+c.pvm, nil)
+ if got := a.date(r); !got.Equal(c.want) {
+ t.Errorf("date = %s, want %s", got.Format(dateLayout), c.want.Format(dateLayout))
+ }
+ })
+ }
+}
+
+func TestReadSignals(t *testing.T) {
+ cases := []struct {
+ name string
+ query string
+ want string
+ wantErr bool
+ }{
+ {"a signal", `/etsi?datastar=` + url.QueryEscape(`{"haku":"keitto"}`), "keitto", false},
+ {"other signals are ignored", `/etsi?datastar=` + url.QueryEscape(`{"haku":"kala","muu":1}`), "kala", false},
+ // The first request carries no signals at all; that is not a failure.
+ {"no parameter", "/etsi", "", false},
+ {"empty parameter", "/etsi?datastar=", "", false},
+ {"malformed json", "/etsi?datastar=%7Bnope", "", true},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ var got searchSignals
+ err := readSignals(httptest.NewRequest(http.MethodGet, c.query, nil), &got)
+ if (err != nil) != c.wantErr {
+ t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
+ }
+ if got.Haku != c.want {
+ t.Errorf("haku = %q, want %q", got.Haku, c.want)
+ }
+ })
+ }
+}
+
func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css
index bd34057..4990e15 100644
--- a/cmd/foodster/static/app.css
+++ b/cmd/foodster/static/app.css
@@ -20,6 +20,11 @@
--kala: light-dark(#25688F, #63AAD8);
--kasvis: light-dark(#457A3C, #82BE7A);
+ /* Its own name rather than reusing --card, so the header can be recoloured
+ later without dragging every card with it. Keep the theme-color meta tags
+ in views.templ in step: those need literal hex. */
+ --header: light-dark(#FFFFFF, #1C1E22);
+
--tap: 48px; /* minimum touch target */
}
@@ -43,11 +48,15 @@ button, input, select { font: inherit; }
:focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
+/* The header is the brand and the theme toggle, and nothing else. The page
+ title below it is content, so it stays on the page background. */
.brandbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
+ background: var(--header);
+ border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
@@ -84,9 +93,7 @@ html[data-theme="dark"] .themetoggle .i-sun { display: none; }
html[data-theme="light"] .themetoggle .i-moon { display: none; }
.appbar {
- background: var(--paper);
- border-bottom: 1px solid var(--line);
- padding: 2px 16px 12px;
+ padding: 16px 16px 4px;
}
.appbar h2 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
.appbar .meta { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
@@ -116,21 +123,17 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
}
.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%);
-}
+/* Category marks carry colour and shape together, so they are readable
+ without having learned which hue means what. */
+.cat { flex: none; display: block; }
+.cat svg { display: block; width: 16px; height: 16px; }
+.pill.xl .cat svg { width: 19px; height: 19px; }
+.logged .cat svg { width: 22px; height: 22px; }
+
+.c-liha { color: var(--liha); }
+.c-kana { color: var(--kana); }
+.c-kala { color: var(--kala); }
+.c-kasvis { color: var(--kasvis); }
/* Day switcher */
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
@@ -307,6 +310,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-transform: uppercase;
color: var(--muted);
}
+/* History sits under the logger on the same page, so whole rows are links. */
+.history { margin-top: 8px; }
.entry, .gapline {
display: flex;
gap: 12px;
@@ -314,6 +319,27 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
min-height: var(--tap);
padding: 14px 0;
border-bottom: 1px solid var(--line);
+ color: inherit;
+ text-decoration: none;
+}
+.gapline .act {
+ margin-left: auto;
+ padding: 0 4px;
+ color: var(--accent);
+ font-weight: 600;
+}
+.more {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: var(--tap);
+ margin-top: 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ color: var(--accent);
+ font-size: 15px;
+ font-weight: 600;
+ text-decoration: none;
}
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
.entry time, .gapline time {
@@ -397,15 +423,52 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
cursor: pointer;
}
-/* Catalog rows */
+/* Catalog structure: Pääruuat and Lisukkeet are the two halves of the
+ catalog, the categories are subdivisions of the first. Two levels, so they
+ must not look alike. */
+.section + .section { margin-top: 34px; }
+.sectiontitle {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin: 0 0 4px;
+ padding-bottom: 8px;
+ border-bottom: 2px solid var(--ink);
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.03em;
+}
+.sectiontitle .count {
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--sunk);
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0;
+}
+
.sechead {
- margin: 26px 0 6px;
+ margin: 20px 0 2px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--muted);
}
+
+/* Collapsed add/edit forms, so the page opens on the catalog. */
+.addform > summary {
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 15px;
+ min-height: 24px;
+}
+.addform[open] > summary {
+ margin-bottom: 14px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--line);
+}
.row {
display: flex;
align-items: center;
@@ -417,12 +480,14 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
.rowtext { flex: 1; min-width: 0; }
.rowtext .nm { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; }
.rowtext .sd { font-size: 12.5px; color: var(--muted); }
-.rowactions { display: flex; align-items: center; gap: 4px; flex: none; }
+.rowactions { display: flex; align-items: center; gap: 2px; flex: none; }
.rowactions a, .rowactions button {
+ min-width: 40px;
min-height: 40px;
- padding: 0 10px;
+ padding: 0 8px;
display: flex;
align-items: center;
+ justify-content: center;
background: none;
border: 0;
border-radius: 8px;
@@ -432,9 +497,19 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-decoration: none;
cursor: pointer;
}
+.rowactions svg { display: block; }
.rowactions a:hover { color: var(--accent); }
.rowactions .del { color: var(--liha); }
+/* The row asks before a delete happens; an icon is easy to hit by accident. */
+.rowactions.confirming {
+ gap: 4px;
+ font-size: 13px;
+ color: var(--muted);
+}
+.rowactions.confirming > span { padding-right: 2px; }
+.rowactions.confirming .del { color: var(--liha); font-weight: 700; }
+
.field input[type="text"] {
width: 100%;
min-height: var(--tap);
diff --git a/cmd/foodster/store.go b/cmd/foodster/store.go
index 872b61c..8369440 100644
--- a/cmd/foodster/store.go
+++ b/cmd/foodster/store.go
@@ -3,6 +3,7 @@ package main
import (
"database/sql"
"errors"
+ "slices"
"strings"
"time"
)
@@ -127,9 +128,12 @@ func dishByID(db *sql.DB, id int64) (*Dish, error) {
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`)
+func listSides(db *sql.DB, search string) ([]Side, error) {
+ rows, err := db.Query(`
+ SELECT id, name FROM side_dishes
+ WHERE deleted_at IS NULL
+ AND (? = '' OR lower(name) LIKE '%' || lower(?) || '%')
+ ORDER BY name`, search, search)
if err != nil {
return nil, err
}
@@ -341,42 +345,103 @@ func sideByID(db *sql.DB, id int64) (*Side, error) {
return &s, nil
}
+// DishGroup is one category's worth of dishes for the catalog listing.
+type DishGroup struct {
+ Key string
+ Label string
+ Dishes []Dish
+}
+
+// groupOrder fixes the order the catalog lists categories in. Sekalaiset is a
+// display grouping for dishes covering more than one category, not a fifth
+// category: the stored set is what PRD §8.1 counts for coverage, and one
+// Tortillat still satisfies meat, chicken, fish and vegetarian at once.
+var groupOrder = []DishGroup{
+ {Key: "liha", Label: "Liha"},
+ {Key: "kana", Label: "Kana"},
+ {Key: "kala", Label: "Kala"},
+ {Key: "kasvis", Label: "Kasvis"},
+ {Key: "sek", Label: "Sekalaiset"},
+}
+
+// groupDishes buckets dishes by category, keeping whatever order they arrived
+// in. The caller decides that order: the log board hands over listDishes'
+// frequency-then-name ordering, the catalog sorts by name first.
+func groupDishes(dishes []Dish) []DishGroup {
+ byKey := make(map[string][]Dish, len(groupOrder))
+ for _, d := range dishes {
+ key := d.CategoryKey()
+ byKey[key] = append(byKey[key], d)
+ }
+
+ var groups []DishGroup
+ for _, g := range groupOrder {
+ in := byKey[g.Key]
+ if len(in) == 0 {
+ continue
+ }
+ groups = append(groups, DishGroup{Key: g.Key, Label: g.Label, Dishes: in})
+ }
+ return groups
+}
+
+// sortByName orders dishes alphabetically, case-insensitively.
+func sortByName(dishes []Dish) {
+ slices.SortFunc(dishes, func(a, b Dish) int {
+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
+ })
+}
+
// 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
+// HistoryPage is one window of history plus where to continue from. After a
+// few years of daily entries the whole log is far too much to render at once.
+type HistoryPage struct {
+ Rows []HistoryRow
+ More bool // older entries exist beyond this window
+ Next time.Time // the day the next window starts at
+}
+
+// history walks back day by day from a given day, 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, from time.Time, days int) (HistoryPage, error) {
+ var first sql.NullString
+ if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil {
+ if err == sql.ErrNoRows {
+ return HistoryPage{}, nil
+ }
+ return HistoryPage{}, err
}
- if err != nil {
- return nil, err
+ if !first.Valid || first.String == "" {
+ return HistoryPage{}, nil
}
- firstDate, err := time.ParseInLocation(dateLayout, first, loc)
+ firstDate, err := time.ParseInLocation(dateLayout, first.String, loc)
if err != nil {
- return nil, err
+ return HistoryPage{}, err
+ }
+ if from.Before(firstDate) {
+ return HistoryPage{}, nil
}
- now := today(loc)
- oldest := now.AddDate(0, 0, -days)
- if firstDate.After(oldest) {
+ oldest := from.AddDate(0, 0, -days+1)
+ page := HistoryPage{More: true}
+ if !firstDate.Before(oldest) {
oldest = firstDate
+ page.More = false
}
+ page.Next = oldest.AddDate(0, 0, -1)
- var rows []HistoryRow
- for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
+ for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
entry, err := entryFor(db, d)
if err != nil {
- return nil, err
+ return HistoryPage{}, err
}
- rows = append(rows, HistoryRow{Date: d, Entry: entry})
+ page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry})
}
- return rows, nil
+ return page, nil
}
diff --git a/cmd/foodster/store_test.go b/cmd/foodster/store_test.go
index 4ea0bed..1a2fdb7 100644
--- a/cmd/foodster/store_test.go
+++ b/cmd/foodster/store_test.go
@@ -235,10 +235,11 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
t.Fatalf("save -3: %v", err)
}
- rows, err := history(h.db, loc, 60)
+ page, err := history(h.db, loc, now, 60)
if err != nil {
t.Fatalf("history: %v", err)
}
+ rows := page.Rows
// Walks back to the oldest entry only: today, -1, -2, -3.
if len(rows) != 4 {
t.Fatalf("%d rows, want 4", len(rows))
@@ -252,16 +253,66 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry)
}
+ if page.More {
+ t.Error("More is set although the window reached the oldest entry")
+ }
+}
+
+func TestHistoryPagesInWindows(t *testing.T) {
+ h := seeded(t)
+ loc := time.UTC
+ now := today(loc)
+
+ // Entries today and 9 days back, with a 5-day window over 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, -9), h.mainNamed(t, "Lihapullat"), nil); err != nil {
+ t.Fatalf("save -9: %v", err)
+ }
+
+ first, err := history(h.db, loc, now, 5)
+ if err != nil {
+ t.Fatalf("first window: %v", err)
+ }
+ if len(first.Rows) != 5 {
+ t.Errorf("%d rows in the first window, want 5", len(first.Rows))
+ }
+ if !first.More {
+ t.Error("More should be set: older entries exist")
+ }
+ if want := now.AddDate(0, 0, -5); !first.Next.Equal(want) {
+ t.Errorf("Next = %s, want %s", first.Next.Format(dateLayout), want.Format(dateLayout))
+ }
+
+ // The windows must meet exactly: no day repeated, none skipped.
+ second, err := history(h.db, loc, first.Next, 5)
+ if err != nil {
+ t.Fatalf("second window: %v", err)
+ }
+ if len(second.Rows) != 5 {
+ t.Errorf("%d rows in the second window, want 5", len(second.Rows))
+ }
+ if second.More {
+ t.Error("the second window reaches the oldest entry, so More should be clear")
+ }
+ last := second.Rows[len(second.Rows)-1]
+ if last.Entry == nil || last.Entry.Main.Name != "Lihapullat" {
+ t.Errorf("last row should be the oldest entry, got %+v", last.Entry)
+ }
}
func TestHistoryEmptyWithoutEntries(t *testing.T) {
h := seeded(t)
- rows, err := history(h.db, time.UTC, 60)
+ page, err := history(h.db, time.UTC, today(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))
+ if len(page.Rows) != 0 {
+ t.Errorf("%d rows for an empty log, want 0", len(page.Rows))
+ }
+ if page.More {
+ t.Error("More is set although there is no history at all")
}
}
diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ
index f771635..5be62d6 100644
--- a/cmd/foodster/views.templ
+++ b/cmd/foodster/views.templ
@@ -1,6 +1,7 @@
package main
import (
+ "encoding/json"
"fmt"
"strconv"
"strings"
@@ -51,6 +52,26 @@ func pickSeparator(v logView) string {
return "&"
}
+// jsString renders a Go string as a JavaScript literal, for the data-signals
+// attribute that seeds the search box.
+func jsString(s string) string {
+ b, err := json.Marshal(s)
+ if err != nil {
+ return `""`
+ }
+ return string(b)
+}
+
+// searchURL is where the live search posts back to. The day travels in the
+// path so the board keeps rendering links for the right date; the search text
+// travels as a Datastar signal.
+func searchURL(v logView) string {
+ if v.Date.Equal(v.Today) {
+ return "/etsi"
+ }
+ return "/etsi?pvm=" + isoDate(v.Date)
+}
+
// categoryLabels lists a dish's categories in Finnish, for the catalog rows.
func categoryLabels(d Dish) string {
names := make([]string, 0, len(d.Categories))
@@ -78,8 +99,10 @@ templ page(title, current string) {
-
-
+ // Matches --header in app.css so the browser chrome continues the
+ // header rather than butting against it.
+
+
{ title }
@@ -109,9 +132,6 @@ templ brandbar() {
}
-// themeSwitch marks the active theme rather than labelling itself with the one
-// a click would produce. aria-pressed is set by theme.js on load, because only
-// the device knows what was chosen.
// themeSwitch shows the theme that is on right now — moon while dark, sun
// while light — and clicking swaps it. Both icons are in the markup and CSS
// picks one, so the server never has to know the device's choice.
@@ -142,11 +162,92 @@ templ iconMoon() {
}
+// categoryIcon draws a dish's category as colour *and* shape. Colour on its
+// own was not telling: a red blob and a yellow blob only differ once you have
+// learned the legend.
+templ categoryIcon(key string) {
+ switch key {
+ case "liha":
+
+ @glyphLiha()
+
+ case "kana":
+
+ @glyphKana()
+
+ case "kala":
+
+ @glyphKala()
+
+ case "kasvis":
+
+ @glyphKasvis()
+
+ default:
+
+ @glyphSekalaiset()
+
+ }
+}
+
+// A steak, its bone knocked out with fill-rule so the hole is transparent on
+// whatever background the icon lands on.
+templ glyphLiha() {
+
+}
+
+// A drumstick. The bone is what keeps it apart from the steak at small sizes,
+// where both are otherwise warm blobs.
+templ glyphKana() {
+
+}
+
+templ glyphKala() {
+
+}
+
+// No midrib: stroking one in the card colour only works on a card, and these
+// also sit on the page background in the history list.
+templ glyphKasvis() {
+
+}
+
+// Quartered, one wedge per category: the mark already means "all of them".
+templ glyphSekalaiset() {
+
+}
+
templ tabbar(current string) {
}
@@ -170,21 +271,67 @@ templ logPage(v logView) {
switch {
case v.Chosen != nil:
@sidesStep(v)
- case v.Entry != nil:
- @loggedCard(v)
- default:
+ case v.ShowBoard:
@board(v)
+ default:
+ @loggedCard(v)
}
+ @historyList(v)
}
}
+// historyList sits under the day being logged: the two were always one thing,
+// since every row here is a link back into the logger above it.
+templ historyList(v logView) {
+
+
Aiemmin
+ if len(v.History.Rows) == 0 {
+
Ei vielä merkintöjä.
+ }
+ for i, row := range v.History.Rows {
+ if !row.Date.Equal(v.Date) {
+ if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
+
@@ -198,23 +345,50 @@ templ dayButton(label string, target, selected, now time.Time) {
}
}
+// The form still works on its own: submitting reloads the page with ?haku=.
+// Datastar binds the same box to a signal and re-renders just the list as it
+// is typed into, so the live version is an enhancement rather than a
+// requirement.
templ board(v logView) {
-
- if len(v.Dishes) > 0 {
-
- for _, d := range v.Dishes {
- @dishPill(d, v)
+
+}
+
+// boardList is what Datastar patches: it carries the id, so a plain text/html
+// response is matched to it and swapped in place.
+templ boardList(v logView) {
+
+ // Grouped by category, and inside each group the most-eaten first — so
+ // a dish keeps a predictable neighbourhood while favourites still
+ // surface at the top of it.
+ for _, g := range v.Groups {
+
{ g.Label }
+
+ for _, d := range g.Dishes {
+ @dishPill(d, v)
+ }
+
}
// quickAddCard turns a search that found nothing into the thing to do next.
@@ -225,7 +399,7 @@ templ quickAddCard(v logView) {
if v.Search == "" {
Lisää ensimmäinen ruoka
- Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruoat-välilehdeltä.
+ Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä.
+
+ // The add and edit forms stay outside the patched fragment, or
+ // typing in the search box would collapse a form mid-edit.
+ @mainForm_(v.Main)
+ @sideForm_(v.Side)
+ @catalogList(v)
+
- Muokkaa
-
+// catalogList carries the id Datastar patches, so typing in the search box
+// swaps the lists without touching the forms above them.
+//
+// Two levels of heading, because there are two: Pääruuat and Lisukkeet are
+// the halves of the catalog, and the categories are subdivisions of the
+// first. They were previously styled the same, which made a category look
+// like a peer of the entire side-dish list.
+templ catalogList(v catalogView) {
+
+
+ @sectionTitle("Pääruuat", v.Mains)
+ if v.Mains == 0 {
+ @emptyNote(v.Search)
+ }
+ // Grouped by category, alphabetical inside. The catalog is a list
+ // you manage, so a predictable position beats a useful one.
+ for _, g := range v.Groups {
+
+ if search == "" {
+ Ei vielä mitään.
+ } else {
+ Ei osumia haulle { search }.
+ }
+
+}
+
+// rowActions is a pencil and a bin, until the bin is tapped: then the row
+// asks. An icon is a smaller target to hit by accident than a word, and the
+// dish disappears from every picker the moment it goes.
+templ rowActions(v catalogView, editURL string, id int64, kind string) {
+ if v.DeleteID == id && v.DeleteKind == kind {
+
+ }
+}
+
+templ iconPencil() {
+
+}
+
+templ iconTrash() {
+
+}
+
+// Collapsed by default so the page opens on the catalog rather than on two
+// screens of empty form. Forced open when editing or after a rejected
+// submission, since the form is then the thing that needs attention.
templ mainForm_(f mainForm) {
-
-
+
+
if f.ID == 0 {
Lisää pääruoka
} else {
Muokkaa pääruokaa
}
-