Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8810d288e | ||
|
|
174652778b | ||
|
|
e9754488db |
+14
-7
@@ -1,22 +1,29 @@
|
|||||||
# 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. FOODSTER_REPO carries no tag.
|
# Image coordinates. REPO carries no tag.
|
||||||
FOODSTER_REPO=registry.example.com/you/foodster
|
REPO=registry.example.com/you/foodster
|
||||||
FOODSTER_TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
# Shared household password. The app will not start without it.
|
# Shared household password. The app will not start without it.
|
||||||
FOODSTER_PASSWORD=changeme
|
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.
|
||||||
FOODSTER_HOST=foodster.example.com
|
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
|
#
|
||||||
FOODSTER_GID=1000
|
# Named PUID/PGID because UID is read-only in bash and a plain UID here would
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 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.
|
||||||
@@ -5,6 +5,11 @@ 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
|
||||||
@@ -33,7 +38,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)
|
||||||
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG)
|
PASSWORD=$${PASSWORD:-dev} ENV=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)
|
||||||
@@ -80,22 +85,55 @@ 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 "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
|
@test -n "$(REPO)" || { echo "set 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 "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" .
|
-t "$(REPO):$$tag" -t "$(REPO):latest" . ; \
|
||||||
|
echo "$$tag" > $(TAGFILE)
|
||||||
|
|
||||||
push: ## Push the newest tag and :latest
|
# Pushing reported success while uploading the previous release once, because
|
||||||
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
|
# nothing compared what was built against what arrived. So afterwards, ask the
|
||||||
@tag=$$(git tag -l 'v*' --sort=-creatordate | head -n1); \
|
# registry what it actually serves for each tag and fail if it is not the
|
||||||
test -n "$$tag" || { echo "no tags yet - run make image"; exit 1; }; \
|
# image we just built.
|
||||||
podman push "$(FOODSTER_REPO):$$tag"; \
|
push: ## Push the newest tag and :latest, then verify the registry
|
||||||
podman push "$(FOODSTER_REPO):latest"
|
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
|
||||||
|
@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
|
||||||
|
|
||||||
release: image push ## Build, tag and push in one go
|
# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -113,6 +113,23 @@ 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
|
||||||
@@ -321,7 +338,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 Ruoat tab — paste JSON or upload a file, get a per-row report
|
app, on the Ruuat tab — paste JSON or upload a file, get a per-row report
|
||||||
back. A plain multipart form rather than a Datastar round trip, since the
|
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
|
||||||
@@ -334,7 +351,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
|
||||||
`FOODSTER_PASSWORD`; the username is ignored. Compared using
|
`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
|
||||||
@@ -360,7 +377,10 @@ 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 merges into `main` when a release is cut.
|
on `dev`, and `main` is protected on the remote: it accepts no direct
|
||||||
|
pushes, so a release arrives as a pull request from `dev`. `make image`
|
||||||
|
additionally refuses to run outside `main` — that one has to be local,
|
||||||
|
because the tag and the image are made before anything reaches the remote.
|
||||||
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that
|
- **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
|
||||||
@@ -375,21 +395,25 @@ 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 `FOODSTER_UID`/`FOODSTER_GID`
|
runs as UID 65534, compose sets `user:` from `PUID`/`PGID`
|
||||||
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`):
|
||||||
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates.
|
Names carry no application prefix: the container namespaces them already.
|
||||||
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to
|
- `REPO` and `TAG` — image coordinates.
|
||||||
start without it.
|
- `PASSWORD` — the shared password. Required; the app refuses to start
|
||||||
- `FOODSTER_DB` — database file path, default `./data/foodster.db`. The
|
without it.
|
||||||
directory is created on startup if missing.
|
- `DB` — database file path, default `./data/foodster.db`. The directory is
|
||||||
- `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`.
|
created on startup if missing.
|
||||||
|
- `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
|
||||||
`FOODSTER_HOST` and terminating TLS. The container publishes no ports —
|
`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.
|
||||||
|
|||||||
@@ -17,18 +17,29 @@ weeks of real history to weight against.
|
|||||||
|
|
||||||
Working:
|
Working:
|
||||||
|
|
||||||
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized
|
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered
|
||||||
by how often they are eaten. Edit or delete the day's entry.
|
and sized by how often they are eaten, so the likely answer is the biggest
|
||||||
- **Historia** — every day back to the first entry, with unlogged days shown
|
target. The history sits on the same page underneath: every day back to the
|
||||||
as explicit gaps.
|
first entry, unlogged days shown as explicit gaps, and every row a link that
|
||||||
- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle
|
loads that day into the logger above it.
|
||||||
by paste or file upload. Deletes are soft, so old log entries keep showing
|
- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
|
||||||
the dish they used.
|
by paste or file upload. Grouped by category and alphabetical inside, since
|
||||||
|
this is a list you manage rather than one you pick from. Deletes are soft,
|
||||||
|
so old log entries keep showing the dish they used.
|
||||||
- **Light / dark**, remembered per device, dark by default. The button shows
|
- **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.
|
||||||
|
|
||||||
@@ -47,19 +58,37 @@ 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.
|
`main`, so its history is the deployment history. **Nothing is committed to
|
||||||
|
`main` directly** — it moves only by fast-forwarding `dev` into it.
|
||||||
|
|
||||||
All development happens on `dev`. Merge into `main` when cutting a release,
|
All development happens on `dev`. `main` is protected on the remote: it takes
|
||||||
then build and push the image from there.
|
no direct pushes, so a release arrives through a pull request.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git switch dev # where the work happens
|
git switch dev # where the work happens
|
||||||
git switch main && git merge dev && make release
|
# ... commits ...
|
||||||
|
make check # lint, unit tests, smoke
|
||||||
|
git push origin dev
|
||||||
|
|
||||||
|
tea pr create --base main --head dev # or open it in the forge
|
||||||
|
# merge the pull request, then:
|
||||||
|
|
||||||
|
git switch main && git pull --ff-only
|
||||||
|
make release # builds, tags vYYYYMMDD-N, pushes the image
|
||||||
|
git push origin --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make image` additionally refuses to run from any branch but `main`, so a
|
||||||
|
release tag can never point at a commit that was not released. That check
|
||||||
|
lives locally because it has to: tags and images are built before anything
|
||||||
|
reaches the remote, so protection there cannot catch it.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -84,7 +113,7 @@ make up/down/logs compose
|
|||||||
|
|
||||||
## Importing dishes
|
## Importing dishes
|
||||||
|
|
||||||
The **Ruoat** tab takes a bundle of mains and sides: paste the JSON or upload
|
The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload
|
||||||
a file, and the app reports row by row what it did.
|
a file, and the app reports row by row what it did.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -147,13 +176,19 @@ Everything is environment variables. `.env` is gitignored; start from
|
|||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. |
|
| `PASSWORD` | *required* | Shared password. The app will not start without it. |
|
||||||
| `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
|
| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
|
||||||
| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. |
|
| `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). |
|
||||||
|
| `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. |
|
|
||||||
| `FOODSTER_TAG` | `latest` | Tag to run under compose. |
|
Names carry no prefix: the container gives them their own namespace already.
|
||||||
| `FOODSTER_PORT` | `8080` | Host port to publish. |
|
`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set
|
||||||
|
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.
|
||||||
@@ -178,9 +213,19 @@ 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 `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
|
creates it, and `PUID`/`PGID` 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
|
||||||
|
database file (14)`, the ownership does not match. Docker creates a missing
|
||||||
|
bind-mount directory as root, and the container is not root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -ldn data # whose is it?
|
||||||
|
sudo chown -R 1000:1000 data # match PUID / PGID
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Access is a single shared password over HTTP Basic — no accounts, no
|
Access is a single shared password over HTTP Basic — no accounts, no
|
||||||
@@ -199,7 +244,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 `FOODSTER_PASSWORD`.** Rate limiting removes
|
**None of this replaces a strong `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
|
||||||
|
|||||||
@@ -131,6 +131,55 @@ 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")
|
||||||
@@ -138,7 +187,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)
|
||||||
}
|
}
|
||||||
|
|||||||
+354
-61
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -17,9 +18,27 @@ import (
|
|||||||
// kilobytes; a megabyte is already absurd generosity.
|
// kilobytes; a megabyte is already absurd generosity.
|
||||||
const maxUpload = 1 << 20
|
const maxUpload = 1 << 20
|
||||||
|
|
||||||
// historyDays is how far back the Historia list walks. Long enough to see a
|
const (
|
||||||
// couple of months, short enough to stay one scroll.
|
// historyDays is one window of the history under the logger, and the step
|
||||||
const historyDays = 60
|
// that "show more" grows it by. Older days arrive a window at a time
|
||||||
|
// rather than all at once.
|
||||||
|
historyDays = 30
|
||||||
|
|
||||||
|
// maxHistoryDays caps what a hand-edited URL can ask for, so ?paivat=
|
||||||
|
// cannot be turned into a request to render a decade of rows.
|
||||||
|
maxHistoryDays = 366 * 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyWindow reads ?paivat=, the number of days of history to show.
|
||||||
|
func historyWindow(r *http.Request) int {
|
||||||
|
days := historyDays
|
||||||
|
if raw := r.URL.Query().Get("paivat"); raw != "" {
|
||||||
|
if n, err := strconv.Atoi(raw); err == nil && n > days {
|
||||||
|
days = min(n, maxHistoryDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -36,35 +55,98 @@ 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 today(a.loc)
|
return now
|
||||||
}
|
}
|
||||||
|
|
||||||
// logView is everything the Kirjaa screen needs.
|
// logView is everything the log screen needs. Logging and history are one
|
||||||
|
// page: every history row was already a link back into the logger, and the
|
||||||
|
// day switcher made them two views of the same thing.
|
||||||
type logView struct {
|
type logView struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
Today time.Time
|
Today time.Time
|
||||||
Search string
|
Search string
|
||||||
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
|
||||||
Dishes []Dish
|
ShowBoard bool
|
||||||
Sides []Side
|
Dishes []Dish // flat, only to know whether anything matched
|
||||||
New mainForm // inline "add the dish you were looking for"
|
Groups []DishGroup // what the board actually renders
|
||||||
|
Special []Dish // Tähteet and the like: loggable, but not food
|
||||||
|
Sides []Side
|
||||||
|
New mainForm // inline "add the dish you were looking for"
|
||||||
|
History HistoryPage
|
||||||
|
HistoryDays int // size of the window currently shown
|
||||||
|
HistoryMore int // the window size the "show more" link asks for
|
||||||
|
|
||||||
|
// Deleting a logged meal drops the row outright, unlike a dish which is
|
||||||
|
// only soft-deleted, so it asks first.
|
||||||
|
Confirming bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
date := a.date(r)
|
render(w, r, logPage(a.buildLog(r, a.logOptionsFrom(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: strings.TrimSpace(r.URL.Query().Get("haku")),
|
Search: o.Search,
|
||||||
Checked: map[int64]bool{},
|
Checked: map[int64]bool{},
|
||||||
|
Confirming: o.Confirming,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := entryFor(a.db, date)
|
entry, err := entryFor(a.db, date)
|
||||||
@@ -76,8 +158,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ?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 raw := r.URL.Query().Get("ruoka"); raw != "" {
|
if o.Dish != "" {
|
||||||
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
if id, err := strconv.ParseInt(o.Dish, 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 {
|
||||||
@@ -89,21 +171,182 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.Chosen == nil && v.Entry == nil {
|
// The board shows when there is nothing logged yet, or when the entry is
|
||||||
|
// being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here,
|
||||||
|
// so swapping the dish and picking one for the first time are one path.
|
||||||
|
changing := 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render(w, r, logPage(v))
|
a.loadDays(r, &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
|
||||||
@@ -141,6 +384,12 @@ 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
|
||||||
}
|
}
|
||||||
@@ -148,16 +397,11 @@ 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 := logView{
|
v := a.buildLog(r, logOptions{Date: date, Search: form.Name})
|
||||||
Date: date,
|
v.New = form
|
||||||
Today: today(a.loc),
|
if isDatastar(r) {
|
||||||
Search: form.Name,
|
fragment(w, r, dayList(v))
|
||||||
Checked: map[int64]bool{},
|
return
|
||||||
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))
|
||||||
}
|
}
|
||||||
@@ -193,7 +437,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.redirectToDay(w, r, date)
|
a.finishDay(w, r, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -203,23 +447,12 @@ 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.redirectToDay(w, r, date)
|
a.finishDay(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) {
|
||||||
target := "/"
|
http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther)
|
||||||
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
|
||||||
@@ -239,16 +472,37 @@ type sideForm struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type catalogView struct {
|
type catalogView struct {
|
||||||
Mains []Dish
|
Groups []DishGroup
|
||||||
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.
|
||||||
@@ -274,25 +528,47 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if raw := r.URL.Query().Get("poista"); raw != "" {
|
||||||
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
v.DeleteID = id
|
||||||
|
v.DeleteKind = r.URL.Query().Get("tyyppi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.renderCatalog(w, r, v)
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
|
// fillCatalog loads the lists into a view built from the request.
|
||||||
|
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{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
mains, err := listDishes(a.db, v.Search)
|
||||||
if v.Mains, err = listDishes(a.db, ""); err != nil {
|
if err != nil {
|
||||||
log.Printf("list mains: %v", err)
|
log.Printf("list mains: %v", err)
|
||||||
}
|
}
|
||||||
if v.Sides, err = listSides(a.db); err != nil {
|
v.Mains = len(mains)
|
||||||
|
sortByName(mains) // the catalog is managed, so position should be predictable
|
||||||
|
v.Groups = groupDishes(mains)
|
||||||
|
|
||||||
|
if v.Sides, err = listSides(a.db, v.Search); err != nil {
|
||||||
log.Printf("list sides: %v", err)
|
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) {
|
||||||
@@ -333,11 +609,28 @@ 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:
|
||||||
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
// Saved: hand back a blank form so it collapses, and a list with
|
||||||
|
// the dish in it.
|
||||||
|
a.finishCatalog(w, r, catalogView{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.renderCatalog(w, r, catalogView{Main: form})
|
a.finishCatalog(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) {
|
||||||
@@ -362,11 +655,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:
|
||||||
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
a.finishCatalog(w, r, catalogView{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.renderCatalog(w, r, catalogView{Side: form})
|
a.finishCatalog(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).
|
||||||
@@ -390,7 +683,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
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
a.finishCatalog(w, r, catalogView{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
+53
-16
@@ -35,6 +35,21 @@ 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"
|
||||||
@@ -55,7 +70,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("FOODSTER_DB"), defaultDB))
|
db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,9 +81,9 @@ func run() error {
|
|||||||
return runImport(db, *importPath)
|
return runImport(db, *importPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
password := os.Getenv("FOODSTER_PASSWORD")
|
password := os.Getenv("PASSWORD")
|
||||||
if password == "" {
|
if password == "" {
|
||||||
return errors.New("FOODSTER_PASSWORD is not set")
|
return errors.New("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
|
||||||
@@ -79,9 +94,11 @@ func run() error {
|
|||||||
return fmt.Errorf("TZ: %w", err)
|
return fmt.Errorf("TZ: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The container always publishes :8080; FOODSTER_ADDR exists so tests and
|
setEnvTag(os.Getenv("ENV"))
|
||||||
// a second local instance can pick another port.
|
|
||||||
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
|
// The container always publishes :8080; ADDR exists so tests and a second
|
||||||
|
// local instance can pick another port.
|
||||||
|
addr := cmp.Or(os.Getenv("ADDR"), listenAddr)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
@@ -128,6 +145,17 @@ func openDB(path string) (*sql.DB, error) {
|
|||||||
// sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend.
|
// sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend.
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
// sql.Open is lazy, so without this the first failure surfaces from
|
||||||
|
// whatever query ran first and says nothing useful. The usual cause is a
|
||||||
|
// bind-mounted directory owned by a different user than the container
|
||||||
|
// runs as, so name the path and the uid.
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"cannot open %s as uid %d gid %d: %w (is that directory writable by this user?)",
|
||||||
|
path, os.Getuid(), os.Getgid(), err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := migrate(db); err != nil {
|
if err := migrate(db); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -147,13 +175,16 @@ 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 /historia", a.history)
|
mux.HandleFunc("GET /ruuat", a.catalog)
|
||||||
mux.HandleFunc("GET /ruoat", a.catalog)
|
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
|
||||||
mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
|
mux.HandleFunc("GET /ruuat/nayta", a.show)
|
||||||
mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
|
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
|
||||||
mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
|
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
|
||||||
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
||||||
|
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
|
||||||
|
|
||||||
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
||||||
root := http.NewServeMux()
|
root := http.NewServeMux()
|
||||||
@@ -202,11 +233,17 @@ 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. Every date in
|
// today is the current calendar day in the configured location, truncated to
|
||||||
// this app goes through here rather than time.Local, which would be UTC
|
// midnight. Every date in this app goes through here rather than time.Local,
|
||||||
// whenever TZ is unset and quietly shift evening entries to the day before.
|
// which would be UTC whenever TZ is unset and quietly shift evening entries to
|
||||||
|
// the day before.
|
||||||
|
//
|
||||||
|
// The truncation matters: dates parsed from ?pvm= are midnight, so a today
|
||||||
|
// carrying a time of day would never compare equal to one of them, and the UI
|
||||||
|
// would stop recognising today as today the moment the date was explicit.
|
||||||
func today(loc *time.Location) time.Time {
|
func today(loc *time.Location) time.Time {
|
||||||
return time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeName collapses whitespace and capitalises the first letter for
|
// normalizeName collapses whitespace and capitalises the first letter for
|
||||||
|
|||||||
+107
-7
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -30,6 +31,103 @@ 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 {
|
||||||
@@ -171,14 +269,16 @@ func TestMealLogOneEntryPerDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil {
|
// Ids well clear of anything the migrations create.
|
||||||
|
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', 1)`); err != nil {
|
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); 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', 2)`); err == nil {
|
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 102)`); 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,18 +290,18 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil {
|
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (101, '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 (2, 'kanacurry')`); err == nil {
|
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, '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 = 1`); err != nil {
|
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 101`); 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 (2, 'kanacurry')`); err != nil {
|
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err != nil {
|
||||||
t.Errorf("name still blocked after soft delete: %v", err)
|
t.Errorf("name still blocked after soft delete: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 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);
|
||||||
+131
-22
@@ -20,6 +20,11 @@
|
|||||||
--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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +48,15 @@ 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;
|
||||||
@@ -84,9 +93,7 @@ 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 {
|
||||||
background: var(--paper);
|
padding: 16px 16px 4px;
|
||||||
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); }
|
||||||
@@ -116,21 +123,19 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
}
|
}
|
||||||
.tabbar a[aria-current] { color: var(--accent); }
|
.tabbar a[aria-current] { color: var(--accent); }
|
||||||
|
|
||||||
.dot {
|
/* Category marks carry colour and shape together, so they are readable
|
||||||
width: 9px;
|
without having learned which hue means what. */
|
||||||
height: 9px;
|
.cat { flex: none; display: block; }
|
||||||
border-radius: 2px;
|
.cat svg { display: block; width: 16px; height: 16px; }
|
||||||
flex: none;
|
.pill.xl .cat svg { width: 19px; height: 19px; }
|
||||||
display: inline-block;
|
.logged .cat svg { width: 22px; height: 22px; }
|
||||||
}
|
|
||||||
.d-liha { background: var(--liha); }
|
.c-liha { color: var(--liha); }
|
||||||
.d-kana { background: var(--kana); }
|
.c-kana { color: var(--kana); }
|
||||||
.d-kala { background: var(--kala); }
|
.c-kala { color: var(--kala); }
|
||||||
.d-kasvis { background: var(--kasvis); }
|
.c-kasvis { color: var(--kasvis); }
|
||||||
.d-sek {
|
/* Not a category, so not a category colour. */
|
||||||
background: conic-gradient(var(--liha) 0 25%, var(--kana) 25% 50%,
|
.c-tahteet { color: var(--muted); }
|
||||||
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; }
|
||||||
@@ -205,6 +210,20 @@ 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; }
|
||||||
@@ -307,6 +326,26 @@ 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;
|
||||||
@@ -314,6 +353,27 @@ 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 {
|
||||||
@@ -397,15 +457,52 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Catalog rows */
|
/* Catalog structure: Pääruuat and Lisukkeet are the two halves of the
|
||||||
|
catalog, the categories are subdivisions of the first. Two levels, so they
|
||||||
|
must not look alike. */
|
||||||
|
.section + .section { margin-top: 34px; }
|
||||||
|
.sectiontitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.sectiontitle .count {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--sunk);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sechead {
|
.sechead {
|
||||||
margin: 26px 0 6px;
|
margin: 20px 0 2px;
|
||||||
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;
|
||||||
@@ -417,12 +514,14 @@ 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: 4px; flex: none; }
|
.rowactions { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||||
.rowactions a, .rowactions button {
|
.rowactions a, .rowactions button {
|
||||||
|
min-width: 40px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 0 10px;
|
padding: 0 8px;
|
||||||
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;
|
||||||
@@ -432,9 +531,19 @@ 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);
|
||||||
|
|||||||
+116
-28
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,13 +28,24 @@ type Dish struct {
|
|||||||
TimesEaten int
|
TimesEaten int
|
||||||
}
|
}
|
||||||
|
|
||||||
// CategoryKey is the class suffix for the colour dot. A dish covering several
|
// Rows flagged `special` in the database — Tähteet — are loggable but are not
|
||||||
// categories (tortillas, build-your-own pizza) gets the mixed marker.
|
// food. They carry no category, never appear in the catalog, and PRD §8
|
||||||
|
// 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 {
|
||||||
if len(d.Categories) == 1 {
|
switch len(d.Categories) {
|
||||||
|
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
|
||||||
@@ -76,6 +88,17 @@ 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)
|
||||||
@@ -84,8 +107,9 @@ func listDishes(db *sql.DB, search string) ([]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`, search, search)
|
ORDER BY 5 DESC, m.name`, special, search, search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -127,9 +151,12 @@ func dishByID(db *sql.DB, id int64) (*Dish, error) {
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listSides(db *sql.DB) ([]Side, error) {
|
func listSides(db *sql.DB, search string) ([]Side, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(`
|
||||||
`SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`)
|
SELECT id, name FROM side_dishes
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (? = '' OR lower(name) LIKE '%' || lower(?) || '%')
|
||||||
|
ORDER BY name`, search, search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -341,42 +368,103 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// history walks back day by day from today, so a day nobody wrote down shows
|
// HistoryPage is one window of history plus where to continue from. After a
|
||||||
// up as an explicit gap rather than silently missing. It stops at the first
|
// few years of daily entries the whole log is far too much to render at once.
|
||||||
// entry ever recorded — before that there is no history to be missing.
|
type HistoryPage struct {
|
||||||
func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) {
|
Rows []HistoryRow
|
||||||
var first string
|
More bool // older entries exist beyond this window
|
||||||
err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first)
|
Next time.Time // the day the next window starts at
|
||||||
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 err != nil {
|
if !first.Valid || first.String == "" {
|
||||||
return nil, err
|
return HistoryPage{}, nil
|
||||||
}
|
}
|
||||||
firstDate, err := time.ParseInLocation(dateLayout, first, loc)
|
firstDate, err := time.ParseInLocation(dateLayout, first.String, loc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return HistoryPage{}, err
|
||||||
|
}
|
||||||
|
if from.Before(firstDate) {
|
||||||
|
return HistoryPage{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := today(loc)
|
oldest := from.AddDate(0, 0, -days+1)
|
||||||
oldest := now.AddDate(0, 0, -days)
|
page := HistoryPage{More: true}
|
||||||
if firstDate.After(oldest) {
|
if !firstDate.Before(oldest) {
|
||||||
oldest = firstDate
|
oldest = firstDate
|
||||||
|
page.More = false
|
||||||
}
|
}
|
||||||
|
page.Next = oldest.AddDate(0, 0, -1)
|
||||||
|
|
||||||
var rows []HistoryRow
|
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
|
||||||
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 nil, err
|
return HistoryPage{}, err
|
||||||
}
|
}
|
||||||
rows = append(rows, HistoryRow{Date: d, Entry: entry})
|
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry})
|
||||||
}
|
}
|
||||||
return rows, nil
|
return page, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,10 +235,11 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
|
|||||||
t.Fatalf("save -3: %v", err)
|
t.Fatalf("save -3: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := history(h.db, loc, 60)
|
page, err := history(h.db, loc, now, 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))
|
||||||
@@ -252,16 +253,66 @@ 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)
|
||||||
|
|
||||||
rows, err := history(h.db, time.UTC, 60)
|
page, err := history(h.db, time.UTC, today(time.UTC), 60)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("history: %v", err)
|
t.Fatalf("history: %v", err)
|
||||||
}
|
}
|
||||||
if len(rows) != 0 {
|
if len(page.Rows) != 0 {
|
||||||
t.Errorf("%d rows for an empty log, want 0", len(rows))
|
t.Errorf("%d rows for an empty log, want 0", len(page.Rows))
|
||||||
|
}
|
||||||
|
if page.More {
|
||||||
|
t.Error("More is set although there is no history at all")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+514
-145
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,6 +43,23 @@ 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 {
|
||||||
@@ -51,6 +69,36 @@ 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))
|
||||||
@@ -60,6 +108,16 @@ 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 {
|
||||||
@@ -78,9 +136,11 @@ 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"/>
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ECEDE8"/>
|
// Matches --header in app.css so the browser chrome continues the
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121316"/>
|
// header rather than butting against it.
|
||||||
<title>{ title }</title>
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
|
||||||
|
<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
|
||||||
@@ -109,9 +169,6 @@ 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.
|
||||||
@@ -142,11 +199,105 @@ 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("/historia", "Historia", current)
|
@tab("/ruuat", "Ruuat", current)
|
||||||
@tab("/ruoat", "Ruoat", current)
|
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,24 +318,85 @@ templ logPage(v logView) {
|
|||||||
@daySwitch(v)
|
@daySwitch(v)
|
||||||
</header>
|
</header>
|
||||||
<main class="pad">
|
<main class="pad">
|
||||||
switch {
|
@dayList(v)
|
||||||
case v.Chosen != nil:
|
|
||||||
@sidesStep(v)
|
|
||||||
case v.Entry != nil:
|
|
||||||
@loggedCard(v)
|
|
||||||
default:
|
|
||||||
@board(v)
|
|
||||||
}
|
|
||||||
</main>
|
</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 {
|
||||||
|
case v.Chosen != nil:
|
||||||
|
@sidesStep(v)
|
||||||
|
case v.ShowBoard:
|
||||||
|
@board(v)
|
||||||
|
default:
|
||||||
|
@loggedCard(v)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} 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) {
|
||||||
<div class="dayseg">
|
<div class="dayseg">
|
||||||
@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">
|
||||||
<input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/>
|
// max stops the picker offering days that have not happened yet;
|
||||||
|
// the server clamps anyway, this just avoids the dead end.
|
||||||
|
<input type="date" name="pvm" value={ isoDate(v.Date) } max={ isoDate(v.Today) } aria-label="Muu päivä"/>
|
||||||
<button type="submit">Näytä</button>
|
<button type="submit">Näytä</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,23 +410,73 @@ 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) {
|
||||||
<form method="get" action="/" class="searchrow">
|
<div data-signals:haku={ jsString(v.Search) }>
|
||||||
if !v.Date.Equal(v.Today) {
|
<form method="get" action="/" class="searchrow">
|
||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
if !v.Date.Equal(v.Today) {
|
||||||
}
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
|
|
||||||
</form>
|
|
||||||
if len(v.Dishes) > 0 {
|
|
||||||
<div class="board">
|
|
||||||
for _, d := range v.Dishes {
|
|
||||||
@dishPill(d, v)
|
|
||||||
}
|
}
|
||||||
</div>
|
<input
|
||||||
}
|
class="filter"
|
||||||
if len(v.Dishes) == 0 {
|
type="search"
|
||||||
@quickAddCard(v)
|
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>
|
||||||
|
@boardList(v)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// boardList is what Datastar patches: it carries the id, so a plain text/html
|
||||||
|
// response is matched to it and swapped in place.
|
||||||
|
templ boardList(v logView) {
|
||||||
|
<div id="lauta">
|
||||||
|
// Grouped by category, and inside each group the most-eaten first — so
|
||||||
|
// a dish keeps a predictable neighbourhood while favourites still
|
||||||
|
// surface at the top of it.
|
||||||
|
for _, g := range v.Groups {
|
||||||
|
<h3 class="sechead">{ g.Label }</h3>
|
||||||
|
<div class="board">
|
||||||
|
for _, d := range g.Dishes {
|
||||||
|
@dishPill(d, v)
|
||||||
|
}
|
||||||
|
</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 {
|
||||||
|
@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.
|
||||||
@@ -225,7 +487,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 Ruoat-välilehdeltä.
|
Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä.
|
||||||
</p>
|
</p>
|
||||||
} else {
|
} else {
|
||||||
<h3>Ei osumia. Lisätäänkö?</h3>
|
<h3>Ei osumia. Lisätäänkö?</h3>
|
||||||
@@ -233,7 +495,11 @@ 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 method="post" action="/lisaa">
|
<form
|
||||||
|
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>
|
||||||
@@ -264,9 +530,10 @@ 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(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
|
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)) + "')" }
|
||||||
>
|
>
|
||||||
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
@categoryIcon(d.CategoryKey())
|
||||||
{ 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>
|
||||||
@@ -277,10 +544,14 @@ templ dishPill(d Dish, v logView) {
|
|||||||
templ sidesStep(v logView) {
|
templ sidesStep(v logView) {
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>
|
<h3>
|
||||||
<i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i>
|
@categoryIcon(v.Chosen.CategoryKey())
|
||||||
{ v.Chosen.Name }
|
{ v.Chosen.Name }
|
||||||
</h3>
|
</h3>
|
||||||
<form method="post" action="/kirjaa">
|
<form
|
||||||
|
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 {
|
||||||
@@ -302,7 +573,11 @@ templ sidesStep(v logView) {
|
|||||||
}
|
}
|
||||||
<button class="primary" type="submit">Tallenna</button>
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
|
<a
|
||||||
|
class="ghost"
|
||||||
|
href={ templ.SafeURL(stepURL(v, "")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
|
||||||
|
>Peruuta</a>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,105 +591,76 @@ templ loggedCard(v logView) {
|
|||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p class="nm">
|
<p class="nm">
|
||||||
<i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i>
|
@categoryIcon(v.Entry.Main.CategoryKey())
|
||||||
{ v.Entry.Main.Name }
|
{ v.Entry.Main.Name }
|
||||||
</p>
|
</p>
|
||||||
<p class="sd">{ v.Entry.SidesLabel() }</p>
|
<p class="sd">{ v.Entry.SidesLabel() }</p>
|
||||||
<div class="pair">
|
if v.Confirming {
|
||||||
<a
|
<p class="q">Poistetaanko merkintä?</p>
|
||||||
class="btn"
|
<div class="pair">
|
||||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
|
<form
|
||||||
>Muokkaa</a>
|
method="post"
|
||||||
<form method="post" action="/poista">
|
action="/poista"
|
||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
|
||||||
<button class="btn del" type="submit">Poista</button>
|
>
|
||||||
</form>
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
</div>
|
<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">
|
||||||
|
<a
|
||||||
|
class="btn"
|
||||||
|
href={ templ.SafeURL(stepURL(v, "muuta=1")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
|
||||||
|
>Muokkaa</a>
|
||||||
|
<a
|
||||||
|
class="btn del"
|
||||||
|
href={ templ.SafeURL(stepURL(v, "poista=1")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" }
|
||||||
|
>Poista</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Historia
|
// ---------------------------------------------------------------- Ruuat
|
||||||
templ historyPage(rows []HistoryRow) {
|
|
||||||
@page("Historia — Foodster", "/historia") {
|
|
||||||
<header class="appbar">
|
|
||||||
<h2>Historia</h2>
|
|
||||||
</header>
|
|
||||||
<main class="pad">
|
|
||||||
if len(rows) == 0 {
|
|
||||||
<p class="muted">Ei vielä merkintöjä.</p>
|
|
||||||
}
|
|
||||||
for i, row := range rows {
|
|
||||||
if i == 0 || rows[i-1].Date.Month() != row.Date.Month() {
|
|
||||||
<p class="monthrule">{ monthFI(row.Date) }</p>
|
|
||||||
}
|
|
||||||
if row.Entry != nil {
|
|
||||||
<div class="entry">
|
|
||||||
<time>{ dayLabelFI(row.Date) }</time>
|
|
||||||
<div>
|
|
||||||
<div class="nm">
|
|
||||||
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
|
|
||||||
{ row.Entry.Main.Name }
|
|
||||||
</div>
|
|
||||||
<div class="sd">{ row.Entry.SidesLabel() }</div>
|
|
||||||
</div>
|
|
||||||
<a class="chev" href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) } aria-label="Muokkaa">›</a>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="gapline">
|
|
||||||
<time>{ dayLabelFI(row.Date) }</time>
|
|
||||||
<span>Ei merkintää</span>
|
|
||||||
<a href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) }>Merkitse</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Ruoat
|
|
||||||
templ catalogPage(v catalogView) {
|
templ catalogPage(v catalogView) {
|
||||||
@page("Ruoat — Foodster", "/ruoat") {
|
@page("Ruuat — Foodster", "/ruuat") {
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<h2>Ruoat</h2>
|
<h2>Ruuat</h2>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
{ countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
|
{ countFI(v.Mains, "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<main class="pad">
|
<main class="pad">
|
||||||
if v.Report != nil {
|
if v.Report != nil {
|
||||||
@importReport(v.Report)
|
@importReport(v.Report)
|
||||||
}
|
}
|
||||||
@mainForm_(v.Main)
|
<div data-signals:haku={ jsString(v.Search) }>
|
||||||
<h3 class="sechead">Pääruoat</h3>
|
<form method="get" action="/ruuat" class="searchrow">
|
||||||
if len(v.Mains) == 0 {
|
<input
|
||||||
<p class="muted small">Ei vielä pääruokia.</p>
|
class="filter"
|
||||||
}
|
type="search"
|
||||||
for _, d := range v.Mains {
|
name="haku"
|
||||||
<div class="row">
|
value={ v.Search }
|
||||||
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
placeholder="Etsi ruokaa"
|
||||||
<div class="rowtext">
|
aria-label="Etsi"
|
||||||
<div class="nm">{ d.Name }</div>
|
data-bind:haku
|
||||||
<div class="sd">
|
data-on:input__debounce.250ms="@get('/ruuat/etsi')"
|
||||||
{ categoryLabels(d) }
|
/>
|
||||||
if !d.HasSides {
|
</form>
|
||||||
· ei lisukkeita
|
// The add and edit forms stay outside the patched fragment, or
|
||||||
}
|
// typing in the search box would collapse a form mid-edit.
|
||||||
</div>
|
@mainForm_(v.Main)
|
||||||
</div>
|
@sideForm_(v.Side)
|
||||||
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
@catalogList(v)
|
||||||
</div>
|
</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()
|
||||||
@@ -423,30 +669,149 @@ templ catalogPage(v catalogView) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ rowActions(editURL string, id int64, kind string) {
|
// catalogList carries the id Datastar patches, so typing in the search box
|
||||||
<div class="rowactions">
|
// swaps the lists without touching the forms above them.
|
||||||
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
|
//
|
||||||
<form method="post" action="/ruoat/poista">
|
// Two levels of heading, because there are two: Pääruuat and Lisukkeet are
|
||||||
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
|
// the halves of the catalog, and the categories are subdivisions of the
|
||||||
<input type="hidden" name="tyyppi" value={ kind }/>
|
// first. They were previously styled the same, which made a category look
|
||||||
<button type="submit" class="del" aria-label="Poista">Poista</button>
|
// like a peer of the entire side-dish list.
|
||||||
</form>
|
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>
|
</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="tyyppi" value={ kind }/>
|
||||||
|
<button type="submit" class="del">Kyllä</button>
|
||||||
|
</form>
|
||||||
|
<a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
|
||||||
|
</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) {
|
||||||
<section class="card" id="paaruoka">
|
<details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }>
|
||||||
<h3>
|
<summary>
|
||||||
if f.ID == 0 {
|
if f.ID == 0 {
|
||||||
Lisää pääruoka
|
Lisää pääruoka
|
||||||
} else {
|
} else {
|
||||||
Muokkaa pääruokaa
|
Muokkaa pääruokaa
|
||||||
}
|
}
|
||||||
</h3>
|
</summary>
|
||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/ruoat/paaruoka">
|
<form
|
||||||
|
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) }/>
|
||||||
}
|
}
|
||||||
@@ -474,9 +839,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="/ruoat">Peruuta</a>
|
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
|
||||||
}
|
}
|
||||||
</section>
|
</details>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ categoryChip(value, label string, f mainForm) {
|
templ categoryChip(value, label string, f mainForm) {
|
||||||
@@ -486,24 +851,28 @@ templ categoryChip(value, label string, f mainForm) {
|
|||||||
} else {
|
} else {
|
||||||
<input type="checkbox" name="kategoria" value={ value }/>
|
<input type="checkbox" name="kategoria" value={ value }/>
|
||||||
}
|
}
|
||||||
<i class={ "dot", "d-" + categoryFI[value] }></i>
|
@categoryIcon(categoryFI[value])
|
||||||
<span>{ label }</span>
|
<span>{ label }</span>
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ sideForm_(f sideForm) {
|
templ sideForm_(f sideForm) {
|
||||||
<section class="card" id="lisuke">
|
<details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }>
|
||||||
<h3>
|
<summary>
|
||||||
if f.ID == 0 {
|
if f.ID == 0 {
|
||||||
Lisää lisuke
|
Lisää lisuke
|
||||||
} else {
|
} else {
|
||||||
Muokkaa lisuketta
|
Muokkaa lisuketta
|
||||||
}
|
}
|
||||||
</h3>
|
</summary>
|
||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/ruoat/lisuke">
|
<form
|
||||||
|
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) }/>
|
||||||
}
|
}
|
||||||
@@ -514,9 +883,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="/ruoat">Peruuta</a>
|
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
|
||||||
}
|
}
|
||||||
</section>
|
</details>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ importForm() {
|
templ importForm() {
|
||||||
@@ -525,7 +894,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="/ruoat/tuonti" enctype="multipart/form-data">
|
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>JSON</span>
|
<span>JSON</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
+13
-9
@@ -1,19 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
|
image: ${REPO:?set REPO in .env}:${TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# The database is a bind mount, not a named volume: it sits in ./data on
|
# A bind mount rather than a named volume: the database sits in ./data on
|
||||||
# the host where it can be listed, copied and opened with any sqlite
|
# the host, where it can be listed, copied and backed up without going
|
||||||
# client. The image runs as UID 65534, so the container has to be told
|
# through the container engine. The image runs as UID 65534, so the
|
||||||
# which host user owns that directory.
|
# container has to be told 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:
|
||||||
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
|
PASSWORD: ${PASSWORD:?set PASSWORD in .env}
|
||||||
FOODSTER_DB: /data/foodster.db
|
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
|
||||||
@@ -22,7 +26,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(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`)
|
- traefik.http.routers.foodster.rule=Host(`${HOST:?set 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
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fi" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Foodster — kategoriakuvakkeet</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
color-scheme: light dark;
|
||||||
|
--paper: light-dark(#ECEDE8, #121316);
|
||||||
|
--card: light-dark(#FFFFFF, #1C1E22);
|
||||||
|
--sunk: light-dark(#E3E4DE, #17181B);
|
||||||
|
--ink: light-dark(#14161A, #E9EAE5);
|
||||||
|
--muted: light-dark(#6B6F6A, #8B8F89);
|
||||||
|
--line: light-dark(#D5D6D0, #2C2F34);
|
||||||
|
--liha: light-dark(#AF4230, #DE7561);
|
||||||
|
--kana: light-dark(#B57E10, #DFA83B);
|
||||||
|
--kala: light-dark(#25688F, #63AAD8);
|
||||||
|
--kasvis:light-dark(#457A3C, #82BE7A);
|
||||||
|
}
|
||||||
|
html[data-theme="light"]{color-scheme:only light;}
|
||||||
|
html[data-theme="dark"] {color-scheme:only dark;}
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{margin:0;background:var(--paper);color:var(--ink);
|
||||||
|
font-family:system-ui,sans-serif;font-size:16px;line-height:1.45;}
|
||||||
|
.wrap{max-width:900px;margin:0 auto;padding:22px 20px 60px;}
|
||||||
|
h1{font-size:19px;letter-spacing:-.02em;margin:0 0 4px;}
|
||||||
|
.lede{margin:0 0 18px;color:var(--muted);font-size:14px;}
|
||||||
|
button.t{font:inherit;font-size:13px;background:var(--card);border:1px solid var(--line);
|
||||||
|
color:var(--muted);padding:7px 12px;border-radius:7px;cursor:pointer;margin-bottom:20px;}
|
||||||
|
|
||||||
|
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;
|
||||||
|
padding:16px 18px;margin-bottom:14px;}
|
||||||
|
.panel h2{font-size:14px;margin:0 0 12px;letter-spacing:-.01em;}
|
||||||
|
|
||||||
|
table{border-collapse:collapse;width:100%;}
|
||||||
|
th{font-size:10px;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);
|
||||||
|
text-align:left;font-weight:600;padding:0 10px 8px 0;}
|
||||||
|
td{padding:7px 10px 7px 0;vertical-align:middle;}
|
||||||
|
td.name{font-size:13px;color:var(--muted);}
|
||||||
|
|
||||||
|
.i-liha { color: var(--liha); }
|
||||||
|
.i-kana { color: var(--kana); }
|
||||||
|
.i-kala { color: var(--kala); }
|
||||||
|
.i-kasvis{ color: var(--kasvis); }
|
||||||
|
svg{display:block;}
|
||||||
|
|
||||||
|
/* in context */
|
||||||
|
.board{display:flex;flex-wrap:wrap;gap:8px;}
|
||||||
|
.pill{display:flex;align-items:center;gap:9px;background:var(--card);
|
||||||
|
border:1px solid var(--line);border-radius:11px;color:var(--ink);
|
||||||
|
font-weight:600;letter-spacing:-.022em;padding:11px 15px;font-size:16px;}
|
||||||
|
.pill.big{font-size:21px;padding:14px 18px;}
|
||||||
|
.n{font-family:ui-monospace,monospace;font-weight:400;font-size:10px;color:var(--muted);}
|
||||||
|
.row{display:flex;align-items:center;gap:11px;padding:11px 0;
|
||||||
|
border-bottom:1px solid var(--line);}
|
||||||
|
.row .nm{font-size:16px;font-weight:600;}
|
||||||
|
.dotrow{display:flex;align-items:center;gap:11px;padding:11px 0;
|
||||||
|
border-bottom:1px solid var(--line);}
|
||||||
|
.dot{width:9px;height:9px;border-radius:2px;flex:none;}
|
||||||
|
.d-liha{background:var(--liha);}.d-kana{background:var(--kana);}
|
||||||
|
.d-kala{background:var(--kala);}.d-kasvis{background:var(--kasvis);}
|
||||||
|
.d-sek{background:conic-gradient(var(--liha) 0 25%,var(--kana) 25% 50%,
|
||||||
|
var(--kala) 50% 75%,var(--kasvis) 75% 100%);}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Kategoriakuvakkeet</h1>
|
||||||
|
<p class="lede">Colour and shape together. The 14 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 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>
|
||||||
+158
-26
@@ -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
|
||||||
|
|
||||||
FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \
|
PASSWORD="$pass" DB="$tmp/smoke.db" ADDR="$addr" \
|
||||||
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
|
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
|
||||||
srv=$!
|
srv=$!
|
||||||
|
|
||||||
@@ -43,6 +43,16 @@ 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" \
|
||||||
@@ -62,6 +72,8 @@ 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"'
|
||||||
@@ -75,35 +87,40 @@ 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/ruoat")" "0 pääruokaa"
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa"
|
||||||
|
|
||||||
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")
|
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
|
||||||
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
|
check "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/ruoat/tuonti")" \
|
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/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/ruoat/tuonti")" "Lisätty 1"
|
"http://$addr/ruuat/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/ruoat/tuonti")" "tuntematon kategoria"
|
"http://$addr/ruuat/tuonti")" "tuntematon kategoria"
|
||||||
|
|
||||||
check "empty submit is explained" \
|
check "empty submit is explained" \
|
||||||
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruoat/tuonti")" "Ei tuotavaa"
|
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
|
||||||
|
|
||||||
check "malformed JSON is explained" \
|
check "malformed JSON is explained" \
|
||||||
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa"
|
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
|
||||||
|
|
||||||
# ---- the log flow, against the dishes imported above --------------------
|
# ---- 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
|
||||||
@@ -122,8 +139,39 @@ 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"
|
||||||
|
|
||||||
check "history lists the entry" \
|
# The selected day expands inside the list rather than in a panel above it,
|
||||||
"$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu"
|
# so the rows below do not shift when one is tapped.
|
||||||
|
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" \
|
||||||
@@ -135,6 +183,43 @@ 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")
|
||||||
@@ -155,39 +240,86 @@ check "the quick-added dish is on the board" \
|
|||||||
|
|
||||||
# ---- catalog CRUD from the UI -------------------------------------------
|
# ---- catalog CRUD from the UI -------------------------------------------
|
||||||
|
|
||||||
check "adding a main redirects" \
|
# Assert where it redirects, not just that it does: these pointed at the old
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
# /ruoat spelling for a while and every 303-only check was happy.
|
||||||
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruoat/paaruoka")" "303"
|
check "adding a main redirects back to the catalog" \
|
||||||
|
"$(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/ruoat")
|
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
|
||||||
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/ruoat/paaruoka")" \
|
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/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/ruoat/paaruoka")" \
|
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/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/ruoat/paaruoka")" \
|
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||||
"Anna nimi."
|
"Anna nimi."
|
||||||
|
|
||||||
check "adding a side redirects" \
|
check "adding a side redirects back to the catalog" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||||
-d 'nimi=lohkoperunat' "http://$addr/ruoat/lisuke")" "303"
|
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
|
||||||
|
"/ruuat"
|
||||||
|
|
||||||
check "the new side is listed" \
|
check "the new side is listed" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "Lohkoperunat"
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
|
||||||
|
|
||||||
uusi=$(printf '%s' "$catalog" | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
|
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so
|
||||||
|
# the first id on the page belongs to some other dish entirely.
|
||||||
|
uusi=$(printf '%s' "$catalog" | grep -o 'Uunikala.*' | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
|
||||||
|
if [ -z "$uusi" ]; then
|
||||||
|
echo " FAIL could not find Uunikala's id in the catalog"
|
||||||
|
fail=1
|
||||||
|
uusi=0
|
||||||
|
fi
|
||||||
check "the edit form is prefilled" \
|
check "the edit form is prefilled" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruoat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
||||||
|
|
||||||
check "deleting a main redirects" \
|
# A bin icon is easy to hit by accident, so the row asks before anything goes.
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
check "the bin asks before deleting" \
|
||||||
-d "id=$uusi&tyyppi=paa" "http://$addr/ruoat/poista")" "303"
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
|
||||||
|
|
||||||
|
check "the dish is still there while it asks" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
|
||||||
|
|
||||||
|
# ---- 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"
|
||||||
|
|||||||
+47
-10
@@ -1,19 +1,56 @@
|
|||||||
{
|
{
|
||||||
"mains": [
|
"mains": [
|
||||||
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
|
{"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false},
|
||||||
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
|
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
|
||||||
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
|
{"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false},
|
||||||
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
|
{"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true},
|
||||||
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
|
{"name": "Kebab", "categories": ["meat"], "has_sides": true},
|
||||||
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
|
{"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false},
|
||||||
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "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": "Uuniperunat (lohitäytteellä)", "categories": ["fish"], "has_sides": false},
|
||||||
|
|
||||||
|
{"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": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
|
||||||
],
|
],
|
||||||
"sides": [
|
"sides": [
|
||||||
{"name": "Keitetyt perunat"},
|
{"name": "Keitetyt perunat"},
|
||||||
{"name": "Ranskalaiset"},
|
|
||||||
{"name": "Lohkoperunat"},
|
{"name": "Lohkoperunat"},
|
||||||
{"name": "Muussi"},
|
{"name": "Muusi"},
|
||||||
|
{"name": "Pasta"},
|
||||||
|
{"name": "Ranskalaiset"},
|
||||||
{"name": "Riisi"},
|
{"name": "Riisi"},
|
||||||
{"name": "Pasta"}
|
{"name": "Spagetti"},
|
||||||
|
{"name": "Tillikastike"},
|
||||||
|
{"name": "Wokkivihannekset"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user