Release: one log page, grouped dishes, live search (#1)

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 <[email protected]>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-09-05 19:28:54 +00:00
co-authored by Esa Kataja
parent eb95bd0a03
commit e9754488db
13 changed files with 1194 additions and 264 deletions
+8
View File
@@ -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"; \
+5 -2
View File
@@ -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
+48 -12
View File
@@ -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
# ... 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
+1 -1
View File
@@ -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)
}
+169 -20
View File
@@ -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,16 +55,27 @@ 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
@@ -53,9 +83,18 @@ type logView struct {
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
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.
@@ -154,11 +286,18 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
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},
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))
+28 -10
View File
@@ -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
+75
View File
@@ -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 {
+97 -22
View File
@@ -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);
+91 -26
View File
@@ -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
}
if err != nil {
return nil, err
}
firstDate, err := time.ParseInLocation(dateLayout, first, loc)
if err != nil {
return nil, err
// 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
}
now := today(loc)
oldest := now.AddDate(0, 0, -days)
if firstDate.After(oldest) {
// 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 !first.Valid || first.String == "" {
return HistoryPage{}, nil
}
firstDate, err := time.ParseInLocation(dateLayout, first.String, loc)
if err != nil {
return HistoryPage{}, err
}
if from.Before(firstDate) {
return HistoryPage{}, nil
}
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
}
+55 -4
View File
@@ -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")
}
}
+351 -115
View File
@@ -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) {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="color-scheme" content="light dark"/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ECEDE8"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121316"/>
// Matches --header in app.css so the browser chrome continues the
// header rather than butting against it.
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ title }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
@@ -109,9 +132,6 @@ templ brandbar() {
</header>
}
// 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() {
</svg>
}
// 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":
<span class="cat c-liha">
@glyphLiha()
</span>
case "kana":
<span class="cat c-kana">
@glyphKana()
</span>
case "kala":
<span class="cat c-kala">
@glyphKala()
</span>
case "kasvis":
<span class="cat c-kasvis">
@glyphKasvis()
</span>
default:
<span class="cat">
@glyphSekalaiset()
</span>
}
}
// A steak, its bone knocked out with fill-rule so the hole is transparent on
// whatever background the icon lands on.
templ glyphLiha() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fill-rule="evenodd"
d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"
></path>
</svg>
}
// A drumstick. The bone is what keeps it apart from the steak at small sizes,
// where both are otherwise warm blobs.
templ glyphKana() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" d="M7.8 8.2l3.1-3.1"></path>
<circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"></circle>
<circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"></circle>
<circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"></circle>
</svg>
}
templ glyphKala() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fill-rule="evenodd"
d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"
></path>
</svg>
}
// 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() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"
></path>
</svg>
}
// Quartered, one wedge per category: the mark already means "all of them".
templ glyphSekalaiset() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"></path>
<path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"></path>
<path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"></path>
<path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"></path>
</svg>
}
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)
@tab("/historia", "Historia", current)
@tab("/ruoat", "Ruoat", current)
@tab("/ruuat", "Ruuat", current)
</nav>
}
@@ -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)
</main>
}
}
// 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) {
<section class="history">
<h3 class="sechead">Aiemmin</h3>
if len(v.History.Rows) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p>
}
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() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<a class="entry" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
@categoryIcon(row.Entry.Main.CategoryKey())
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</div>
<span class="chev"></span>
</a>
} else {
<a class="gapline" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<span class="act">Merkitse</span>
</a>
}
}
}
if v.History.More {
<a
class="more"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) }
>Näytä lisää</a>
}
</section>
}
templ daySwitch(v logView) {
<div class="dayseg">
@dayButton("Tänään", v.Today, v.Date, v.Today)
@dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
<form method="get" action="/" class="daypick">
<input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/>
// max stops the picker offering days that have not happened yet;
// the server clamps anyway, this just avoids the dead end.
<input type="date" name="pvm" value={ isoDate(v.Date) } max={ isoDate(v.Today) } aria-label="Muu päivä"/>
<button type="submit">Näytä</button>
</form>
</div>
@@ -198,16 +345,42 @@ 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) {
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
}
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
<input
class="filter"
type="search"
name="haku"
value={ v.Search }
placeholder="Etsi tai lisää uusi"
aria-label="Etsi"
data-bind:haku
data-on:input__debounce.250ms={ "@get('" + searchURL(v) + "')" }
/>
</form>
if len(v.Dishes) > 0 {
@boardList(v)
</div>
}
// 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) {
<div id="lauta">
// 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 {
<h3 class="sechead">{ g.Label }</h3>
<div class="board">
for _, d := range v.Dishes {
for _, d := range g.Dishes {
@dishPill(d, v)
}
</div>
@@ -215,6 +388,7 @@ templ board(v logView) {
if len(v.Dishes) == 0 {
@quickAddCard(v)
}
</div>
}
// quickAddCard turns a search that found nothing into the thing to do next.
@@ -225,7 +399,7 @@ templ quickAddCard(v logView) {
if v.Search == "" {
<h3>Lisää ensimmäinen ruoka</h3>
<p class="muted small">
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ä.
</p>
} else {
<h3>Ei osumia. Lisätäänkö?</h3>
@@ -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)) }
>
<i class={ "dot", "d-" + d.CategoryKey() }></i>
@categoryIcon(d.CategoryKey())
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
@@ -277,7 +451,7 @@ templ dishPill(d Dish, v logView) {
templ sidesStep(v logView) {
<section class="card">
<h3>
<i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i>
@categoryIcon(v.Chosen.CategoryKey())
{ v.Chosen.Name }
</h3>
<form method="post" action="/kirjaa">
@@ -316,105 +490,66 @@ templ loggedCard(v logView) {
}
</p>
<p class="nm">
<i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i>
@categoryIcon(v.Entry.Main.CategoryKey())
{ v.Entry.Main.Name }
</p>
<p class="sd">{ v.Entry.SidesLabel() }</p>
if v.Confirming {
<p class="q">Poistetaanko merkintä?</p>
<div class="pair">
<form method="post" action="/poista">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button>
</form>
<a class="btn" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
</div>
} else {
<div class="pair">
<a
class="btn"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "muuta=1") }
>Muokkaa</a>
<form method="post" action="/poista">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Poista</button>
</form>
<a
class="btn del"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "poista=1") }
>Poista</a>
</div>
}
</section>
}
// ---------------------------------------------------------------- Historia
templ historyPage(rows []HistoryRow) {
@page("Historia — Foodster", "/historia") {
<header class="appbar">
<h2>Historia</h2>
</header>
<main class="pad">
if len(rows) == 0 {
<p class="muted">Ei vielä merkintöjä.</p>
}
for i, row := range rows {
if i == 0 || rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<div class="entry">
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</div>
<a class="chev" href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) } aria-label="Muokkaa"></a>
</div>
} else {
<div class="gapline">
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<a href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) }>Merkitse</a>
</div>
}
}
</main>
}
}
// ---------------------------------------------------------------- Ruoat
// ---------------------------------------------------------------- Ruuat
templ catalogPage(v catalogView) {
@page("Ruoat — Foodster", "/ruoat") {
@page("Ruuat — Foodster", "/ruuat") {
<header class="appbar">
<h2>Ruoat</h2>
<h2>Ruuat</h2>
<p class="meta">
{ 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") }
</p>
</header>
<main class="pad">
if v.Report != nil {
@importReport(v.Report)
}
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/ruuat" class="searchrow">
<input
class="filter"
type="search"
name="haku"
value={ v.Search }
placeholder="Etsi ruokaa"
aria-label="Etsi"
data-bind:haku
data-on:input__debounce.250ms="@get('/ruuat/etsi')"
/>
</form>
// 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)
<h3 class="sechead">Pääruoat</h3>
if len(v.Mains) == 0 {
<p class="muted small">Ei vielä pääruokia.</p>
}
for _, d := range v.Mains {
<div class="row">
<i class={ "dot", "d-" + d.CategoryKey() }></i>
<div class="rowtext">
<div class="nm">{ d.Name }</div>
<div class="sd">
{ categoryLabels(d) }
if !d.HasSides {
· ei lisukkeita
}
</div>
</div>
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
}
@sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3>
if len(v.Sides) == 0 {
<p class="muted small">Ei vielä lisukkeita.</p>
}
for _, s := range v.Sides {
<div class="row">
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
@rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
@catalogList(v)
</div>
}
<details class="card">
<summary>Tuo ruokia tiedostosta</summary>
@importForm()
@@ -423,30 +558,131 @@ templ catalogPage(v catalogView) {
}
}
templ rowActions(editURL string, id int64, kind string) {
<div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
<form method="post" action="/ruoat/poista">
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del" aria-label="Poista">Poista</button>
</form>
// 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) {
<div id="ruokalista">
<section class="section">
@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 {
<h3 class="sechead">{ g.Label }</h3>
for _, d := range g.Dishes {
<div class="row">
@categoryIcon(d.CategoryKey())
<div class="rowtext">
<div class="nm">{ d.Name }</div>
if !d.HasSides {
<div class="sd">Ei lisukkeita</div>
}
</div>
@rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
}
}
</section>
<section class="section">
@sectionTitle("Lisukkeet", len(v.Sides))
if len(v.Sides) == 0 {
@emptyNote(v.Search)
}
for _, s := range v.Sides {
<div class="row">
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div>
}
</section>
</div>
}
templ sectionTitle(label string, n int) {
<h2 class="sectiontitle">
{ label }
<span class="count">{ strconv.Itoa(n) }</span>
</h2>
}
templ emptyNote(search string) {
<p class="muted small">
if search == "" {
Ei vielä mitään.
} else {
Ei osumia haulle { search }.
}
</p>
}
// 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 {
<div class="rowactions confirming">
<span>Poista?</span>
<form method="post" action="/ruuat/poista">
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del">Kyllä</button>
</form>
<a href="/ruuat">Peruuta</a>
</div>
} else {
<div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa" title="Muokkaa">
@iconPencil()
</a>
<a
class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
aria-label="Poista"
title="Poista"
>
@iconTrash()
</a>
</div>
}
}
templ iconPencil() {
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M11.1 1.6a1.7 1.7 0 0 1 2.4 0l0.9 0.9a1.7 1.7 0 0 1 0 2.4l-0.8 0.8-3.3-3.3z"></path>
<path d="M9.4 3.3l3.3 3.3-6.6 6.6-4 0.7 0.7-4z"></path>
</svg>
}
templ iconTrash() {
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M6.2 1.3h3.6a1 1 0 0 1 1 1v0.6h3.1v1.7H2.1V2.9h3.1v-.6a1 1 0 0 1 1-1z"></path>
<path d="M3.3 6.2h9.4l-0.7 7.3a1.5 1.5 0 0 1-1.5 1.3H5.5a1.5 1.5 0 0 1-1.5-1.3z"></path>
</svg>
}
// 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) {
<section class="card" id="paaruoka">
<h3>
<details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }>
<summary>
if f.ID == 0 {
Lisää pääruoka
} else {
Muokkaa pääruokaa
}
</h3>
</summary>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/paaruoka">
<form method="post" action="/ruuat/paaruoka">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
@@ -474,9 +710,9 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a>
<a class="ghost" href="/ruuat">Peruuta</a>
}
</section>
</details>
}
templ categoryChip(value, label string, f mainForm) {
@@ -486,24 +722,24 @@ templ categoryChip(value, label string, f mainForm) {
} else {
<input type="checkbox" name="kategoria" value={ value }/>
}
<i class={ "dot", "d-" + categoryFI[value] }></i>
@categoryIcon(categoryFI[value])
<span>{ label }</span>
</label>
}
templ sideForm_(f sideForm) {
<section class="card" id="lisuke">
<h3>
<details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }>
<summary>
if f.ID == 0 {
Lisää lisuke
} else {
Muokkaa lisuketta
}
</h3>
</summary>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/lisuke">
<form method="post" action="/ruuat/lisuke">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
@@ -514,9 +750,9 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a>
<a class="ghost" href="/ruuat">Peruuta</a>
}
</section>
</details>
}
templ importForm() {
@@ -525,7 +761,7 @@ templ importForm() {
<p class="muted small">
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
</p>
<form method="post" action="/ruoat/tuonti" enctype="multipart/form-data">
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
<label class="field">
<span>JSON</span>
<textarea
+145
View File
@@ -0,0 +1,145 @@
<!doctype html>
<html lang="fi" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foodster — kategoriakuvakkeet</title>
<style>
:root{
color-scheme: light dark;
--paper: light-dark(#ECEDE8, #121316);
--card: light-dark(#FFFFFF, #1C1E22);
--sunk: light-dark(#E3E4DE, #17181B);
--ink: light-dark(#14161A, #E9EAE5);
--muted: light-dark(#6B6F6A, #8B8F89);
--line: light-dark(#D5D6D0, #2C2F34);
--liha: light-dark(#AF4230, #DE7561);
--kana: light-dark(#B57E10, #DFA83B);
--kala: light-dark(#25688F, #63AAD8);
--kasvis:light-dark(#457A3C, #82BE7A);
}
html[data-theme="light"]{color-scheme:only light;}
html[data-theme="dark"] {color-scheme:only dark;}
*{box-sizing:border-box;}
body{margin:0;background:var(--paper);color:var(--ink);
font-family:system-ui,sans-serif;font-size:16px;line-height:1.45;}
.wrap{max-width:900px;margin:0 auto;padding:22px 20px 60px;}
h1{font-size:19px;letter-spacing:-.02em;margin:0 0 4px;}
.lede{margin:0 0 18px;color:var(--muted);font-size:14px;}
button.t{font:inherit;font-size:13px;background:var(--card);border:1px solid var(--line);
color:var(--muted);padding:7px 12px;border-radius:7px;cursor:pointer;margin-bottom:20px;}
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:16px 18px;margin-bottom:14px;}
.panel h2{font-size:14px;margin:0 0 12px;letter-spacing:-.01em;}
table{border-collapse:collapse;width:100%;}
th{font-size:10px;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);
text-align:left;font-weight:600;padding:0 10px 8px 0;}
td{padding:7px 10px 7px 0;vertical-align:middle;}
td.name{font-size:13px;color:var(--muted);}
.i-liha { color: var(--liha); }
.i-kana { color: var(--kana); }
.i-kala { color: var(--kala); }
.i-kasvis{ color: var(--kasvis); }
svg{display:block;}
/* in context */
.board{display:flex;flex-wrap:wrap;gap:8px;}
.pill{display:flex;align-items:center;gap:9px;background:var(--card);
border:1px solid var(--line);border-radius:11px;color:var(--ink);
font-weight:600;letter-spacing:-.022em;padding:11px 15px;font-size:16px;}
.pill.big{font-size:21px;padding:14px 18px;}
.n{font-family:ui-monospace,monospace;font-weight:400;font-size:10px;color:var(--muted);}
.row{display:flex;align-items:center;gap:11px;padding:11px 0;
border-bottom:1px solid var(--line);}
.row .nm{font-size:16px;font-weight:600;}
.dotrow{display:flex;align-items:center;gap:11px;padding:11px 0;
border-bottom:1px solid var(--line);}
.dot{width:9px;height:9px;border-radius:2px;flex:none;}
.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%);}
</style>
</head>
<body>
<div class="wrap">
<h1>Kategoriakuvakkeet</h1>
<p class="lede">Colour and shape together. The 14&nbsp;px column and the pill row are the sizes that actually ship.</p>
<button class="t" id="t">Teema: tumma</button>
<div class="panel">
<h2>Every size</h2>
<table>
<tr><th>Kategoria</th><th>12</th><th>14</th><th>18</th><th>26</th></tr>
<tr>
<td class="name">liha</td>
<td class="i-liha"><svg width="12" height="12" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
<td class="i-liha"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
<td class="i-liha"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
<td class="i-liha"><svg width="26" height="26" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
</tr>
<tr>
<td class="name">kana</td>
<td class="i-kana"><svg width="12" height="12" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
<td class="i-kana"><svg width="14" height="14" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
<td class="i-kana"><svg width="18" height="18" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
<td class="i-kana"><svg width="26" height="26" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
</tr>
<tr>
<td class="name">kala</td>
<td class="i-kala"><svg width="12" height="12" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
<td class="i-kala"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
<td class="i-kala"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
<td class="i-kala"><svg width="26" height="26" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
</tr>
<tr>
<td class="name">kasvis</td>
<td class="i-kasvis"><svg width="12" height="12" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
<td class="i-kasvis"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
<td class="i-kasvis"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
<td class="i-kasvis"><svg width="26" height="26" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
</tr>
<tr>
<td class="name">sekalaiset</td>
<td><svg width="12" height="12" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
<td><svg width="14" height="14" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
<td><svg width="18" height="18" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
<td><svg width="26" height="26" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
</tr>
</table>
</div>
<div class="panel">
<h2>On the board, at 16&nbsp;px</h2>
<div class="board">
<span class="pill big"><span class="i-liha"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></span>Lihapullat <span class="n">12</span></span>
<span class="pill"><span class="i-kana"><svg width="16" height="16" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></span>Kanacurry <span class="n">8</span></span>
<span class="pill"><span class="i-kala"><svg width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></span>Lohikeitto <span class="n">9</span></span>
<span class="pill"><span class="i-kasvis"><svg width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></span>Hernekeitto <span class="n">4</span></span>
<span class="pill"><svg width="16" height="16" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg>Tortillat <span class="n">4</span></span>
</div>
</div>
<div class="panel">
<h2>What it replaces</h2>
<div class="dotrow"><i class="dot d-liha"></i><span>Lihapullat</span></div>
<div class="dotrow"><i class="dot d-kana"></i><span>Kanacurry</span></div>
<div class="dotrow"><i class="dot d-kala"></i><span>Lohikeitto</span></div>
<div class="dotrow"><i class="dot d-kasvis"></i><span>Hernekeitto</span></div>
<div class="dotrow"><i class="dot d-sek"></i><span>Tortillat</span></div>
</div>
</div>
<script>
const h=document.documentElement,b=document.getElementById('t'),m=['dark','light'];
b.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%2];h.dataset.theme=n;b.textContent='Teema: '+(n==='dark'?'tumma':'vaalea');};
</script>
</body>
</html>
+89 -20
View File
@@ -43,6 +43,16 @@ check() {
fi
}
# refute <name> <haystack> <needle>
refute() {
if printf '%s' "$2" | grep -qF -- "$3"; then
echo " FAIL $1 (should not contain: $3)"
fail=1
else
echo " ok $1"
fi
}
echo "smoke: http://$addr"
check "unauthenticated request is refused" \
@@ -75,29 +85,29 @@ check "manifest has the right content type" \
"application/manifest+json"
check "catalog starts empty" \
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "0 pääruokaa"
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa"
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
check "re-import refuses duplicates" \
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")" \
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
"jo listalla"
check "pasted JSON imports" \
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
"http://$addr/ruoat/tuonti")" "Lisätty 1"
"http://$addr/ruuat/tuonti")" "Lisätty 1"
check "unknown category is reported" \
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
"http://$addr/ruoat/tuonti")" "tuntematon kategoria"
"http://$addr/ruuat/tuonti")" "tuntematon kategoria"
check "empty submit is explained" \
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruoat/tuonti")" "Ei tuotavaa"
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
check "malformed JSON is explained" \
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa"
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
# ---- the log flow, against the dishes imported above --------------------
@@ -122,8 +132,13 @@ check "saving redirects back to the day" \
check "the saved day shows what was eaten" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
check "history lists the entry" \
"$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu"
check "history is on the same page as the logger" \
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
# Deleting a logged meal drops the row outright, so it asks first.
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
refute "and does not delete while asking" "$saved" "Ei merkintää"
check "deleting redirects back" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
@@ -135,6 +150,43 @@ check "the day is empty again" \
check "search filters the board" \
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
# ---- live search: Datastar sends signals as JSON in ?datastar= -----------
live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")
check "live search returns the board fragment" "$live" 'id="lauta"'
check "live search applies the term" "$live" "keitto"
refute "live search excludes non-matches" "$live" "Lihapullat"
refute "the fragment is not a whole page" "$live" "<html"
check "live search is served as html for Datastar to patch" \
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" \
--get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \
"text/html"
cat_live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi")
check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"'
check "catalog live search matches sides too" "$cat_live" "Riisi"
refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat"
# The plain form still works without JavaScript.
check "catalog search works as a plain form too" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?haku=riisi")" "Riisi"
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
future=$(date -d '+30 days' +%Y-%m-%d)
check "a future date falls back to today" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
check "saving a future date is clamped too" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/"
check "tomorrow was not written to the log" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
# Clean up the entry that clamped onto today.
curl -s -o /dev/null -u ":$pass" -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista"
# ---- adding a dish without leaving Kirjaa --------------------------------
miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste")
@@ -157,37 +209,54 @@ check "the quick-added dish is on the board" \
check "adding a main redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruoat/paaruoka")" "303"
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303"
catalog=$(curl -s -u ":$pass" "http://$addr/ruoat")
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
check "a duplicate name is refused" \
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Nimi on jo listalla."
check "a main with no category is refused" \
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruoat/paaruoka")" \
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
"Valitse vähintään yksi kategoria."
check "a nameless dish is refused" \
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Anna nimi."
check "adding a side redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d 'nimi=lohkoperunat' "http://$addr/ruoat/lisuke")" "303"
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303"
check "the new side is listed" \
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "Lohkoperunat"
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
uusi=$(printf '%s' "$catalog" | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so
# the first id on the page belongs to some other dish entirely.
uusi=$(printf '%s' "$catalog" | grep -o 'Uunikala.*' | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$uusi" ]; then
echo " FAIL could not find Uunikala's id in the catalog"
fail=1
uusi=0
fi
check "the edit form is prefilled" \
"$(curl -s -u ":$pass" "http://$addr/ruoat?muokkaa=$uusi")" "Muokkaa pääruokaa"
"$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
check "deleting a main redirects" \
# A bin icon is easy to hit by accident, so the row asks before anything goes.
check "the bin asks before deleting" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
check "the dish is still there while it asks" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
check "confirming the delete redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruoat/poista")" "303"
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303"
refute "the dish is gone once confirmed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
if [ "$fail" -ne 0 ]; then
echo "smoke: FAILED"