release: repair the catalog 404 and stop the page jumping (#3)

Showstopper. Adding, editing or deleting a dish redirected to /ruoat, which stopped existing when the tab was renamed to Ruuat — every one of those actions ended on a 404. Live in v20260905-4. The tests missed it because they asserted only a 303; a redirect to a dead URL is still a 303. They now assert the target.

The page no longer jumps. Deleting a dish partway down the catalog, or opening a day in Kirjaa, sent the browser to the top. Both now patch in place via Datastar — bin, pencil, day rows, dish pills, save, delete, cancel and Näytä lisää. Links stay links and forms stay forms, so it works without JavaScript.

Non-production tabs are labelled. ENV=dev gives dev · Foodster.

Contributing guide added, and commits now take Conventional Commit types.

⚠️ Breaking: rewrite the server's .env in this deploy. Environment variables lost the FOODSTER_ prefix; the app refuses to start on an unset PASSWORD.

REPO=…  TAG=latest  PASSWORD=…  HOST=foodster.kessinen.com
ENV=prod  PUID=1000  PGID=1000  TZ=Europe/Helsinki

PUID/PGID rather than UID/GID — UID is read-only in bash and would be silently overwritten.

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-09-05 21:03:04 +00:00
co-authored by Esa Kataja
parent 174652778b
commit d8810d288e
11 changed files with 533 additions and 136 deletions
+14 -7
View File
@@ -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
+94
View File
@@ -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.
+9 -9
View File
@@ -38,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)
@@ -85,7 +85,7 @@ 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 @# A release tag must point into main, or the tag records a commit that
@# was never released. @# was never released.
@branch=$$(git symbolic-ref --short HEAD); \ @branch=$$(git symbolic-ref --short HEAD); \
@@ -99,7 +99,7 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
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) echo "$$tag" > $(TAGFILE)
# Pushing reported success while uploading the previous release once, because # Pushing reported success while uploading the previous release once, because
@@ -107,18 +107,18 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
# registry what it actually serves for each tag and fail if it is not the # registry what it actually serves for each tag and fail if it is not the
# image we just built. # image we just built.
push: ## Push the newest tag and :latest, then verify the registry push: ## Push the newest tag and :latest, then verify the registry
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \ @test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
tag=$$(cat $(TAGFILE)); \ tag=$$(cat $(TAGFILE)); \
built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \ built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \ { echo "no local image tagged $$tag - run make image"; exit 1; }; \
podman push "$(FOODSTER_REPO):$$tag"; \ podman push "$(REPO):$$tag"; \
podman push "$(FOODSTER_REPO):latest"; \ podman push "$(REPO):latest"; \
echo "==> verifying $$tag"; \ echo "==> verifying $$tag"; \
for ref in "$$tag" latest; do \ for ref in "$$tag" latest; do \
podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \ podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \ { echo " FAIL $$ref is not in the registry"; exit 1; }; \
served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \ served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \
if [ "$$served" != "$$built" ]; then \ if [ "$$served" != "$$built" ]; then \
echo " FAIL $$ref serves $$served"; \ echo " FAIL $$ref serves $$served"; \
echo " expected $$built"; \ echo " expected $$built"; \
+13 -9
View File
@@ -351,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
@@ -395,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.
+18 -9
View File
@@ -58,6 +58,9 @@ 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
@@ -173,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.
@@ -204,7 +213,7 @@ the container, so a backup is `cp -r data` and you can inspect the file with
any sqlite client without going through the engine. any sqlite client without going through the engine.
That directory must exist and be owned by the user compose runs as — `make up` That directory must exist and be owned by the user compose runs as — `make up`
creates it, and `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 If the app exits with `cannot open /data/foodster.db ... unable to open
@@ -213,7 +222,7 @@ bind-mount directory as root, and the container is not root:
```sh ```sh
ls -ldn data # whose is it? ls -ldn data # whose is it?
sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID sudo chown -R 1000:1000 data # match PUID / PGID
docker compose restart docker compose restart
``` ```
@@ -235,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
+157 -39
View File
@@ -98,17 +98,57 @@ type logView struct {
Confirming bool 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,
} }
v.Confirming = r.URL.Query().Get("poista") != ""
entry, err := entryFor(a.db, date) entry, err := entryFor(a.db, date)
if err != nil { if err != nil {
log.Printf("entry for %s: %v", date.Format(dateLayout), err) log.Printf("entry for %s: %v", date.Format(dateLayout), err)
@@ -118,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 {
@@ -134,7 +174,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
// The board shows when there is nothing logged yet, or when the entry is // 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, // 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. // so swapping the dish and picking one for the first time are one path.
changing := r.URL.Query().Get("muuta") != "" || v.Search != "" changing := o.Changing || v.Search != ""
if v.Chosen == nil && (v.Entry == nil || changing) { if v.Chosen == nil && (v.Entry == nil || changing) {
v.ShowBoard = true 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 {
@@ -157,7 +197,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
} }
a.loadDays(r, &v) a.loadDays(r, &v)
render(w, r, logPage(v)) return v
} }
// loadDays fills the day list. The selected day expands inside it rather than // loadDays fills the day list. The selected day expands inside it rather than
@@ -215,6 +255,50 @@ func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
} }
} }
// 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. // searchBoard re-renders the dish board as the search box is typed into.
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) { func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
var signals searchSignals var signals searchSignals
@@ -300,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
} }
@@ -307,23 +397,12 @@ 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,
ShowBoard: true,
} }
var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
}
v.Groups = groupDishes(v.Dishes)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("list special: %v", err)
}
a.loadDays(r, &v)
render(w, r, logPage(v)) render(w, r, logPage(v))
} }
@@ -333,9 +412,7 @@ func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.T
if strings.Contains(target, "?") { if strings.Contains(target, "?") {
sep = "&" sep = "&"
} }
http.Redirect(w, r, http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date),
http.StatusSeeOther)
} }
// save records the meal and redirects, so a refresh cannot double-post. // save records the meal and redirects, so a refresh cannot double-post.
@@ -360,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) {
@@ -370,13 +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 returns to the day in the list, anchor included, so saving or // redirectToDay is the no-JavaScript path back after a write.
// deleting leaves the viewport where the work was happening.
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) { func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther) http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther)
} }
// mainForm and sideForm carry what the user typed, so a rejected submission // mainForm and sideForm carry what the user typed, so a rejected submission
@@ -410,7 +486,20 @@ type catalogView struct {
DeleteKind string 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")), Search: strings.TrimSpace(r.URL.Query().Get("haku")),
@@ -446,10 +535,11 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
} }
} }
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{}
} }
@@ -465,9 +555,20 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
if v.Sides, err = listSides(a.db, v.Search); err != nil { 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) {
@@ -508,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) {
@@ -537,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).
@@ -565,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
+25 -6
View File
@@ -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,
@@ -159,9 +176,11 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
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 /etsi", a.searchBoard)
mux.HandleFunc("GET /paiva", a.day)
mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
mux.HandleFunc("GET /ruuat/nayta", a.show)
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain) mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish) mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
+23
View File
@@ -105,6 +105,29 @@ func TestReadSignals(t *testing.T) {
} }
} }
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 {
+98 -29
View File
@@ -43,16 +43,21 @@ func dayURL(base string, d, now time.Time) string {
return base + "?pvm=" + isoDate(d) return base + "?pvm=" + isoDate(d)
} }
// dayAnchor names the element a day expands into. // showURL turns a catalog page link into the patch endpoint behind it, so the
func dayAnchor(d time.Time) string { // href and the Datastar call never drift apart.
return "paiva-" + isoDate(d) func showURL(pageURL string) string {
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
} }
// dayLink opens a day and lands the viewport on it. Without the fragment the // dayPatch is the endpoint behind every link in the day list. The href beside
// browser would jump to the top of a page whose selected day might be far // it stays a real page URL for anyone without JavaScript; Datastar calls this
// down the list. // instead and swaps the list where it stands.
func dayLink(d, now time.Time) string { func dayPatch(d time.Time, param string) string {
return dayURL("/", d, now) + "#" + dayAnchor(d) 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
@@ -64,15 +69,14 @@ func pickSeparator(v logView) string {
return "&" return "&"
} }
// stepURL is any link inside the open day. It keeps the anchor, so picking a // stepURL is the page URL for a link inside the open day: the fallback when
// dish or cancelling stays where the day is instead of throwing the viewport // there is no JavaScript to intercept it.
// back to the top of the list.
func stepURL(v logView, param string) string { func stepURL(v logView, param string) string {
url := dayURL("/", v.Date, v.Today) url := dayURL("/", v.Date, v.Today)
if param != "" { if param != "" {
url += pickSeparator(v) + param url += pickSeparator(v) + param
} }
return url + "#" + dayAnchor(v.Date) return url
} }
// jsString renders a Go string as a JavaScript literal, for the data-signals // jsString renders a Go string as a JavaScript literal, for the data-signals
@@ -104,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 {
@@ -126,7 +140,7 @@ templ page(title, current string) {
// header rather than butting against it. // header rather than butting against it.
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ title }</title> <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
@@ -314,13 +328,13 @@ templ logPage(v logView) {
// above the list and drop that day out of it, so the rows below jumped up // 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. // under the tap. Now nothing moves — the row grows.
templ dayList(v logView) { templ dayList(v logView) {
<section class="history"> <section class="history" id="paivat">
for i, row := range v.History.Rows { for i, row := range v.History.Rows {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() { if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p> <p class="monthrule">{ monthFI(row.Date) }</p>
} }
if row.Date.Equal(v.Date) { if row.Date.Equal(v.Date) {
<div class="open" id={ dayAnchor(row.Date) }> <div class="open">
<p class="openday"> <p class="openday">
if row.Date.Equal(v.Today) { if row.Date.Equal(v.Today) {
Tänään Tänään
@@ -338,7 +352,11 @@ templ dayList(v logView) {
} }
</div> </div>
} else if row.Entry != nil { } else if row.Entry != nil {
<a class="entry" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }> <a
class="entry"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<time>{ dayLabelFI(row.Date) }</time> <time>{ dayLabelFI(row.Date) }</time>
<div> <div>
<div class="nm"> <div class="nm">
@@ -350,7 +368,11 @@ templ dayList(v logView) {
<span class="chev"></span> <span class="chev"></span>
</a> </a>
} else { } else {
<a class="gapline" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }> <a
class="gapline"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<time>{ dayLabelFI(row.Date) }</time> <time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span> <span>Ei merkintää</span>
<span class="act">Merkitse</span> <span class="act">Merkitse</span>
@@ -361,6 +383,7 @@ templ dayList(v logView) {
<a <a
class="more" class="more"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) } 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> >Näytä lisää</a>
} }
</section> </section>
@@ -436,6 +459,7 @@ templ boardList(v logView) {
<a <a
class="pill plain" class="pill plain"
href={ templ.SafeURL(stepURL(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)) + "')" }
> >
@categoryIcon(d.CategoryKey()) @categoryIcon(d.CategoryKey())
{ d.Name } { d.Name }
@@ -471,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>
@@ -503,6 +531,7 @@ templ dishPill(d Dish, v logView) {
<a <a
class={ "pill", d.Size() } class={ "pill", d.Size() }
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) } href={ templ.SafeURL(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()) @categoryIcon(d.CategoryKey())
{ d.Name } { d.Name }
@@ -518,7 +547,11 @@ templ sidesStep(v logView) {
@categoryIcon(v.Chosen.CategoryKey()) @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 {
@@ -540,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(stepURL(v, "")) }>Peruuta</a> <a
class="ghost"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</section> </section>
} }
@@ -561,21 +598,31 @@ templ loggedCard(v logView) {
if v.Confirming { if v.Confirming {
<p class="q">Poistetaanko merkintä?</p> <p class="q">Poistetaanko merkintä?</p>
<div class="pair"> <div class="pair">
<form method="post" action="/poista"> <form
method="post"
action="/poista"
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button> <button class="btn del" type="submit">Kyllä, poista</button>
</form> </form>
<a class="btn" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a> <a
class="btn"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</div> </div>
} else { } else {
<div class="pair"> <div class="pair">
<a <a
class="btn" class="btn"
href={ templ.SafeURL(stepURL(v, "muuta=1")) } href={ templ.SafeURL(stepURL(v, "muuta=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
>Muokkaa</a> >Muokkaa</a>
<a <a
class="btn del" class="btn del"
href={ templ.SafeURL(stepURL(v, "poista=1")) } href={ templ.SafeURL(stepURL(v, "poista=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" }
>Poista</a> >Poista</a>
</div> </div>
} }
@@ -689,25 +736,39 @@ templ emptyNote(search string) {
// rowActions is a pencil and a bin, until the bin is tapped: then the row // 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 // 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. // 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) { templ rowActions(v catalogView, editURL string, id int64, kind string) {
if v.DeleteID == id && v.DeleteKind == kind { if v.DeleteID == id && v.DeleteKind == kind {
<div class="rowactions confirming"> <div class="rowactions confirming">
<span>Poista?</span> <span>Poista?</span>
<form method="post" action="/ruuat/poista"> <form
method="post"
action="/ruuat/poista"
data-on:submit__prevent="@post('/ruuat/poista', {contentType: 'form'})"
>
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/> <input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del">Kyllä</button> <button type="submit" class="del">Kyllä</button>
</form> </form>
<a href="/ruuat">Peruuta</a> <a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
</div> </div>
} else { } else {
<div class="rowactions"> <div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa" title="Muokkaa"> <a
href={ templ.SafeURL(editURL) }
data-on:click__prevent={ "@get('" + showURL(editURL) + "')" }
aria-label="Muokkaa"
title="Muokkaa"
>
@iconPencil() @iconPencil()
</a> </a>
<a <a
class="del" class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) } 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" aria-label="Poista"
title="Poista" title="Poista"
> >
@@ -746,7 +807,11 @@ templ mainForm_(f mainForm) {
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruuat/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) }/>
} }
@@ -774,7 +839,7 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a> <a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
} }
</details> </details>
} }
@@ -803,7 +868,11 @@ templ sideForm_(f sideForm) {
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruuat/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) }/>
} }
@@ -814,7 +883,7 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a> <a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
} }
</details> </details>
} }
+13 -9
View File
@@ -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
+64 -14
View File
@@ -16,7 +16,7 @@ trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
go build -o "$tmp/foodster" ./cmd/foodster go build -o "$tmp/foodster" ./cmd/foodster
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=$!
@@ -72,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"'
@@ -140,13 +142,31 @@ check "the saved day shows what was eaten" \
# The selected day expands inside the list rather than in a panel above it, # The selected day expands inside the list rather than in a panel above it,
# so the rows below do not shift when one is tapped. # so the rows below do not shift when one is tapped.
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05") day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
check "the selected day expands in place" "$day" 'id="paiva-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" check "and stays in the list rather than being lifted out" "$day" "kirjattu"
check "links inside it keep the anchor" "$day" "#paiva-2026-09-05"
check "saving returns to the day, not the top" \ # ---- 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" \ "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "#paiva-2026-09-04" -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. # 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") saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
@@ -220,9 +240,12 @@ 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/ruuat/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/ruuat") 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"
@@ -239,9 +262,10 @@ check "a nameless dish is refused" \
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/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/ruuat/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/ruuat")" "Lohkoperunat" "$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
@@ -264,9 +288,35 @@ check "the bin asks before deleting" \
check "the dish is still there while it asks" \ check "the dish is still there while it asks" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala" "$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
check "confirming the delete redirects" \ # ---- the catalog patches in place instead of navigating -----------------
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303" # 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" \ refute "the dish is gone once confirmed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala" "$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"