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() { +

{ monthFI(row.Date) }

+ } + if row.Entry != nil { + + +
+
+ @categoryIcon(row.Entry.Main.CategoryKey()) + { row.Entry.Main.Name } +
+
{ row.Entry.SidesLabel() }
+
+ +
+ } else { + + + Ei merkintää + Merkitse + + } + } + } + if v.History.More { + @dayButton("Tänään", v.Today, v.Date, v.Today) @dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
- + // max stops the picker offering days that have not happened yet; + // the server clamps anyway, this just avoids the dead end. +
@@ -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 !v.Date.Equal(v.Today) { - - } - -
- if len(v.Dishes) > 0 { -
- for _, d := range v.Dishes { - @dishPill(d, v) +
+
+ if !v.Date.Equal(v.Today) { + } -
- } - if len(v.Dishes) == 0 { - @quickAddCard(v) - } + + + @boardList(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) + } +
+ } + if len(v.Dishes) == 0 { + @quickAddCard(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ä.

} else {

Ei osumia. Lisätäänkö?

@@ -266,7 +440,7 @@ templ dishPill(d Dish, v logView) { class={ "pill", d.Size() } href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) } > - + @categoryIcon(d.CategoryKey()) { d.Name } if d.TimesEaten > 0 { { strconv.Itoa(d.TimesEaten) } @@ -277,7 +451,7 @@ templ dishPill(d Dish, v logView) { templ sidesStep(v logView) {

- + @categoryIcon(v.Chosen.CategoryKey()) { v.Chosen.Name }

@@ -316,105 +490,66 @@ templ loggedCard(v logView) { }

- + @categoryIcon(v.Entry.Main.CategoryKey()) { v.Entry.Main.Name }

{ v.Entry.SidesLabel() }

-
+ if v.Confirming { +

Poistetaanko merkintä?

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

Historia

-
-
- if len(rows) == 0 { -

Ei vielä merkintöjä.

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

{ monthFI(row.Date) }

- } - if row.Entry != nil { -
- -
-
- - { row.Entry.Main.Name } -
-
{ row.Entry.SidesLabel() }
-
- -
- } else { -
- - Ei merkintää - -

Ruoat

+

Ruuat

- { countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") } + { countFI(v.Mains, "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }

if v.Report != nil { @importReport(v.Report) } - @mainForm_(v.Main) -

Pääruoat

- if len(v.Mains) == 0 { -

Ei vielä pääruokia.

- } - for _, d := range v.Mains { -
- -
-
{ d.Name }
-
- { categoryLabels(d) } - if !d.HasSides { - · ei lisukkeita - } -
-
- @rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa") -
- } - @sideForm_(v.Side) -

Lisukkeet

- if len(v.Sides) == 0 { -

Ei vielä lisukkeita.

- } - for _, s := range v.Sides { -
-
{ s.Name }
- @rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") -
- } +
+
+ +
+ // 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) +
Tuo ruokia tiedostosta @importForm() @@ -423,30 +558,131 @@ templ catalogPage(v catalogView) { } } -templ rowActions(editURL string, id int64, kind string) { -
- 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 { +

{ g.Label }

+ for _, d := range g.Dishes { +
+ @categoryIcon(d.CategoryKey()) +
+
{ d.Name }
+ if !d.HasSides { +
Ei lisukkeita
+ } +
+ @rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa") +
+ } + } +
+
+ @sectionTitle("Lisukkeet", len(v.Sides)) + if len(v.Sides) == 0 { + @emptyNote(v.Search) + } + for _, s := range v.Sides { +
+
{ s.Name }
+ @rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") +
+ } +
} +templ sectionTitle(label string, n int) { +

+ { label } + { strconv.Itoa(n) } +

+} + +templ emptyNote(search string) { +

+ 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 { +
+ Poista? +
+ + + +
+ Peruuta +
+ } else { + + } +} + +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 } -

+ if f.Err != "" {

{ f.Err }

} -
+ if f.ID != 0 { } @@ -474,9 +710,9 @@ templ mainForm_(f mainForm) {
if f.ID != 0 { - Peruuta + Peruuta } -
+
} templ categoryChip(value, label string, f mainForm) { @@ -486,24 +722,24 @@ templ categoryChip(value, label string, f mainForm) { } else { } - + @categoryIcon(categoryFI[value]) { label } } templ sideForm_(f sideForm) { -
-

+
+ if f.ID == 0 { Lisää lisuke } else { Muokkaa lisuketta } -

+ if f.Err != "" {

{ f.Err }

} -
+ if f.ID != 0 { } @@ -514,9 +750,9 @@ templ sideForm_(f sideForm) {
if f.ID != 0 { - Peruuta + Peruuta } -
+ } templ importForm() { @@ -525,7 +761,7 @@ templ importForm() {

Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.

-
+