1 Commits
Author SHA1 Message Date
Esa Kataja 813e82ef5c Say which path and user cannot open the database
sql.Open is lazy, so a permission problem surfaced from whichever query ran
first: "create schema_migrations: unable to open database file (14)", which
names neither the file nor the reason. Ping on open and report the path and
the effective uid and gid instead.

The cause in practice is a bind-mounted ./data that Docker created as root
while the container runs as FOODSTER_UID. Documented in the README.
2026-09-05 20:23:44 +03:00
19 changed files with 380 additions and 2003 deletions
+7 -14
View File
@@ -1,29 +1,22 @@
# Copy to .env and fill in. .env is gitignored — the real registry hostname # Copy to .env and fill in. .env is gitignored — the real registry hostname
# must not end up in the repository. # must not end up in the repository.
# Image coordinates. REPO carries no tag. # Image coordinates. FOODSTER_REPO carries no tag.
REPO=registry.example.com/you/foodster FOODSTER_REPO=registry.example.com/you/foodster
TAG=latest FOODSTER_TAG=latest
# Shared household password. The app will not start without it. # Shared household password. The app will not start without it.
PASSWORD=changeme FOODSTER_PASSWORD=changeme
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no # Hostname Traefik routes to. Kept here rather than in compose.yaml so no
# infrastructure detail is committed. # infrastructure detail is committed.
HOST=foodster.example.com FOODSTER_HOST=foodster.example.com
# Anything other than prod is written into the browser tab title, so a dev
# instance open beside the real one can be told apart.
ENV=prod
# The database lives in ./data, bind-mounted into the container. These must # The database lives in ./data, bind-mounted into the container. These must
# match whoever owns that directory on the host, or the container cannot # match whoever owns that directory on the host, or the container cannot
# write to it. `id -u` and `id -g` will tell you. # write to it. `id -u` and `id -g` will tell you.
# FOODSTER_UID=1000
# Named PUID/PGID because UID is read-only in bash and a plain UID here would FOODSTER_GID=1000
# be quietly replaced by the invoking shell's own.
PUID=1000
PGID=1000
# Used for every calendar-day calculation. Set it in development too: under # Used for every calendar-day calculation. Set it in development too: under
# UTC the date rolls over three hours late, which is exactly when dinner # UTC the date rolls over three hours late, which is exactly when dinner
-3
View File
@@ -4,9 +4,6 @@
# Build output # Build output
/foodster /foodster
# The tag `make image` last built, handed to `make push`.
/.release-tag
# Generated by `templ generate` during the container build. # Generated by `templ generate` during the container build.
*_templ.go *_templ.go
-94
View File
@@ -1,94 +0,0 @@
# Contributing
A household project, so this is less a set of rules than a note to whoever
picks it up next — including me in six months.
## Getting set up
```sh
cp .env.example .env # then edit it
make run # http://localhost:8080, tab titled "dev · Foodster"
make # every target, with a one-line description
```
`make check` is the gate: `go vet`, gofmt, unit tests, and `scripts/smoke.sh`,
which drives a real server over HTTP. Run it before every commit.
## Branches
`dev` is where work happens. `main` holds released versions only — it is
protected on the remote and takes no direct pushes, so a release arrives as a
pull request from `dev`, squash-merged.
After a squash merge, reset `dev` onto it or the next pull request will offer
the same commits again:
```sh
git switch main && git pull --ff-only
git switch dev && git reset --hard main
git push --force-with-lease origin dev
```
`make image` refuses to run outside `main`. That check has to be local: the
tag and the image are made before anything reaches the remote, so branch
protection cannot catch a release built from the wrong branch.
## Commit messages
Conventional Commits — a type, an optional scope, then a short subject in the
imperative.
```
feat(kirjaa): expand the selected day in place
fix: redirect the catalog to /ruuat, not /ruoat
chore(deps): bump the vendored Datastar client
```
| Type | For |
|---|---|
| `feat` | new behaviour someone will notice |
| `fix` | a bug, ideally naming what broke |
| `refactor` | same behaviour, different shape |
| `test` | tests only |
| `docs` | documentation only |
| `build` | Makefile, Containerfile, compose, CI |
| `chore` | anything else: dependencies, seeds, tidying |
**The body matters more than the type.** Explain *why*, and what the
alternative was — the diff already says what changed. If a fix was subtle,
say what made it subtle; if a test caught something, say what. Commits here
are the only design record this project has.
### Release pull requests
Because `main` is squash-merged, a pull request title becomes a commit message
on `main`. A release spans a fix, a feature and some chores at once, so none
of the types above fits it honestly. Use `release:` instead:
```
release: repair the catalog 404 and stop the page jumping
```
`main`'s log is then one line per deployment, which is what that branch is
for, and the pull request body serves as the release notes. No version in the
title — the CalVer tag is not created until `make image` runs after the merge.
The types above are for `dev`, where a commit really does do one thing.
## Things that are easy to get wrong
- **The interface is Finnish.** Code, comments, this file and the PRD are
English. There is no i18n layer and no language switcher.
- **No infrastructure detail is committed** — no hostnames, registry paths or
ports. They live in `.env`, which is gitignored, because the PRD leaves the
door open to publishing this repository.
- **Migrations are immutable once shipped.** A released migration has run on a
live database and will not run again. Add a new numbered file instead.
- **Interactions patch, they do not navigate.** Anything that reloads the page
loses the scroll position, which on a long list is maddening. Links stay
links and forms stay forms so it works without JavaScript; Datastar layers
over them with `data-on:click__prevent` and `data-on:submit__prevent`.
- **A `ponytail:` comment marks a deliberate shortcut** and names its ceiling,
so the next reader can tell a decision from an oversight.
- **Assert what a response does, not just that it responded.** A redirect to a
dead URL is still a 303; that one shipped.
+10 -48
View File
@@ -5,11 +5,6 @@ BIN := foodster
PKG := ./cmd/foodster PKG := ./cmd/foodster
STATIC := cmd/foodster/static STATIC := cmd/foodster/static
# What `make image` last built. push reads it rather than re-deriving the tag:
# sorting tags by date is ambiguous when two point at the same commit, and
# re-deriving is what let a parallel make push the wrong one.
TAGFILE := .release-tag
# Vendored Datastar client. Bump, run `make vendor`, commit the result. # Vendored Datastar client. Bump, run `make vendor`, commit the result.
DATASTAR_VERSION ?= v1.0.3 DATASTAR_VERSION ?= v1.0.3
SEED ?= seeds/testi.json SEED ?= seeds/testi.json
@@ -38,7 +33,7 @@ build: generate ## Build ./foodster
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG) -ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
run: generate ## Run locally on :8080 (database in ./data) run: generate ## Run locally on :8080 (database in ./data)
PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG) FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG)
seed: ## Import a dish bundle (SEED=seeds/testi.json) seed: ## Import a dish bundle (SEED=seeds/testi.json)
go run $(PKG) -import $(SEED) go run $(PKG) -import $(SEED)
@@ -85,55 +80,22 @@ fix: ## Format Go and templ sources, tidy go.mod
go mod tidy go mod tidy
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; } @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); \ @day=$$(date +%Y%m%d); \
tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \ tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \
echo "==> $$tag"; \ echo "==> $$tag"; \
git tag "$$tag"; \ git tag "$$tag"; \
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \ podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
-t "$(REPO):$$tag" -t "$(REPO):latest" . ; \ -t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" .
echo "$$tag" > $(TAGFILE)
# Pushing reported success while uploading the previous release once, because push: ## Push the newest tag and :latest
# nothing compared what was built against what arrived. So afterwards, ask the @test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
# registry what it actually serves for each tag and fail if it is not the @tag=$$(git tag -l 'v*' --sort=-creatordate | head -n1); \
# image we just built. test -n "$$tag" || { echo "no tags yet - run make image"; exit 1; }; \
push: ## Push the newest tag and :latest, then verify the registry podman push "$(FOODSTER_REPO):$$tag"; \
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; } podman push "$(FOODSTER_REPO):latest"
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
tag=$$(cat $(TAGFILE)); \
built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \
podman push "$(REPO):$$tag"; \
podman push "$(REPO):latest"; \
echo "==> verifying $$tag"; \
for ref in "$$tag" latest; do \
podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \
served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \
if [ "$$served" != "$$built" ]; then \
echo " FAIL $$ref serves $$served"; \
echo " expected $$built"; \
exit 1; \
fi; \
echo " ok $$ref"; \
done
# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on release: image push ## Build, tag and push in one go
# at least one machine here — these run concurrently, so push resolves the
# newest tag and uploads :latest before image has finished building and
# tagging. That silently ships the previous release a second time.
release: ## Build, tag and push in one go
@$(MAKE) --no-print-directory image
@$(MAKE) --no-print-directory push
up: ## Start the stack up: ## Start the stack
@mkdir -p data # or the engine creates it root-owned and the app cannot write @mkdir -p data # or the engine creates it root-owned and the app cannot write
+11 -35
View File
@@ -113,23 +113,6 @@ in English.
Side dishes live in their own table and have no category. The pool is Side dishes live in their own table and have no category. The pool is
expected to stay small. expected to stay small.
### Tähteet — leftovers (stage 1)
A single built-in entry, flagged `special` on the main dish table. It is
**not food**: it exists so a day can be recorded as "we ate what was already
there" without inventing a meal that was never cooked.
- No category, which is why it cannot be an ordinary main: those must have
at least one.
- Created by a migration. The household does not add, edit or delete it, and
it never appears in the Ruuat catalog.
- Loggable exactly like any other entry, and shown on the log board apart
from the categories.
- **The stage 2 suggester must never propose it.** It is excluded from the
eligible pool outright, so cooldown, category coverage (§8.1) and
frequency weighting (§8.2) all skip it — despite it being among the
most-logged entries.
### Meal log entry ("what was actually eaten") (stage 1) ### Meal log entry ("what was actually eaten") (stage 1)
- `id` - `id`
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field - `date` — SQL `DATE`, day granularity only. There is no time-of-day field
@@ -338,7 +321,7 @@ build and no asset bundler.
lines of `database/sql`. There are no down-migrations: restoring the lines of `database/sql`. There are no down-migrations: restoring the
database file is the rollback for a single-household app. database file is the rollback for a single-household app.
- **Bundle import**: the §7.3 mass import is a live feature of the running - **Bundle import**: the §7.3 mass import is a live feature of the running
app, on the Ruuat tab — paste JSON or upload a file, get a per-row report app, on the Ruoat 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 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 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 are capped at 1 MiB. The same importer is also reachable as
@@ -351,7 +334,7 @@ build and no asset bundler.
is imported because the runtime image carries no zoneinfo. All date logic is imported because the runtime image carries no zoneinfo. All date logic
uses that location explicitly and never `time.Local`. uses that location explicitly and never `time.Local`.
- **Auth**: HTTP Basic with one shared household password read from - **Auth**: HTTP Basic with one shared household password read from
`PASSWORD`; the username is ignored. Compared using `FOODSTER_PASSWORD`; the username is ignored. Compared using
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor `subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
its length leaks through timing. `/healthz` is the only route outside auth. its length leaks through timing. `/healthz` is the only route outside auth.
- **Exposure**: the app is served on a public hostname behind Traefik, which - **Exposure**: the app is served on a public hostname behind Traefik, which
@@ -377,10 +360,7 @@ on the server and run with Docker Compose.
- **Branches**: `main` carries released versions only, so its history is the - **Branches**: `main` carries released versions only, so its history is the
deployment history and every release tag points into it. Development happens deployment history and every release tag points into it. Development happens
on `dev`, and `main` is protected on the remote: it accepts no direct on `dev` and merges into `main` when a release is cut.
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 - **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, 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 creates the new tag, and bakes the version into the binary through
@@ -395,25 +375,21 @@ on the server and run with Docker Compose.
`/data/foodster.db`, bind-mounted from `./data` on the host rather than `/data/foodster.db`, bind-mounted from `./data` on the host rather than
kept in a named volume, so the file can be listed and copied without going kept in a named volume, so the file can be listed and copied without going
through the container engine. Backup is `cp -r data`. Because the image through the container engine. Backup is `cp -r data`. Because the image
runs as UID 65534, compose sets `user:` from `PUID`/`PGID` runs as UID 65534, compose sets `user:` from `FOODSTER_UID`/`FOODSTER_GID`
to match whoever owns that directory. `restart: unless-stopped`. to match whoever owns that directory. `restart: unless-stopped`.
- **Configuration**, entirely through environment variables (see - **Configuration**, entirely through environment variables (see
`.env.example`): `.env.example`):
Names carry no application prefix: the container namespaces them already. - `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates.
- `REPO` and `TAG` — image coordinates. - `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to
- `PASSWORD` — the shared password. Required; the app refuses to start start without it.
without it. - `FOODSTER_DB` — database file path, default `./data/foodster.db`. The
- `DB` — database file path, default `./data/foodster.db`. The directory is directory is created on startup if missing.
created on startup if missing. - `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`.
- `ENV` — anything but `prod` is prefixed to the browser tab title, so a
dev instance open beside the real one can be told apart.
- `PUID` / `PGID` — host owner of `./data`. Not `UID`, which is read-only
in bash and would be replaced by the invoking shell's own value.
- `TZ` — default `Europe/Helsinki`. - `TZ` — default `Europe/Helsinki`.
- The registry hostname exists only in `.env`, which is gitignored, because - The registry hostname exists only in `.env`, which is gitignored, because
§11 leaves open the possibility of publishing this repository. §11 leaves open the possibility of publishing this repository.
- **Routing**: Traefik on an external `traefik` network, matching on - **Routing**: Traefik on an external `traefik` network, matching on
`HOST` and terminating TLS. The container publishes no ports — `FOODSTER_HOST` and terminating TLS. The container publishes no ports —
doing so would put an unencrypted copy of the app on the host, bypassing doing so would put an unencrypted copy of the app on the host, bypassing
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
infrastructure detail is committed. infrastructure detail is committed.
+21 -56
View File
@@ -17,29 +17,18 @@ weeks of real history to weight against.
Working: Working:
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered - **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized
and sized by how often they are eaten, so the likely answer is the biggest by how often they are eaten. Edit or delete the day's entry.
target. The history sits on the same page underneath: every day back to the - **Historia** — every day back to the first entry, with unlogged days shown
first entry, unlogged days shown as explicit gaps, and every row a link that as explicit gaps.
loads that day into the logger above it. - **Ruoat** — add, edit and delete mains and sides, or import a whole bundle
- **Ruuat** — 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
by paste or file upload. Grouped by category and alphabetical inside, since the dish they used.
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 - **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 the theme that is on — moon while dark, sun while light — not the one a
click would bring. click would bring.
Still to build: 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 - Stage 2: the seven-meal suggester, which starts once there is history to
weight against. weight against.
@@ -58,37 +47,19 @@ One static Go binary. No Node.js, no bundler, no separate database server.
| Auth | HTTP Basic, one shared household password | | Auth | HTTP Basic, one shared household password |
| Runtime image | `FROM scratch` | | Runtime image | `FROM scratch` |
Working on it: [CONTRIBUTING.md](CONTRIBUTING.md) — branches, commit messages,
and the conventions that are easy to miss.
## Branches ## Branches
`main` holds released versions only. Every release tag points at a commit on `main` holds released versions only. Every release tag points at a commit on
`main`, so its history is the deployment history. **Nothing is committed to `main`, so its history is the deployment history.
`main` directly** — it moves only by fast-forwarding `dev` into it.
All development happens on `dev`. `main` is protected on the remote: it takes All development happens on `dev`. Merge into `main` when cutting a release,
no direct pushes, so a release arrives through a pull request. then build and push the image from there.
```sh ```sh
git switch dev # where the work happens git switch dev # where the work happens
# ... commits ... git switch main && git merge dev && make release
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 ## Quick start
```sh ```sh
@@ -113,7 +84,7 @@ make up/down/logs compose
## Importing dishes ## Importing dishes
The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload The **Ruoat** 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. a file, and the app reports row by row what it did.
```json ```json
@@ -176,19 +147,13 @@ Everything is environment variables. `.env` is gitignored; start from
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `PASSWORD` | *required* | Shared password. The app will not start without it. | | `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. |
| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. | | `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
| `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). | | `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. |
| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. |
| `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. |
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. | | `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
| `FOODSTER_REPO` | *required to build* | Image repository, no tag. |
Names carry no prefix: the container gives them their own namespace already. | `FOODSTER_TAG` | `latest` | Tag to run under compose. |
`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set | `FOODSTER_PORT` | `8080` | Host port to publish. |
in `.env` would be silently replaced by the invoking shell's own.
| `REPO` | *required to build* | Image repository, no tag. |
| `TAG` | `latest` | Tag to run under compose. |
| `HOST` | *required to run* | Hostname Traefik routes to. |
Set `TZ` in development too. Under UTC the date rolls over three hours late, Set `TZ` in development too. Under UTC the date rolls over three hours late,
which is exactly when dinner gets logged. which is exactly when dinner gets logged.
@@ -213,7 +178,7 @@ the container, so a backup is `cp -r data` and you can inspect the file with
any sqlite client without going through the engine. any sqlite client without going through the engine.
That directory must exist and be owned by the user compose runs as — `make up` That directory must exist and be owned by the user compose runs as — `make up`
creates it, and `PUID`/`PGID` in `.env` tell the container who creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
that is. Get them from `id -u` and `id -g`. that is. Get them from `id -u` and `id -g`.
If the app exits with `cannot open /data/foodster.db ... unable to open If the app exits with `cannot open /data/foodster.db ... unable to open
@@ -222,7 +187,7 @@ bind-mount directory as root, and the container is not root:
```sh ```sh
ls -ldn data # whose is it? ls -ldn data # whose is it?
sudo chown -R 1000:1000 data # match PUID / PGID sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID
docker compose restart docker compose restart
``` ```
@@ -244,7 +209,7 @@ counting it would lock the household out for simply opening the app.
address, meaning it arrived through the proxy. A client connecting directly address, meaning it arrived through the proxy. A client connecting directly
could otherwise forge a new address per attempt and skip the limiter. could otherwise forge a new address per attempt and skip the limiter.
**None of this replaces a strong `PASSWORD`.** Rate limiting removes **None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes
brute force as a practical route; it does not make a guessable password safe. brute force as a practical route; it does not make a guessable password safe.
## Mockups ## Mockups
+1 -50
View File
@@ -131,55 +131,6 @@ func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) {
} }
} }
func TestTahteetIsLoggableButNotFood(t *testing.T) {
h := seeded(t)
// The migration creates it; nobody adds it.
special, err := listSpecial(h.db, "")
if err != nil {
t.Fatalf("listSpecial: %v", err)
}
if len(special) != 1 || special[0].Name != "Tähteet" {
t.Fatalf("special = %+v, want exactly Tähteet", special)
}
// It must not turn up among the dishes: not on the board's categories,
// not in the catalog, and not in whatever the suggester later draws from.
dishes, err := listDishes(h.db, "")
if err != nil {
t.Fatalf("listDishes: %v", err)
}
for _, d := range dishes {
if d.Name == "Tähteet" {
t.Fatal("Tähteet appears among the dishes")
}
}
// It carries no category at all, which is why it cannot be an ordinary
// dish: those are required to have one.
if len(special[0].Categories) != 0 {
t.Errorf("categories = %v, want none", special[0].Categories)
}
// And it gets its own mark: no categories is not the same as several, so
// it must not fall through to the mixed Sekalaiset one.
if got := special[0].CategoryKey(); got != "tahteet" {
t.Errorf("CategoryKey = %q, want tahteet", got)
}
// Logging it has to work exactly like logging a real meal.
date := day(t, "2026-09-05")
if err := saveEntry(h.db, date, special[0].ID, nil); err != nil {
t.Fatalf("saveEntry: %v", err)
}
entry, err := entryFor(h.db, date)
if err != nil || entry == nil {
t.Fatalf("entryFor: %v, %v", entry, err)
}
if entry.Main.Name != "Tähteet" {
t.Errorf("logged %q, want Tähteet", entry.Main.Name)
}
}
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) { func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
h := seeded(t) h := seeded(t)
id := h.sideNamed(t, "Riisi") id := h.sideNamed(t, "Riisi")
@@ -187,7 +138,7 @@ func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
if err := softDeleteSide(h.db, id); err != nil { if err := softDeleteSide(h.db, id); err != nil {
t.Fatalf("softDeleteSide: %v", err) t.Fatalf("softDeleteSide: %v", err)
} }
sides, err := listSides(h.db, "") sides, err := listSides(h.db)
if err != nil { if err != nil {
t.Fatalf("listSides: %v", err) t.Fatalf("listSides: %v", err)
} }
+49 -342
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"io" "io"
"log" "log"
@@ -18,27 +17,9 @@ import (
// kilobytes; a megabyte is already absurd generosity. // kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20 const maxUpload = 1 << 20
const ( // historyDays is how far back the Historia list walks. Long enough to see a
// historyDays is one window of the history under the logger, and the step // couple of months, short enough to stay one scroll.
// that "show more" grows it by. Older days arrive a window at a time const historyDays = 60
// 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 { type app struct {
db *sql.DB db *sql.DB
@@ -55,27 +36,16 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
// date reads the ?pvm= parameter, falling back to today. An unparseable value // 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 // is treated as today rather than an error: a mangled URL should not be a
// dead end. // 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 { func (a *app) date(r *http.Request) time.Time {
now := today(a.loc)
if raw := r.FormValue("pvm"); raw != "" { if raw := r.FormValue("pvm"); raw != "" {
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil { if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
if d.After(now) {
return now
}
return d return d
} }
} }
return now return today(a.loc)
} }
// logView is everything the log screen needs. Logging and history are one // logView is everything the Kirjaa screen needs.
// 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 { type logView struct {
Date time.Time Date time.Time
Today time.Time Today time.Time
@@ -83,70 +53,18 @@ type logView struct {
Entry *Entry // what is already logged for Date, if anything Entry *Entry // what is already logged for Date, if anything
Chosen *Dish // dish picked, so the sides step is showing Chosen *Dish // dish picked, so the sides step is showing
Checked map[int64]bool // sides ticked in that step Checked map[int64]bool // sides ticked in that step
ShowBoard bool Dishes []Dish
Dishes []Dish // flat, only to know whether anything matched
Groups []DishGroup // what the board actually renders
Special []Dish // Tähteet and the like: loggable, but not food
Sides []Side Sides []Side
New mainForm // inline "add the dish you were looking for" 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
}
// logOptions is what the Kirjaa screen is being asked to show. Pulled out of
// the request for a page load or a patch, and set directly after a write,
// where the answer is simply "that day, nothing else open".
type logOptions struct {
Date time.Time
Dish string // ?ruoka=, opening the sides step
Changing bool // ?muuta=, swapping the dish on a logged day
Confirming bool // ?poista=, asking before deleting the entry
Search string
}
func (a *app) logOptionsFrom(r *http.Request) logOptions {
q := r.URL.Query()
return logOptions{
Date: a.date(r),
Dish: q.Get("ruoka"),
Changing: q.Get("muuta") != "",
Confirming: q.Get("poista") != "",
Search: strings.TrimSpace(q.Get("haku")),
}
} }
func (a *app) index(w http.ResponseWriter, r *http.Request) { func (a *app) index(w http.ResponseWriter, r *http.Request) {
render(w, r, logPage(a.buildLog(r, a.logOptionsFrom(r)))) date := a.date(r)
}
// day patches the list in place. Every link in it calls here rather than
// loading a page, so opening a day leaves the scroll position alone.
func (a *app) day(w http.ResponseWriter, r *http.Request) {
fragment(w, r, dayList(a.buildLog(r, a.logOptionsFrom(r))))
}
// finishDay answers a write: a patch for Datastar, a redirect otherwise.
func (a *app) finishDay(w http.ResponseWriter, r *http.Request, date time.Time) {
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, logOptions{Date: date})))
return
}
a.redirectToDay(w, r, date)
}
func (a *app) buildLog(r *http.Request, o logOptions) logView {
date := o.Date
v := logView{ v := logView{
Date: date, Date: date,
Today: today(a.loc), Today: today(a.loc),
Search: o.Search, Search: strings.TrimSpace(r.URL.Query().Get("haku")),
Checked: map[int64]bool{}, Checked: map[int64]bool{},
Confirming: o.Confirming,
} }
entry, err := entryFor(a.db, date) entry, err := entryFor(a.db, date)
@@ -158,8 +76,8 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
// ?ruoka= opens the sides step for that dish. When it is the dish already // ?ruoka= opens the sides step for that dish. When it is the dish already
// logged, the existing sides come back ticked, which makes editing an // logged, the existing sides come back ticked, which makes editing an
// entry the same screen as creating one. // entry the same screen as creating one.
if o.Dish != "" { if raw := r.URL.Query().Get("ruoka"); raw != "" {
if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil { if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
if dish, err := dishByID(a.db, id); err == nil { if dish, err := dishByID(a.db, id); err == nil {
v.Chosen = dish v.Chosen = dish
if entry != nil && entry.Main.ID == id { if entry != nil && entry.Main.ID == id {
@@ -171,182 +89,21 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
} }
} }
// The board shows when there is nothing logged yet, or when the entry is if v.Chosen == nil && v.Entry == nil {
// 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 := o.Changing || v.Search != ""
if v.Chosen == nil && (v.Entry == nil || changing) {
v.ShowBoard = true
if v.Dishes, err = listDishes(a.db, v.Search); err != nil { if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err) 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)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("list special: %v", err)
}
// Seed the inline add form with whatever was searched for, so a miss // Seed the inline add form with whatever was searched for, so a miss
// turns straight into "add it" without retyping. // turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
} }
if v.Chosen != nil && v.Chosen.HasSides { 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) log.Printf("list sides: %v", err)
} }
} }
a.loadDays(r, &v) render(w, r, logPage(v))
return v
}
// loadDays fills the day list. The selected day expands inside it rather than
// in a panel above it, so choosing a day from the list does not reorder the
// list underneath the tap.
func (a *app) loadDays(r *http.Request, v *logView) {
v.HistoryDays = historyWindow(r)
// The window has to reach the selected day, or it would have nowhere to
// expand.
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
v.HistoryDays = min(reach, maxHistoryDays)
}
v.HistoryMore = v.HistoryDays + historyDays
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
if err != nil {
log.Printf("history: %v", err)
}
// Nothing logged ever: the selected day is still the one being worked on,
// so it needs a row of its own to open in.
if len(page.Rows) == 0 {
page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}}
}
v.History = page
}
// 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)
}
}
// isDatastar reports whether the request came from the client library, which
// tags its own. Everything below keeps working without JavaScript: the same
// handlers redirect instead of patching when the header is absent.
func isDatastar(r *http.Request) bool {
return r.Header.Get("Datastar-Request") != ""
}
// patchElements sends one Datastar event carrying several elements, each
// matched to the page by its id. A text/html response can only replace one
// element, and the catalog has to move its list and its forms together —
// opening an edit form also has to un-highlight whatever was open before.
//
// ponytail: about twenty lines instead of the SDK, which brought four modules
// for an SSE generator we would otherwise never call.
func patchElements(w http.ResponseWriter, r *http.Request, components ...templ.Component) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
var out strings.Builder
out.WriteString("event: datastar-patch-elements\n")
for _, c := range components {
var html strings.Builder
if err := c.Render(r.Context(), &html); err != nil {
log.Printf("patch %s: %v", r.URL.Path, err)
return
}
// One `data: elements` line per line of HTML, as the protocol wants.
for _, line := range strings.Split(html.String(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
out.WriteString("data: elements ")
out.WriteString(line)
out.WriteString("\n")
}
}
out.WriteString("\n")
io.WriteString(w, out.String())
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// 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)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("search special: %v", err)
}
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 // quickAdd creates a dish from the Kirjaa screen and goes straight on to
@@ -384,12 +141,6 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
log.Printf("quick add: %v", err) log.Printf("quick add: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: default:
// Created: straight on to its sides step.
opts := logOptions{Date: date, Dish: strconv.FormatInt(id, 10)}
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, opts)))
return
}
a.redirectToPick(w, r, date, id) a.redirectToPick(w, r, date, id)
return return
} }
@@ -397,11 +148,16 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
// Rejected: back to the board with the form filled in and the search // Rejected: back to the board with the form filled in and the search
// still narrowed, so the add card stays on screen. // still narrowed, so the add card stays on screen.
v := a.buildLog(r, logOptions{Date: date, Search: form.Name}) v := logView{
v.New = form Date: date,
if isDatastar(r) { Today: today(a.loc),
fragment(w, r, dayList(v)) Search: form.Name,
return Checked: map[int64]bool{},
New: form,
}
var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
} }
render(w, r, logPage(v)) render(w, r, logPage(v))
} }
@@ -437,7 +193,7 @@ func (a *app) save(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError) http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
return return
} }
a.finishDay(w, r, date) a.redirectToDay(w, r, date)
} }
func (a *app) delete(w http.ResponseWriter, r *http.Request) { func (a *app) delete(w http.ResponseWriter, r *http.Request) {
@@ -447,12 +203,23 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return return
} }
a.finishDay(w, r, date) a.redirectToDay(w, r, date)
} }
// redirectToDay is the no-JavaScript path back after a write.
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) { func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther) target := "/"
if !date.Equal(today(a.loc)) {
target += "?pvm=" + date.Format(dateLayout)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (a *app) history(w http.ResponseWriter, r *http.Request) {
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 // mainForm and sideForm carry what the user typed, so a rejected submission
@@ -472,37 +239,16 @@ type sideForm struct {
} }
type catalogView struct { type catalogView struct {
Groups []DishGroup Mains []Dish
Sides []Side Sides []Side
Main mainForm Main mainForm
Side sideForm Side sideForm
Report *ImportReport 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
} }
// catalog renders the whole page. show patches the same state in place, so
// nothing navigates: both build the view the same way.
func (a *app) catalog(w http.ResponseWriter, r *http.Request) { func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
a.renderCatalog(w, r, a.catalogState(r))
}
// show is what every catalog link actually calls. It patches the list and both
// forms rather than loading a page, so opening an edit form or asking to
// delete a row leaves the scroll position exactly where it was.
func (a *app) show(w http.ResponseWriter, r *http.Request) {
a.patchCatalog(w, r, a.catalogState(r))
}
func (a *app) catalogState(r *http.Request) catalogView {
v := catalogView{ 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. // ?muokkaa= loads a dish into its form; the same form adds and edits.
@@ -528,47 +274,25 @@ func (a *app) catalogState(r *http.Request) catalogView {
} }
} }
} }
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")
}
}
return v a.renderCatalog(w, r, v)
} }
// fillCatalog loads the lists into a view built from the request. func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
func (a *app) fillCatalog(v *catalogView) {
if v.Main.Categories == nil { if v.Main.Categories == nil {
v.Main.Categories = map[string]bool{} v.Main.Categories = map[string]bool{}
} }
mains, err := listDishes(a.db, v.Search) var err error
if err != nil { if v.Mains, err = listDishes(a.db, ""); err != nil {
log.Printf("list mains: %v", err) log.Printf("list mains: %v", err)
} }
v.Mains = len(mains) if v.Sides, err = listSides(a.db); err != nil {
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) log.Printf("list sides: %v", err)
} }
}
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
render(w, r, catalogPage(v)) render(w, r, catalogPage(v))
} }
// patchCatalog swaps the list and both forms in one event. They move together:
// opening an edit form also has to clear whatever delete was being confirmed.
func (a *app) patchCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
patchElements(w, r, catalogList(v), mainForm_(v.Main), sideForm_(v.Side))
}
// saveMain adds or updates a main dish. A rejected form is re-rendered with // saveMain adds or updates a main dish. A rejected form is re-rendered with
// the values still in it; a good one redirects, so refresh cannot re-submit. // the values still in it; a good one redirects, so refresh cannot re-submit.
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
@@ -609,28 +333,11 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
log.Printf("save main: %v", err) log.Printf("save main: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: default:
// Saved: hand back a blank form so it collapses, and a list with http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
// the dish in it.
a.finishCatalog(w, r, catalogView{})
return return
} }
} }
a.finishCatalog(w, r, catalogView{Main: form}) a.renderCatalog(w, r, catalogView{Main: form})
}
// finishCatalog answers a catalog write: a patch for Datastar, a redirect for
// a plain form post. Without the redirect, submitting with JavaScript off
// would leave the browser sitting on a POST it could not reload.
func (a *app) finishCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
if isDatastar(r) {
a.patchCatalog(w, r, v)
return
}
if v.Main.Err != "" || v.Side.Err != "" {
a.renderCatalog(w, r, v)
return
}
http.Redirect(w, r, "/ruuat", http.StatusSeeOther)
} }
func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
@@ -655,11 +362,11 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
log.Printf("save side: %v", err) log.Printf("save side: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: default:
a.finishCatalog(w, r, catalogView{}) http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
return return
} }
} }
a.finishCatalog(w, r, catalogView{Side: form}) a.renderCatalog(w, r, catalogView{Side: form})
} }
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6). // deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
@@ -683,7 +390,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return return
} }
a.finishCatalog(w, r, catalogView{}) http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
} }
// importDishes takes a bundle either pasted into the textarea or uploaded as a // importDishes takes a bundle either pasted into the textarea or uploaded as a
+16 -42
View File
@@ -35,21 +35,6 @@ var staticFS embed.FS
// version is replaced at build time with the CalVer tag (see `make image`). // version is replaced at build time with the CalVer tag (see `make image`).
var version = "dev" var version = "dev"
// envTag marks the browser tab of anything that is not production, so a dev
// instance and the real one open side by side are told apart at a glance.
// Empty in production, which is the default.
var envTag string
func setEnvTag(value string) {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "prod") || strings.EqualFold(value, "production") {
envTag = ""
return
}
// Whatever it says, so ENV=staging labels itself too.
envTag = strings.ToLower(value)
}
const ( const (
listenAddr = ":8080" listenAddr = ":8080"
defaultTZ = "Europe/Helsinki" defaultTZ = "Europe/Helsinki"
@@ -70,7 +55,7 @@ func run() error {
"import a JSON dish bundle (PRD §7.3 shape) and exit") "import a JSON dish bundle (PRD §7.3 shape) and exit")
flag.Parse() flag.Parse()
db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB)) db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB))
if err != nil { if err != nil {
return err return err
} }
@@ -81,9 +66,9 @@ func run() error {
return runImport(db, *importPath) return runImport(db, *importPath)
} }
password := os.Getenv("PASSWORD") password := os.Getenv("FOODSTER_PASSWORD")
if password == "" { if password == "" {
return errors.New("PASSWORD is not set") return errors.New("FOODSTER_PASSWORD is not set")
} }
// Fail rather than fall back to UTC: a silently wrong zone shifts logged // Fail rather than fall back to UTC: a silently wrong zone shifts logged
@@ -94,11 +79,9 @@ func run() error {
return fmt.Errorf("TZ: %w", err) return fmt.Errorf("TZ: %w", err)
} }
setEnvTag(os.Getenv("ENV")) // The container always publishes :8080; FOODSTER_ADDR exists so tests and
// a second local instance can pick another port.
// The container always publishes :8080; ADDR exists so tests and a second addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
// local instance can pick another port.
addr := cmp.Or(os.Getenv("ADDR"), listenAddr)
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
@@ -175,16 +158,13 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("GET /{$}", a.index) mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("POST /kirjaa", a.save) mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd) mux.HandleFunc("POST /lisaa", a.quickAdd)
mux.HandleFunc("GET /etsi", a.searchBoard)
mux.HandleFunc("GET /paiva", a.day)
mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /historia", a.history)
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) mux.HandleFunc("GET /ruoat", a.catalog)
mux.HandleFunc("GET /ruuat/nayta", a.show) mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain) mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish) mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it. // /healthz stays outside auth so a monitor or reverse proxy can reach it.
root := http.NewServeMux() root := http.NewServeMux()
@@ -233,17 +213,11 @@ func challenge(w http.ResponseWriter) {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
} }
// today is the current calendar day in the configured location, truncated to // today is the current calendar day in the configured location. Every date in
// midnight. Every date in this app goes through here rather than time.Local, // this app goes through here rather than time.Local, which would be UTC
// which would be UTC whenever TZ is unset and quietly shift evening entries to // whenever TZ is unset and quietly shift evening entries to the day before.
// 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 { func today(loc *time.Location) time.Time {
now := time.Now().In(loc) return 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 // normalizeName collapses whitespace and capitalises the first letter for
+7 -107
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -31,103 +30,6 @@ 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 TestEnvTagMarksNonProduction(t *testing.T) {
t.Cleanup(func() { envTag = "" })
cases := []struct{ env, want string }{
// Production is the default and must stay unmarked: the tag exists to
// pick the dev tab out of two identical ones.
{"", "Foodster"},
{"prod", "Foodster"},
{"PRODUCTION", "Foodster"},
{" ", "Foodster"},
{"dev", "dev · Foodster"},
{"DEV", "dev · Foodster"},
{"staging", "staging · Foodster"},
}
for _, c := range cases {
setEnvTag(c.env)
if got := pageTitle("Foodster"); got != c.want {
t.Errorf("ENV=%q: title = %q, want %q", c.env, got, c.want)
}
}
}
func TestMigrateCreatesSchema(t *testing.T) { func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db") db, err := openDB(t.TempDir() + "/test.db")
if err != nil { if err != nil {
@@ -269,16 +171,14 @@ func TestMealLogOneEntryPerDate(t *testing.T) {
} }
defer db.Close() defer db.Close()
// Ids well clear of anything the migrations create. if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil {
if _, err := db.Exec(
`INSERT INTO main_dishes (id, name) VALUES (101, 'Lohikeitto'), (102, 'Lihapullat')`); err != nil {
t.Fatalf("seed: %v", err) t.Fatalf("seed: %v", err)
} }
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); err != nil { if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil {
t.Fatalf("first entry: %v", err) t.Fatalf("first entry: %v", err)
} }
// PRD §6: a second dinner for the same day must be refused. // PRD §6: a second dinner for the same day must be refused.
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 102)`); err == nil { if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil {
t.Error("second entry for the same date was accepted, want a unique violation") t.Error("second entry for the same date was accepted, want a unique violation")
} }
} }
@@ -290,18 +190,18 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
} }
defer db.Close() defer db.Close()
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (101, 'Kanacurry')`); err != nil { if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil {
t.Fatalf("first insert: %v", err) t.Fatalf("first insert: %v", err)
} }
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err == nil { if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil {
t.Error("case-variant duplicate was accepted, want a unique violation") t.Error("case-variant duplicate was accepted, want a unique violation")
} }
// Soft-deleting the original frees the name again (PRD §7.3). // Soft-deleting the original frees the name again (PRD §7.3).
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 101`); err != nil { if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
t.Fatalf("soft delete: %v", err) t.Fatalf("soft delete: %v", err)
} }
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err != nil { if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil {
t.Errorf("name still blocked after soft delete: %v", err) t.Errorf("name still blocked after soft delete: %v", err)
} }
} }
-19
View File
@@ -1,19 +0,0 @@
-- Tähteet: leftovers.
--
-- Not a dish. It exists so a day can be recorded as "we ate what was already
-- there" without inventing a meal that was never cooked. It has no category,
-- it is not something the household adds or edits, and the stage 2 suggester
-- must never propose it (PRD §8).
--
-- Modelled as a flagged row in main_dishes rather than a nullable
-- main_dish_id on meal_log: the log keeps one shape, and every foreign key
-- and join carries on working untouched.
ALTER TABLE main_dishes
ADD COLUMN special INTEGER NOT NULL DEFAULT 0 CHECK (special IN (0, 1));
-- OR IGNORE in case a household already typed a dish by this name: the unique
-- index on lower(name) would otherwise fail the migration. Their row stays as
-- an ordinary dish, which is wrong but harmless and fixable by hand.
INSERT OR IGNORE INTO main_dishes (name, has_sides, special)
VALUES ('Tähteet', 0, 1);
+22 -131
View File
@@ -20,11 +20,6 @@
--kala: light-dark(#25688F, #63AAD8); --kala: light-dark(#25688F, #63AAD8);
--kasvis: light-dark(#457A3C, #82BE7A); --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 */ --tap: 48px; /* minimum touch target */
} }
@@ -48,15 +43,11 @@ button, input, select { font: inherit; }
:focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; } :focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } @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 { .brandbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 16px; padding: 10px 16px;
background: var(--header);
border-bottom: 1px solid var(--line);
} }
.brand { .brand {
display: flex; display: flex;
@@ -93,7 +84,9 @@ html[data-theme="dark"] .themetoggle .i-sun { display: none; }
html[data-theme="light"] .themetoggle .i-moon { display: none; } html[data-theme="light"] .themetoggle .i-moon { display: none; }
.appbar { .appbar {
padding: 16px 16px 4px; background: var(--paper);
border-bottom: 1px solid var(--line);
padding: 2px 16px 12px;
} }
.appbar h2 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; } .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); } .appbar .meta { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
@@ -123,19 +116,21 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
} }
.tabbar a[aria-current] { color: var(--accent); } .tabbar a[aria-current] { color: var(--accent); }
/* Category marks carry colour and shape together, so they are readable .dot {
without having learned which hue means what. */ width: 9px;
.cat { flex: none; display: block; } height: 9px;
.cat svg { display: block; width: 16px; height: 16px; } border-radius: 2px;
.pill.xl .cat svg { width: 19px; height: 19px; } flex: none;
.logged .cat svg { width: 22px; height: 22px; } display: inline-block;
}
.c-liha { color: var(--liha); } .d-liha { background: var(--liha); }
.c-kana { color: var(--kana); } .d-kana { background: var(--kana); }
.c-kala { color: var(--kala); } .d-kala { background: var(--kala); }
.c-kasvis { color: var(--kasvis); } .d-kasvis { background: var(--kasvis); }
/* Not a category, so not a category colour. */ .d-sek {
.c-tahteet { color: var(--muted); } background: conic-gradient(var(--liha) 0 25%, var(--kana) 25% 50%,
var(--kala) 50% 75%, var(--kasvis) 75% 100%);
}
/* Day switcher */ /* Day switcher */
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; } .dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
@@ -210,20 +205,6 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
font-size: 10px; font-size: 10px;
color: var(--muted); color: var(--muted);
} }
/* Not food: set apart from the categories, and deliberately quiet. */
.special {
margin-top: 22px;
padding-top: 16px;
border-top: 1px dashed var(--line);
}
.pill.plain {
font-size: 16px;
padding: 12px 16px;
background: var(--sunk);
color: var(--muted);
font-weight: 600;
}
.pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; } .pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
.pill.lg { font-size: 18px; padding: 12px 16px; } .pill.lg { font-size: 18px; padding: 12px 16px; }
.pill.md { font-size: 15.5px; padding: 11px 14px; } .pill.md { font-size: 15.5px; padding: 11px 14px; }
@@ -326,26 +307,6 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
/* The day list is the page; rows are links and the selected one expands. */
.history { margin-top: 4px; }
/* Marked with a bar down the side, not rules above and below: the rows either
side already draw a bottom border, so a horizontal rule here doubled up.
scroll-margin keeps the anchor off the viewport edge. */
.open {
scroll-margin-top: 12px;
margin: 8px 0 18px;
padding: 10px 0 4px 13px;
border-left: 3px solid var(--accent);
}
.openday {
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
.entry, .gapline { .entry, .gapline {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -353,27 +314,6 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
min-height: var(--tap); min-height: var(--tap);
padding: 14px 0; padding: 14px 0;
border-bottom: 1px solid var(--line); 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); } .gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
.entry time, .gapline time { .entry time, .gapline time {
@@ -457,52 +397,15 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
cursor: pointer; cursor: pointer;
} }
/* Catalog structure: Pääruuat and Lisukkeet are the two halves of the /* Catalog rows */
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 { .sechead {
margin: 20px 0 2px; margin: 26px 0 6px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.09em; letter-spacing: 0.09em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); 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 { .row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -514,14 +417,12 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
.rowtext { flex: 1; min-width: 0; } .rowtext { flex: 1; min-width: 0; }
.rowtext .nm { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; } .rowtext .nm { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; }
.rowtext .sd { font-size: 12.5px; color: var(--muted); } .rowtext .sd { font-size: 12.5px; color: var(--muted); }
.rowactions { display: flex; align-items: center; gap: 2px; flex: none; } .rowactions { display: flex; align-items: center; gap: 4px; flex: none; }
.rowactions a, .rowactions button { .rowactions a, .rowactions button {
min-width: 40px;
min-height: 40px; min-height: 40px;
padding: 0 8px; padding: 0 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
background: none; background: none;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
@@ -531,19 +432,9 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
.rowactions svg { display: block; }
.rowactions a:hover { color: var(--accent); } .rowactions a:hover { color: var(--accent); }
.rowactions .del { color: var(--liha); } .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"] { .field input[type="text"] {
width: 100%; width: 100%;
min-height: var(--tap); min-height: var(--tap);
+28 -116
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"slices"
"strings" "strings"
"time" "time"
) )
@@ -28,24 +27,13 @@ type Dish struct {
TimesEaten int TimesEaten int
} }
// Rows flagged `special` in the database — Tähteet — are loggable but are not // CategoryKey is the class suffix for the colour dot. A dish covering several
// food. They carry no category, never appear in the catalog, and PRD §8 // categories (tortillas, build-your-own pizza) gets the mixed marker.
// excludes them from the suggester: cooldown, coverage and weighting all skip
// them. dishByID deliberately does not filter on the flag, because logging one
// has to work like logging anything else.
// CategoryKey picks the mark for a dish. One covering several categories
// (tortillas, build-your-own pizza) gets the mixed one; carrying none at all
// means it is not food, and Tähteet is not a mixture of anything.
func (d Dish) CategoryKey() string { func (d Dish) CategoryKey() string {
switch len(d.Categories) { if len(d.Categories) == 1 {
case 0:
return "tahteet"
case 1:
return categoryFI[d.Categories[0]] return categoryFI[d.Categories[0]]
default:
return "sek"
} }
return "sek"
} }
// Size buckets the dish by how often it has been eaten. The board draws // Size buckets the dish by how often it has been eaten. The board draws
@@ -88,17 +76,6 @@ func (e Entry) SidesLabel() string {
// listDishes returns live mains ordered by how often they have been eaten. // listDishes returns live mains ordered by how often they have been eaten.
// An empty search matches everything. // An empty search matches everything.
func listDishes(db *sql.DB, search string) ([]Dish, error) { func listDishes(db *sql.DB, search string) ([]Dish, error) {
return queryDishes(db, search, false)
}
// listSpecial returns the entries that are not food — Tähteet and anything
// like it. They are loggable but never suggested, and never appear in the
// catalog, so they are fetched deliberately rather than by accident.
func listSpecial(db *sql.DB, search string) ([]Dish, error) {
return queryDishes(db, search, true)
}
func queryDishes(db *sql.DB, search string, special bool) ([]Dish, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT m.id, m.name, m.has_sides, SELECT m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category) coalesce((SELECT group_concat(c.category)
@@ -107,9 +84,8 @@ func queryDishes(db *sql.DB, search string, special bool) ([]Dish, error) {
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id) (SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
FROM main_dishes m FROM main_dishes m
WHERE m.deleted_at IS NULL WHERE m.deleted_at IS NULL
AND m.special = ?
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%') AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%')
ORDER BY 5 DESC, m.name`, special, search, search) ORDER BY 5 DESC, m.name`, search, search)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -151,12 +127,9 @@ func dishByID(db *sql.DB, id int64) (*Dish, error) {
return &d, nil return &d, nil
} }
func listSides(db *sql.DB, search string) ([]Side, error) { func listSides(db *sql.DB) ([]Side, error) {
rows, err := db.Query(` rows, err := db.Query(
SELECT id, name FROM side_dishes `SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`)
WHERE deleted_at IS NULL
AND (? = '' OR lower(name) LIKE '%' || lower(?) || '%')
ORDER BY name`, search, search)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -368,103 +341,42 @@ func sideByID(db *sql.DB, id int64) (*Side, error) {
return &s, nil 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. // HistoryRow is one calendar day: either what was eaten or an unfilled gap.
type HistoryRow struct { type HistoryRow struct {
Date time.Time Date time.Time
Entry *Entry Entry *Entry
} }
// HistoryPage is one window of history plus where to continue from. After a // history walks back day by day from today, so a day nobody wrote down shows
// few years of daily entries the whole log is far too much to render at once. // up as an explicit gap rather than silently missing. It stops at the first
type HistoryPage struct { // entry ever recorded — before that there is no history to be missing.
Rows []HistoryRow func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) {
More bool // older entries exist beyond this window var first string
Next time.Time // the day the next window starts at err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first)
} if err == sql.ErrNoRows || first == "" {
return nil, nil
// 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 { if err != nil {
return HistoryPage{}, err return nil, err
} }
if from.Before(firstDate) { firstDate, err := time.ParseInLocation(dateLayout, first, loc)
return HistoryPage{}, nil if err != nil {
return nil, err
} }
oldest := from.AddDate(0, 0, -days+1) now := today(loc)
page := HistoryPage{More: true} oldest := now.AddDate(0, 0, -days)
if !firstDate.Before(oldest) { if firstDate.After(oldest) {
oldest = firstDate oldest = firstDate
page.More = false
} }
page.Next = oldest.AddDate(0, 0, -1)
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) { var rows []HistoryRow
for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
entry, err := entryFor(db, d) entry, err := entryFor(db, d)
if err != nil { if err != nil {
return HistoryPage{}, err return nil, err
} }
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry}) rows = append(rows, HistoryRow{Date: d, Entry: entry})
} }
return page, nil return rows, nil
} }
+4 -55
View File
@@ -235,11 +235,10 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
t.Fatalf("save -3: %v", err) t.Fatalf("save -3: %v", err)
} }
page, err := history(h.db, loc, now, 60) rows, err := history(h.db, loc, 60)
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
rows := page.Rows
// Walks back to the oldest entry only: today, -1, -2, -3. // Walks back to the oldest entry only: today, -1, -2, -3.
if len(rows) != 4 { if len(rows) != 4 {
t.Fatalf("%d rows, want 4", len(rows)) t.Fatalf("%d rows, want 4", len(rows))
@@ -253,66 +252,16 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" { if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry) 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) { func TestHistoryEmptyWithoutEntries(t *testing.T) {
h := seeded(t) h := seeded(t)
page, err := history(h.db, time.UTC, today(time.UTC), 60) rows, err := history(h.db, time.UTC, 60)
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
if len(page.Rows) != 0 { if len(rows) != 0 {
t.Errorf("%d rows for an empty log, want 0", len(page.Rows)) t.Errorf("%d rows for an empty log, want 0", len(rows))
}
if page.More {
t.Error("More is set although there is no history at all")
} }
} }
+120 -489
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -43,23 +42,6 @@ func dayURL(base string, d, now time.Time) string {
return base + "?pvm=" + isoDate(d) return base + "?pvm=" + isoDate(d)
} }
// showURL turns a catalog page link into the patch endpoint behind it, so the
// href and the Datastar call never drift apart.
func showURL(pageURL string) string {
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
}
// dayPatch is the endpoint behind every link in the day list. The href beside
// it stays a real page URL for anyone without JavaScript; Datastar calls this
// instead and swaps the list where it stands.
func dayPatch(d time.Time, param string) string {
url := "/paiva?pvm=" + isoDate(d)
if param != "" {
url += "&" + param
}
return url
}
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for // pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
// any day but today. // any day but today.
func pickSeparator(v logView) string { func pickSeparator(v logView) string {
@@ -69,36 +51,6 @@ func pickSeparator(v logView) string {
return "&" return "&"
} }
// stepURL is the page URL for a link inside the open day: the fallback when
// there is no JavaScript to intercept it.
func stepURL(v logView, param string) string {
url := dayURL("/", v.Date, v.Today)
if param != "" {
url += pickSeparator(v) + param
}
return url
}
// 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. // categoryLabels lists a dish's categories in Finnish, for the catalog rows.
func categoryLabels(d Dish) string { func categoryLabels(d Dish) string {
names := make([]string, 0, len(d.Categories)) names := make([]string, 0, len(d.Categories))
@@ -108,16 +60,6 @@ func categoryLabels(d Dish) string {
return strings.Join(names, ", ") return strings.Join(names, ", ")
} }
// pageTitle prefixes the tab title on any instance that is not production.
// The tab is the only place a browser shows which of two identical apps you
// are looking at.
func pageTitle(title string) string {
if envTag == "" {
return title
}
return envTag + " · " + title
}
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive // countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
// after every number except one. // after every number except one.
func countFI(n int, one, many string) string { func countFI(n int, one, many string) string {
@@ -136,11 +78,9 @@ templ page(title, current string) {
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="color-scheme" content="light dark"/> <meta name="color-scheme" content="light dark"/>
// Matches --header in app.css so the browser chrome continues the <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ECEDE8"/>
// header rather than butting against it. <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121316"/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/> <title>{ title }</title>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ pageTitle(title) }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and <!-- use-credentials: the manifest is fetched behind Basic auth and
@@ -169,6 +109,9 @@ templ brandbar() {
</header> </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 // 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 // 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. // picks one, so the server never has to know the device's choice.
@@ -199,105 +142,11 @@ templ iconMoon() {
</svg> </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>
case "tahteet":
<span class="cat c-tahteet">
@glyphTahteet()
</span>
default:
<span class="cat">
@glyphSekalaiset()
</span>
}
}
// A lidded tub. Tähteet is not food and not a mixture of categories, so it
// gets neither a category colour nor the quartered mark.
templ glyphTahteet() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
<rect x="1.4" y="2.6" width="13.2" height="3" rx="1.3"></rect>
<path d="M2.8 6.8h10.4l-.9 6.6a1.6 1.6 0 0 1-1.6 1.4H5.3a1.6 1.6 0 0 1-1.6-1.4z"></path>
</svg>
}
// 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) { templ tabbar(current string) {
<nav class="tabbar"> <nav class="tabbar">
@tab("/", "Kirjaa", current) @tab("/", "Kirjaa", current)
@tab("/ruuat", "Ruuat", current) @tab("/historia", "Historia", current)
@tab("/ruoat", "Ruoat", current)
</nav> </nav>
} }
@@ -318,75 +167,16 @@ templ logPage(v logView) {
@daySwitch(v) @daySwitch(v)
</header> </header>
<main class="pad"> <main class="pad">
@dayList(v)
</main>
}
}
// dayList is the whole page: every day back through the window, with the
// selected one expanded where it sits. Opening a day used to swap in a panel
// above the list and drop that day out of it, so the rows below jumped up
// under the tap. Now nothing moves — the row grows.
templ dayList(v logView) {
<section class="history" id="paivat">
for i, row := range v.History.Rows {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Date.Equal(v.Date) {
<div class="open">
<p class="openday">
if row.Date.Equal(v.Today) {
Tänään
} else {
{ longDateFI(row.Date) }
}
</p>
switch { switch {
case v.Chosen != nil: case v.Chosen != nil:
@sidesStep(v) @sidesStep(v)
case v.ShowBoard: case v.Entry != nil:
@board(v)
default:
@loggedCard(v) @loggedCard(v)
default:
@board(v)
} }
</div> </main>
} else if row.Entry != nil {
<a
class="entry"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<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)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<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)) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "paivat="+strconv.Itoa(v.HistoryMore)) + "')" }
>Näytä lisää</a>
}
</section>
} }
templ daySwitch(v logView) { templ daySwitch(v logView) {
@@ -394,9 +184,7 @@ templ daySwitch(v logView) {
@dayButton("Tänään", v.Today, v.Date, v.Today) @dayButton("Tänään", v.Today, v.Date, v.Today)
@dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today) @dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
<form method="get" action="/" class="daypick"> <form method="get" action="/" class="daypick">
// max stops the picker offering days that have not happened yet; <input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/>
// 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> <button type="submit">Näytä</button>
</form> </form>
</div> </div>
@@ -410,73 +198,23 @@ 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) { templ board(v logView) {
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/" class="searchrow"> <form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) { if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
} }
<input <input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
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> </form>
@boardList(v) if len(v.Dishes) > 0 {
</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"> <div class="board">
for _, d := range g.Dishes { for _, d := range v.Dishes {
@dishPill(d, v) @dishPill(d, v)
} }
</div> </div>
} }
// Tähteet is not food, so it sits apart from the categories rather
// than inside one. Fixed size: it will be among the most-logged
// entries, and it should not tower over the actual cooking.
if len(v.Special) > 0 {
<div class="special">
for _, d := range v.Special {
<a
class="pill plain"
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
>
@categoryIcon(d.CategoryKey())
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
</div>
}
// Tähteet always matches an empty search, so the add card keys off the
// real dishes only: otherwise a fresh install would show leftovers and
// no way to add anything.
if len(v.Dishes) == 0 { if len(v.Dishes) == 0 {
@quickAddCard(v) @quickAddCard(v)
} }
</div>
} }
// quickAddCard turns a search that found nothing into the thing to do next. // quickAddCard turns a search that found nothing into the thing to do next.
@@ -487,7 +225,7 @@ templ quickAddCard(v logView) {
if v.Search == "" { if v.Search == "" {
<h3>Lisää ensimmäinen ruoka</h3> <h3>Lisää ensimmäinen ruoka</h3>
<p class="muted small"> <p class="muted small">
Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä. Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruoat-välilehdeltä.
</p> </p>
} else { } else {
<h3>Ei osumia. Lisätäänkö?</h3> <h3>Ei osumia. Lisätäänkö?</h3>
@@ -495,11 +233,7 @@ templ quickAddCard(v logView) {
if v.New.Err != "" { if v.New.Err != "" {
<p class="formerr">{ v.New.Err }</p> <p class="formerr">{ v.New.Err }</p>
} }
<form <form method="post" action="/lisaa">
method="post"
action="/lisaa"
data-on:submit__prevent="@post('/lisaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<label class="field"> <label class="field">
<span>Nimi</span> <span>Nimi</span>
@@ -530,10 +264,9 @@ templ quickAddCard(v logView) {
templ dishPill(d Dish, v logView) { templ dishPill(d Dish, v logView) {
<a <a
class={ "pill", d.Size() } class={ "pill", d.Size() }
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) } href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
> >
@categoryIcon(d.CategoryKey()) <i class={ "dot", "d-" + d.CategoryKey() }></i>
{ d.Name } { d.Name }
if d.TimesEaten > 0 { if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span> <span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
@@ -544,14 +277,10 @@ templ dishPill(d Dish, v logView) {
templ sidesStep(v logView) { templ sidesStep(v logView) {
<section class="card"> <section class="card">
<h3> <h3>
@categoryIcon(v.Chosen.CategoryKey()) <i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i>
{ v.Chosen.Name } { v.Chosen.Name }
</h3> </h3>
<form <form method="post" action="/kirjaa">
method="post"
action="/kirjaa"
data-on:submit__prevent="@post('/kirjaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/> <input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
if v.Chosen.HasSides && len(v.Sides) > 0 { if v.Chosen.HasSides && len(v.Sides) > 0 {
@@ -573,11 +302,7 @@ templ sidesStep(v logView) {
} }
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
<a <a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
class="ghost"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</section> </section>
} }
@@ -591,76 +316,105 @@ templ loggedCard(v logView) {
} }
</p> </p>
<p class="nm"> <p class="nm">
@categoryIcon(v.Entry.Main.CategoryKey()) <i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i>
{ v.Entry.Main.Name } { v.Entry.Main.Name }
</p> </p>
<p class="sd">{ v.Entry.SidesLabel() }</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"
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
>
<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(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</div>
} else {
<div class="pair"> <div class="pair">
<a <a
class="btn" class="btn"
href={ templ.SafeURL(stepURL(v, "muuta=1")) } href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
>Muokkaa</a> >Muokkaa</a>
<a <form method="post" action="/poista">
class="btn del" <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
href={ templ.SafeURL(stepURL(v, "poista=1")) } <button class="btn del" type="submit">Poista</button>
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" } </form>
>Poista</a>
</div> </div>
}
</section> </section>
} }
// ---------------------------------------------------------------- Ruuat // ---------------------------------------------------------------- Historia
templ catalogPage(v catalogView) { templ historyPage(rows []HistoryRow) {
@page("Ruuat — Foodster", "/ruuat") { @page("Historia — Foodster", "/historia") {
<header class="appbar"> <header class="appbar">
<h2>Ruuat</h2> <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
templ catalogPage(v catalogView) {
@page("Ruoat — Foodster", "/ruoat") {
<header class="appbar">
<h2>Ruoat</h2>
<p class="meta"> <p class="meta">
{ countFI(v.Mains, "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") } { countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
</p> </p>
</header> </header>
<main class="pad"> <main class="pad">
if v.Report != nil { if v.Report != nil {
@importReport(v.Report) @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) @mainForm_(v.Main)
@sideForm_(v.Side) <h3 class="sechead">Pääruoat</h3>
@catalogList(v) 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>
</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")
</div>
}
<details class="card"> <details class="card">
<summary>Tuo ruokia tiedostosta</summary> <summary>Tuo ruokia tiedostosta</summary>
@importForm() @importForm()
@@ -669,149 +423,30 @@ templ catalogPage(v catalogView) {
} }
} }
// catalogList carries the id Datastar patches, so typing in the search box templ rowActions(editURL string, id int64, kind string) {
// swaps the lists without touching the forms above them. <div class="rowactions">
// <a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
// Two levels of heading, because there are two: Pääruuat and Lisukkeet are <form method="post" action="/ruoat/poista">
// 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.
// Every control here is a real link or form, so the page still works without
// JavaScript. Datastar intercepts them and patches the list in place instead,
// which is the whole point: a delete confirmation halfway down a long list
// must not send the browser back to the top.
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"
data-on:submit__prevent="@post('/ruuat/poista', {contentType: 'form'})"
>
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/> <input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del">Kyllä</button> <button type="submit" class="del" aria-label="Poista">Poista</button>
</form> </form>
<a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
</div> </div>
} else {
<div class="rowactions">
<a
href={ templ.SafeURL(editURL) }
data-on:click__prevent={ "@get('" + showURL(editURL) + "')" }
aria-label="Muokkaa"
title="Muokkaa"
>
@iconPencil()
</a>
<a
class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
data-on:click__prevent={ "@get('/ruuat/nayta?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) { templ mainForm_(f mainForm) {
<details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }> <section class="card" id="paaruoka">
<summary> <h3>
if f.ID == 0 { if f.ID == 0 {
Lisää pääruoka Lisää pääruoka
} else { } else {
Muokkaa pääruokaa Muokkaa pääruokaa
} }
</summary> </h3>
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form <form method="post" action="/ruoat/paaruoka">
method="post"
action="/ruuat/paaruoka"
data-on:submit__prevent="@post('/ruuat/paaruoka', {contentType: 'form'})"
>
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -839,9 +474,9 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a> <a class="ghost" href="/ruoat">Peruuta</a>
} }
</details> </section>
} }
templ categoryChip(value, label string, f mainForm) { templ categoryChip(value, label string, f mainForm) {
@@ -851,28 +486,24 @@ templ categoryChip(value, label string, f mainForm) {
} else { } else {
<input type="checkbox" name="kategoria" value={ value }/> <input type="checkbox" name="kategoria" value={ value }/>
} }
@categoryIcon(categoryFI[value]) <i class={ "dot", "d-" + categoryFI[value] }></i>
<span>{ label }</span> <span>{ label }</span>
</label> </label>
} }
templ sideForm_(f sideForm) { templ sideForm_(f sideForm) {
<details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }> <section class="card" id="lisuke">
<summary> <h3>
if f.ID == 0 { if f.ID == 0 {
Lisää lisuke Lisää lisuke
} else { } else {
Muokkaa lisuketta Muokkaa lisuketta
} }
</summary> </h3>
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form <form method="post" action="/ruoat/lisuke">
method="post"
action="/ruuat/lisuke"
data-on:submit__prevent="@post('/ruuat/lisuke', {contentType: 'form'})"
>
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -883,9 +514,9 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a> <a class="ghost" href="/ruoat">Peruuta</a>
} }
</details> </section>
} }
templ importForm() { templ importForm() {
@@ -894,7 +525,7 @@ templ importForm() {
<p class="muted small"> <p class="muted small">
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan. Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
</p> </p>
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data"> <form method="post" action="/ruoat/tuonti" enctype="multipart/form-data">
<label class="field"> <label class="field">
<span>JSON</span> <span>JSON</span>
<textarea <textarea
+9 -13
View File
@@ -1,23 +1,19 @@
services: services:
app: app:
image: ${REPO:?set REPO in .env}:${TAG:-latest} image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
restart: unless-stopped restart: unless-stopped
# A bind mount rather than a named volume: the database sits in ./data on # The database is a bind mount, not a named volume: it sits in ./data on
# the host, where it can be listed, copied and backed up without going # the host where it can be listed, copied and opened with any sqlite
# through the container engine. The image runs as UID 65534, so the # client. The image runs as UID 65534, so the container has to be told
# container has to be told which host user owns that directory. # which host user owns that directory.
# user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}"
# PUID/PGID rather than UID/GID: UID is a read-only variable in bash, so a
# value set here would be silently replaced by the invoking shell's own.
user: "${PUID:-1000}:${PGID:-1000}"
volumes: volumes:
- ./data:/data - ./data:/data
environment: environment:
PASSWORD: ${PASSWORD:?set PASSWORD in .env} FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
DB: /data/foodster.db FOODSTER_DB: /data/foodster.db
ENV: ${ENV:-prod}
TZ: ${TZ:-Europe/Helsinki} TZ: ${TZ:-Europe/Helsinki}
# No published ports: Traefik reaches the container over the shared # No published ports: Traefik reaches the container over the shared
@@ -26,7 +22,7 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.foodster.entrypoints=websecure - traefik.http.routers.foodster.entrypoints=websecure
- traefik.http.routers.foodster.rule=Host(`${HOST:?set HOST in .env}`) - traefik.http.routers.foodster.rule=Host(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`)
- traefik.http.routers.foodster.tls=true - traefik.http.routers.foodster.tls=true
- traefik.http.services.foodster.loadbalancer.server.port=8080 - traefik.http.services.foodster.loadbalancer.server.port=8080
- traefik.docker.network=traefik - traefik.docker.network=traefik
-145
View File
@@ -1,145 +0,0 @@
<!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>
+26 -158
View File
@@ -16,7 +16,7 @@ trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
go build -o "$tmp/foodster" ./cmd/foodster go build -o "$tmp/foodster" ./cmd/foodster
PASSWORD="$pass" DB="$tmp/smoke.db" ADDR="$addr" \ FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \
"$tmp/foodster" >"$tmp/server.log" 2>&1 & "$tmp/foodster" >"$tmp/server.log" 2>&1 &
srv=$! srv=$!
@@ -43,16 +43,6 @@ check() {
fi 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" echo "smoke: http://$addr"
check "unauthenticated request is refused" \ check "unauthenticated request is refused" \
@@ -72,8 +62,6 @@ check "theme script is served" \
home=$(curl -s -u ":$pass" "http://$addr/") home=$(curl -s -u ":$pass" "http://$addr/")
check "the header carries the brand" "$home" "Foodster" check "the header carries the brand" "$home" "Foodster"
# ENV is unset here, so this instance is production and unmarked.
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">' check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
check "the theme toggle is present" "$home" "data-theme-toggle" check "the theme toggle is present" "$home" "data-theme-toggle"
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"' check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
@@ -87,40 +75,35 @@ check "manifest has the right content type" \
"application/manifest+json" "application/manifest+json"
check "catalog starts empty" \ check "catalog starts empty" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa" "$(curl -s -u ":$pass" "http://$addr/ruoat")" "0 pääruokaa"
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti") out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0" 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 "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
check "re-import refuses duplicates" \ check "re-import refuses duplicates" \
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \ "$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")" \
"jo listalla" "jo listalla"
check "pasted JSON imports" \ check "pasted JSON imports" \
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \ "$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
"http://$addr/ruuat/tuonti")" "Lisätty 1" "http://$addr/ruoat/tuonti")" "Lisätty 1"
check "unknown category is reported" \ check "unknown category is reported" \
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \ "$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
"http://$addr/ruuat/tuonti")" "tuntematon kategoria" "http://$addr/ruoat/tuonti")" "tuntematon kategoria"
check "empty submit is explained" \ check "empty submit is explained" \
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa" "$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruoat/tuonti")" "Ei tuotavaa"
check "malformed JSON is explained" \ check "malformed JSON is explained" \
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa" "$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa"
# ---- the log flow, against the dishes imported above -------------------- # ---- the log flow, against the dishes imported above --------------------
board=$(curl -s -u ":$pass" "http://$addr/") board=$(curl -s -u ":$pass" "http://$addr/")
check "board lists imported dishes" "$board" "Lihapullat" check "board lists imported dishes" "$board" "Lihapullat"
# Tähteet is loggable but is not food: on the board, never in the catalog.
check "leftovers are on the board" "$board" "Tähteet"
refute "leftovers are not in the catalog" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet"
# Pull a real dish id out of the board rather than assuming one. # Pull a real dish id out of the board rather than assuming one.
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2) ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$ruoka" ]; then if [ -z "$ruoka" ]; then
@@ -139,39 +122,8 @@ check "saving redirects back to the day" \
check "the saved day shows what was eaten" \ check "the saved day shows what was eaten" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu" "$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
# The selected day expands inside the list rather than in a panel above it, check "history lists the entry" \
# so the rows below do not shift when one is tapped. "$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu"
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
check "the selected day expands in place" "$day" 'class="open"'
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
# ---- the day list patches in place instead of navigating ----------------
dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=2026-09-05")
check "opening a day patches the list" "$dayp" 'id="paivat"'
refute "and returns a fragment, not a page" "$dayp" "<html"
check "picking a dish patches to the sides step" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/paiva?pvm=2026-09-05&ruoka=$ruoka")" "Tallenna"
check "saving from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
check "deleting from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04" "http://$addr/poista")" 'id="paivat"'
# Without the header it must still redirect, for no JavaScript.
check "a plain save still redirects to the day" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=2026-09-04"
# 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" \ check "deleting redirects back" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
@@ -183,43 +135,6 @@ check "the day is empty again" \
check "search filters the board" \ check "search filters the board" \
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto" "$(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 -------------------------------- # ---- adding a dish without leaving Kirjaa --------------------------------
miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste") miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste")
@@ -240,86 +155,39 @@ check "the quick-added dish is on the board" \
# ---- catalog CRUD from the UI ------------------------------------------- # ---- catalog CRUD from the UI -------------------------------------------
# Assert where it redirects, not just that it does: these pointed at the old check "adding a main redirects" \
# /ruoat spelling for a while and every 303-only check was happy. "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
check "adding a main redirects back to the catalog" \ -d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruoat/paaruoka")" "303"
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat") catalog=$(curl -s -u ":$pass" "http://$addr/ruoat")
check "the new main is listed, sentence-cased" "$catalog" "Uunikala" check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
check "a duplicate name is refused" \ check "a duplicate name is refused" \
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
"Nimi on jo listalla." "Nimi on jo listalla."
check "a main with no category is refused" \ check "a main with no category is refused" \
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \ "$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruoat/paaruoka")" \
"Valitse vähintään yksi kategoria." "Valitse vähintään yksi kategoria."
check "a nameless dish is refused" \ check "a nameless dish is refused" \
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
"Anna nimi." "Anna nimi."
check "adding a side redirects back to the catalog" \ check "adding a side redirects" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \ -d 'nimi=lohkoperunat' "http://$addr/ruoat/lisuke")" "303"
"/ruuat"
check "the new side is listed" \ check "the new side is listed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat" "$(curl -s -u ":$pass" "http://$addr/ruoat")" "Lohkoperunat"
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so uusi=$(printf '%s' "$catalog" | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
# 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" \ check "the edit form is prefilled" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa" "$(curl -s -u ":$pass" "http://$addr/ruoat?muokkaa=$uusi")" "Muokkaa pääruokaa"
# A bin icon is easy to hit by accident, so the row asks before anything goes. check "deleting a main redirects" \
check "the bin asks before deleting" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?" -d "id=$uusi&tyyppi=paa" "http://$addr/ruoat/poista")" "303"
check "the dish is still there while it asks" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
# ---- the catalog patches in place instead of navigating -----------------
# A delete confirmation halfway down a long list must not send the browser
# back to the top, so these answer with a Datastar patch rather than a page.
patch=$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
check "the patch carries the list" "$patch" 'id="ruokalista"'
check "and both forms, so an open one closes" "$patch" 'id="paaruoka"'
check "the row it patches in is asking" "$patch" "Poista?"
check "patches are served as an event stream" \
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta")" "text/event-stream"
check "deleting from Datastar patches too" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
"event: datastar-patch-elements"
refute "and the dish is gone from the patched list" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
"Uunikala"
# Without the header it must still be an ordinary redirect, for no JavaScript.
check "a plain form post still redirects" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
refute "the dish is gone once confirmed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
if [ "$fail" -ne 0 ]; then if [ "$fail" -ne 0 ]; then
echo "smoke: FAILED" echo "smoke: FAILED"
+8 -45
View File
@@ -1,56 +1,19 @@
{ {
"mains": [ "mains": [
{"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true},
{"name": "Kebab", "categories": ["meat"], "has_sides": true},
{"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false},
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
{"name": "Lihapullat/pihvit", "categories": ["meat"], "has_sides": true},
{"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Makaronimössö", "categories": ["meat"], "has_sides": false},
{"name": "Maksalaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Nachopelti", "categories": ["meat"], "has_sides": false},
{"name": "Nakkikeitto", "categories": ["meat"], "has_sides": false},
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
{"name": "Possunsuikalekastike", "categories": ["meat"], "has_sides": true},
{"name": "Possurisotto", "categories": ["meat"], "has_sides": false},
{"name": "Pyttipannu", "categories": ["meat"], "has_sides": false},
{"name": "Uuniliha", "categories": ["meat"], "has_sides": true},
{"name": "Uunimakkara", "categories": ["meat"], "has_sides": true},
{"name": "Broilerin koipireidet", "categories": ["chicken"], "has_sides": true},
{"name": "Kanakastike", "categories": ["chicken"], "has_sides": true},
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
{"name": "Kanamakaronilaatikko", "categories": ["chicken"], "has_sides": false},
{"name": "Kanapasta", "categories": ["chicken"], "has_sides": false},
{"name": "Kanarisotto", "categories": ["chicken"], "has_sides": false},
{"name": "Kalakeitto", "categories": ["fish"], "has_sides": false},
{"name": "Lohicuscus-salaatti", "categories": ["fish"], "has_sides": false},
{"name": "Lohipyörykät", "categories": ["fish"], "has_sides": true},
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true}, {"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
{"name": "Uuniperunat (lohitäytteellä)", "categories": ["fish"], "has_sides": false}, {"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Italianpata (lihaton)", "categories": ["vegetarian"], "has_sides": true},
{"name": "Kasvispihvit", "categories": ["vegetarian"], "has_sides": true},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Pinaattiletut", "categories": ["vegetarian"], "has_sides": false},
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false}, {"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
{"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false} {"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false}
], ],
"sides": [ "sides": [
{"name": "Keitetyt perunat"}, {"name": "Keitetyt perunat"},
{"name": "Lohkoperunat"},
{"name": "Muusi"},
{"name": "Pasta"},
{"name": "Ranskalaiset"}, {"name": "Ranskalaiset"},
{"name": "Lohkoperunat"},
{"name": "Muussi"},
{"name": "Riisi"}, {"name": "Riisi"},
{"name": "Spagetti"}, {"name": "Pasta"}
{"name": "Tillikastike"},
{"name": "Wokkivihannekset"}
] ]
} }