2 Commits
Author SHA1 Message Date
KessinenandEsa Kataja 57d5faf65f release: open days older than the first entry ever logged (#4)
release / image (push) Failing after 5s
## Fixed

- Days older than the first entry ever logged can now be opened. Picking a
  date from before anything was recorded landed on a dead end.

## Changed

- Releases are built by CI. Merging this pull request builds the image,
  tags it `vYYYYMMDD-N` and `latest`, pushes both, and creates the git
  tag. Nothing is built locally any more.
- `make image`, `push`, `release`, `seed`, `icons` and `vendor` are gone;
  the Makefile is down from 151 lines to 71. The two rare commands are
  written out in the README.
- Every push to `dev` now runs `make check` in CI.

## Deployment

No new or renamed environment variables, and `compose.yaml` is unchanged —
nothing to copy to the server this time. Pull the new image once CI reports
the build finished.

---------

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #4
2026-09-06 08:28:31 +00:00
KessinenandEsa Kataja d8810d288e 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
2026-09-05 21:03:04 +00:00
13 changed files with 482 additions and 178 deletions
+22
View File
@@ -0,0 +1,22 @@
name: check
on:
push:
branches: [dev]
# ponytail: only because Traefik still serves its default self-signed cert for
# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run.
env:
GIT_SSL_NO_VERIFY: "true"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: make check
+58
View File
@@ -0,0 +1,58 @@
name: release
on:
push:
branches: [main]
# ponytail: only because Traefik still serves its default self-signed cert for
# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run.
env:
GIT_SSL_NO_VERIFY: "true"
REGISTRY: git.kessinen.com
IMAGE: git.kessinen.com/kessinen/foodster
jobs:
image:
runs-on: ubuntu-latest
steps:
# Full history and tags: the release number is derived by counting the
# tags already cut today.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# The job container is node:22-bookworm and has no docker client. The
# static binary is one file; installing docker.io would pull a daemon
# that is never used, since the build runs against the host's.
- name: Install the docker client
run: |
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
docker version --format '{{.Client.Version}}'
- name: Work out the release tag
id: rel
run: |
day=$(date +%Y%m%d)
tag="v$day-$(( $(git tag -l "v$day-*" | wc -l) + 1 ))"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "==> $tag"
- name: Tag the commit
run: |
git tag "${{ steps.rel.outputs.tag }}"
git push origin "${{ steps.rel.outputs.tag }}"
- name: Log in to the registry
run: |
echo "${{ secrets.GITEA_TOKEN }}" \
| docker login "$REGISTRY" -u "${{ gitea.actor }}" --password-stdin
- name: Build and push
run: |
tag="${{ steps.rel.outputs.tag }}"
docker build --platform linux/amd64 --build-arg VERSION="$tag" \
-f Containerfile \
-t "$IMAGE:$tag" -t "$IMAGE:latest" .
docker push "$IMAGE:$tag"
docker push "$IMAGE:latest"
echo "pushed $IMAGE:$tag and :latest - pull it in dockge when ready"
-3
View File
@@ -4,9 +4,6 @@
# Build output # Build output
/foodster /foodster
# The tag `make image` last built, handed to `make push`.
/.release-tag
# Generated by `templ generate` during the container build. # Generated by `templ generate` during the container build.
*_templ.go *_templ.go
+104
View File
@@ -0,0 +1,104 @@
# 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
```
The release workflow only triggers on `main`, and `main` only moves through a
pull request, so a release can never be built from the wrong branch. Nothing
needs to check for it.
## 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 — CI creates the CalVer tag after the merge, so it is not known yet.
The types above are for `dev`, where a commit really does do one thing.
## Deploying
The server keeps its own `compose.yaml` and `.env`. Neither is pulled from
here, so a release that renames a variable, adds one, or changes a mount
needs both copied across **in the same deploy** — otherwise the container
comes up against the old names and the app refuses to start.
Anything in this repository that reaches the server by hand belongs in the
release notes, flagged as breaking.
## 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.
+2 -81
View File
@@ -3,18 +3,8 @@
COMPOSE ?= podman compose COMPOSE ?= podman compose
BIN := foodster BIN := foodster
PKG := ./cmd/foodster PKG := ./cmd/foodster
STATIC := cmd/foodster/static
# What `make image` last built. push reads it rather than re-deriving the tag: # Shared password and TZ live here. Gitignored.
# 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.
DATASTAR_VERSION ?= v1.0.3
SEED ?= seeds/testi.json
# Registry coordinates, shared password and TZ live here. Gitignored.
ifneq (,$(wildcard .env)) ifneq (,$(wildcard .env))
include .env include .env
export export
@@ -24,7 +14,7 @@ endif
GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null) GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null)
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help generate build run seed test smoke check lint fix icons vendor image push release up down logs clean .PHONY: help generate build run test smoke check lint fix up down logs clean
help: ## Show this help help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
@@ -40,9 +30,6 @@ build: generate ## Build ./foodster
run: generate ## Run locally on :8080 (database in ./data) run: generate ## Run locally on :8080 (database in ./data)
PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG) PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG)
seed: ## Import a dish bundle (SEED=seeds/testi.json)
go run $(PKG) -import $(SEED)
test: generate ## Run unit tests test: generate ## Run unit tests
go test ./... go test ./...
@@ -57,21 +44,6 @@ check: ## Everything that must pass before a commit
@$(MAKE) --no-print-directory smoke @$(MAKE) --no-print-directory smoke
@echo "check: all passed" @echo "check: all passed"
icons: ## Rasterise home-screen PNGs from assets/icon.svg and optimise them
rsvg-convert -w 180 -h 180 assets/icon.svg -o $(STATIC)/apple-touch-icon.png
rsvg-convert -w 192 -h 192 assets/icon.svg -o $(STATIC)/icon-192.png
rsvg-convert -w 512 -h 512 assets/icon.svg -o $(STATIC)/icon-512.png
# oxipng -o max alone loses to optipng on the 512; --zopfli wins at every
# size. Slow, but these are three tiny files built by hand.
oxipng -o max --zopfli --quiet \
$(STATIC)/apple-touch-icon.png $(STATIC)/icon-192.png $(STATIC)/icon-512.png
@ls -l $(STATIC)/*.png
vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
curl -sSfL -o $(STATIC)/datastar.js \
"https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js"
@head -1 $(STATIC)/datastar.js
lint: generate ## go vet, gofmt check, golangci-lint when installed lint: generate ## go vet, gofmt check, golangci-lint when installed
go vet ./... go vet ./...
@bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \
@@ -84,57 +56,6 @@ fix: ## Format Go and templ sources, tidy go.mod
go tool templ fmt . go tool templ fmt .
go mod tidy go mod tidy
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
@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); \
tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \
echo "==> $$tag"; \
git tag "$$tag"; \
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
-t "$(REPO):$$tag" -t "$(REPO):latest" . ; \
echo "$$tag" > $(TAGFILE)
# Pushing reported success while uploading the previous release once, because
# nothing compared what was built against what arrived. So afterwards, ask the
# registry what it actually serves for each tag and fail if it is not the
# image we just built.
push: ## Push the newest tag and :latest, then verify the registry
@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
# 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
$(COMPOSE) up -d $(COMPOSE) up -d
+17 -12
View File
@@ -372,22 +372,27 @@ Explicitly *not* React.
## 10. Deployment ## 10. Deployment
Images are built locally, pushed to a private container registry, then pulled Images are built by CI, pushed to a private container registry, then pulled on
on the server and run with Docker Compose. the server and run with Docker Compose.
- **Branches**: `main` carries released versions only, so its history is the - **Branches**: `main` carries released versions only, so its history is the
deployment history and every release tag points into it. Development happens deployment history and every release tag points into it. Development happens
on `dev`, and `main` is protected on the remote: it accepts no direct on `dev`, and `main` is protected on the remote: it accepts no direct
pushes, so a release arrives as a pull request from `dev`. `make image` pushes, so a release arrives as a pull request from `dev`. The release
additionally refuses to run outside `main` — that one has to be local, workflow runs only on `main`, so a release cannot be built from anywhere
because the tag and the image are made before anything reaches the remote. else and nothing needs to check for it.
- **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. The release workflow derives `N` by counting the day's existing git
creates the new tag, and bakes the version into the binary through tags, creates the new tag, and bakes the version into the binary through
`-ldflags -X main.version`. `make release` builds, tags and pushes. `-ldflags -X main.version`.
- **Tooling**: a `Makefile` is the single entry point — `make` on its own - **CI**: Gitea Actions, workflows in `.gitea/workflows/`. `check.yaml` runs
lists every target. Build, test, lint, format, image and compose commands `make check` on every push to `dev`; `release.yaml` builds and pushes the
all live there rather than in loose scripts. image when a pull request merges into `main`. Merging is the release —
there is no local build step.
- **Tooling**: a `Makefile` covers development — `make` on its own lists every
target. Build, test, lint, format and compose commands live there rather
than in loose scripts. Commands run a handful of times a year are written
out in the README instead of earning a target.
- **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a - **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a
static binary; the runtime stage is `FROM scratch` holding only that static binary; the runtime stage is `FROM scratch` holding only that
binary, running as UID 65534. binary, running as UID 65534.
@@ -425,7 +430,7 @@ on the server and run with Docker Compose.
- Pending migrations are applied on app start. - Pending migrations are applied on app start.
- The Datastar client is vendored at `cmd/foodster/static/datastar.js` and - The Datastar client is vendored at `cmd/foodster/static/datastar.js` and
served from the app's own origin — the SDK ships no browser asset, and a served from the app's own origin — the SDK ships no browser asset, and a
CDN link would break an offline LAN. `make vendor` refreshes it; the pinned CDN link would break an offline LAN. The README says how to refresh it; the pinned
version lives in the `Makefile` and in the file's first line. version lives in the `Makefile` and in the file's first line.
- No internet exposure; the server binds to the LAN. - No internet exposure; the server binds to the LAN.
+78 -49
View File
@@ -11,35 +11,35 @@ See [PRD.md](PRD.md) for the full specification.
## Status ## Status
**Stage 1 — eating history: in development.** The meal catalog and the daily **Stage 1 — eating history: in use.** The meal catalog and the daily log came
log come first, because the suggester is worthless until there are a few first, because the suggester is worthless until there are a few weeks of real
weeks of real history to weight against. history to weight against.
Working: Working:
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered - **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are grouped
and sized by how often they are eaten, so the likely answer is the biggest by category, then ordered and sized by how often they are eaten, so the
target. The history sits on the same page underneath: every day back to the likely answer is the biggest target. The history sits on the same page
first entry, unlogged days shown as explicit gaps, and every row a link that underneath: every day back to the first entry, unlogged days shown as
loads that day into the logger above it. explicit gaps, and every row opening that day's logger in place. Older days
arrive a window at a time.
- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle - **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
by paste or file upload. Grouped by category and alphabetical inside, since 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, this is a list you manage rather than one you pick from. Edit and delete are
so old log entries keep showing the dish they used. row icons, and a delete asks first. Deletes are soft, so old log entries
keep showing the dish they used.
- **Search as you type** on both tabs, debounced, patching just the list.
- **Category icons**, not colour dots: shape and colour together, so two marks
are told apart by more than hue.
- **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.
Nothing navigates. Every interaction patches the page through Datastar, so
the scroll position survives; links and forms still work with JavaScript off.
Still to build: Still to build:
- Live search as you type, and paging for the history and catalog lists once
years of entries make them long. Both via Datastar.
- Category icons instead of plain colour dots — colour and shape together, so
a red blob and a yellow blob are told apart by more than hue.
- Edit and delete as icons in the catalog rows, and a confirmation step before
a delete actually happens.
- A background for the header. Something subtle; the palette gets overhauled
later.
- Stage 2: the seven-meal suggester, which starts once there is history to - Stage 2: the seven-meal suggester, which starts once there is history to
weight against. weight against.
@@ -58,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
@@ -71,20 +74,20 @@ no direct pushes, so a release arrives through a pull request.
git switch dev # where the work happens git switch dev # where the work happens
# ... commits ... # ... commits ...
make check # lint, unit tests, smoke make check # lint, unit tests, smoke
git push origin dev git push origin dev # CI runs make check too
tea pr create --base main --head dev # or open it in the forge tea pr create --base main --head dev # or open it in the forge
# merge the pull request, then: # squash-merge the pull request — that is the whole release
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 Merging is the release. CI builds the image, tags it `vYYYYMMDD-N` and
release tag can never point at a commit that was not released. That check `latest`, pushes both to the registry, and creates the matching git tag. There
lives locally because it has to: tags and images are built before anything is nothing to run locally afterwards; pull the new image on the server when
reaches the remote, so protection there cannot catch it. you are ready.
A release tag can therefore never point at a commit that was not released:
the workflow only runs on `main`, and `main` only moves through a pull
request.
## Quick start ## Quick start
@@ -99,15 +102,39 @@ make run # http://localhost:8080
make fix gofmt, templ fmt, go mod tidy make fix gofmt, templ fmt, go mod tidy
make lint go vet, gofmt check, golangci-lint when installed make lint go vet, gofmt check, golangci-lint when installed
make test go test ./... make test go test ./...
make smoke end-to-end check against a scratch server
make check lint + test + smoke — run before every commit
make build ./foodster make build ./foodster
make seed import a dish bundle (SEED=seeds/testi.json)
make vendor re-download the Datastar client
make image build and tag vYYYYMMDD-N (creates a git tag)
make push push the newest tag and :latest
make release image + push
make up/down/logs compose make up/down/logs compose
``` ```
Images are built by CI, not here — see [Deployment](#deployment).
### Occasional commands
Rare enough not to earn a `make` target. Both write into
`cmd/foodster/static/`, and the results are committed.
Re-download the vendored Datastar client after bumping the version:
```sh
curl -sSfL -o cmd/foodster/static/datastar.js \
"https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"
```
Re-rasterise the home-screen icons after editing `assets/icon.svg`:
```sh
cd cmd/foodster/static
rsvg-convert -w 180 -h 180 ../../../assets/icon.svg -o apple-touch-icon.png
rsvg-convert -w 192 -h 192 ../../../assets/icon.svg -o icon-192.png
rsvg-convert -w 512 -h 512 ../../../assets/icon.svg -o icon-512.png
oxipng -o max --zopfli --quiet apple-touch-icon.png icon-192.png icon-512.png
```
`oxipng -o max` on its own loses to optipng on the 512; `--zopfli` wins at
every size. Slow, but these are three tiny files built by hand.
## Importing dishes ## Importing dishes
The **Ruuat** 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
@@ -138,7 +165,7 @@ The same importer runs from the command line when you just want to repopulate
a scratch database: a scratch database:
```sh ```sh
make seed # or: SEED=seeds/other.json make seed go run ./cmd/foodster -import seeds/testi.json
``` ```
## Icons ## Icons
@@ -149,12 +176,8 @@ cannot be transparent and must not change with the theme; they are rasterised
from `assets/icon.svg`, which is opaque and keeps the artwork inside the from `assets/icon.svg`, which is opaque and keeps the artwork inside the
central 80% so Android can mask it to any shape. central 80% so Android can mask it to any shape.
```sh The PNGs are committed so the build needs no rasterizer. The commands to
make icons # rsvg-convert, then optipng -o7 regenerate them are under [Occasional commands](#occasional-commands).
```
The PNGs are committed so the build needs no rasterizer. Re-run `make icons`
after editing `assets/icon.svg`.
## Migrations ## Migrations
@@ -179,25 +202,24 @@ Everything is environment variables. `.env` is gitignored; start from
| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. | | `ADDR` | `:8080` | Listen address. Only useful for a second local instance. |
| `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. | | `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. |
| `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. |
| `TAG` | `latest` | Tag to run under compose. |
| `HOST` | *required to run* | Hostname Traefik routes to. |
Names carry no prefix: the container gives them their own namespace already. Names carry no prefix: the container gives them their own namespace already.
`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set `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. 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.
## Deployment ## Deployment
Images are built with Podman and run under Docker Compose on a LAN server. Images are built by CI when a pull request merges into `main`, and run under
They are OCI images, so either engine works. Docker Compose on a LAN server. They are OCI images, so either engine works.
```sh ```sh
make release # build, tag, push # on the server, once CI reports the build finished:
# on the server:
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
``` ```
@@ -206,8 +228,15 @@ running version is served at `GET /healthz`, which is the one route outside
authentication. authentication.
There is no database container. SQLite lives in `./data`, bind-mounted into There is no database container. SQLite lives in `./data`, bind-mounted into
the container, so a backup is `cp -r data` and you can inspect the file with the container, so you can inspect the file with any sqlite client without
any sqlite client without going through the engine. going through the engine. Back it up with
```sh
sqlite3 data/foodster.db ".backup data/foodster-$(date +%F).db"
```
rather than copying the directory: the database runs in WAL mode, and a plain
copy of a live database can catch the `.db` and its `-wal` mid-write.
That directory must exist and be owned by the user compose runs as — `make up` That directory must exist and be owned by the user compose runs as — `make up`
creates it, and `PUID`/`PGID` in `.env` tell the container who creates it, and `PUID`/`PGID` in `.env` tell the container who
+1 -1
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="Foodster"> role="img" aria-label="Foodster">
<!-- Source for the home-screen PNGs; `make icons` rasterises it. <!-- Source for the home-screen PNGs; the README says how to rasterise it.
Unlike favicon.svg this one is opaque and fixed-colour: a home screen Unlike favicon.svg this one is opaque and fixed-colour: a home screen
icon cannot be transparent and must not change with the system theme. icon cannot be transparent and must not change with the system theme.
The bowl sits inside the central 80% so Android can mask it to any The bowl sits inside the central 80% so Android can mask it to any

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 927 B

+13 -8
View File
@@ -60,12 +60,19 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
// there is nothing to write down for a dinner that has not happened, and a // 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. // 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. // Every read and write goes through here, so the clamp covers them all.
//
// The past is clamped too, at maxHistoryDays. The day list runs unbroken from
// today down to the selected day, so a picker set to 1994 would ask for eleven
// thousand rows. Same ceiling ?paivat= already has.
func (a *app) date(r *http.Request) time.Time { func (a *app) date(r *http.Request) time.Time {
now := today(a.loc) 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) { switch floor := now.AddDate(0, 0, -maxHistoryDays+1); {
case d.After(now):
return now return now
case d.Before(floor):
return floor
} }
return d return d
} }
@@ -205,15 +212,13 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
// list underneath the tap. // list underneath the tap.
func (a *app) loadDays(r *http.Request, v *logView) { func (a *app) loadDays(r *http.Request, v *logView) {
v.HistoryDays = historyWindow(r) 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 v.HistoryMore = v.HistoryDays + historyDays
page, err := history(a.db, a.loc, v.Today, v.HistoryDays) // The window has to reach the selected day, or it would have nowhere to
// expand. history() takes it as a floor rather than the caller inflating
// the day count, because the window also truncates at the first entry ever
// logged — and a day older than that still has to be loggable.
page, err := history(a.db, a.loc, v.Today, v.HistoryDays, v.Date)
if err != nil { if err != nil {
log.Printf("history: %v", err) log.Printf("history: %v", err)
} }
+6 -1
View File
@@ -32,7 +32,7 @@ import (
//go:embed static //go:embed static
var staticFS embed.FS 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 by the release workflow.
var version = "dev" var version = "dev"
// envTag marks the browser tab of anything that is not production, so a dev // envTag marks the browser tab of anything that is not production, so a dev
@@ -100,10 +100,15 @@ func run() error {
// local instance can pick another port. // local instance can pick another port.
addr := cmp.Or(os.Getenv("ADDR"), listenAddr) addr := cmp.Or(os.Getenv("ADDR"), listenAddr)
// WriteTimeout and IdleTimeout matter more than they look: the pool holds
// exactly one database connection, so a reader stalling on a long history
// response blocks every other request behind it.
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: routes(db, loc, password), Handler: routes(db, loc, password),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
} }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+96 -7
View File
@@ -305,7 +305,8 @@ func updateMain(db *sql.DB, id int64, name string, categories []string, hasSides
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.Exec( if _, err := tx.Exec(
`UPDATE main_dishes SET name = ?, has_sides = ? WHERE id = ? AND deleted_at IS NULL`, `UPDATE main_dishes SET name = ?, has_sides = ?
WHERE id = ? AND deleted_at IS NULL AND special = 0`,
name, hasSides, id, name, hasSides, id,
); err != nil { ); err != nil {
return taken(err) return taken(err)
@@ -345,9 +346,14 @@ func updateSide(db *sql.DB, id int64, name string) error {
// Soft delete: the row stays so historical log entries keep resolving their // Soft delete: the row stays so historical log entries keep resolving their
// names, but it disappears from the catalog and every picker (PRD §6). // names, but it disappears from the catalog and every picker (PRD §6).
//
// `special = 0` here and in updateMain: the catalog never lists Tähteet, so
// the UI cannot reach it, but a stale tab or a hand-made POST could — and
// removing it would take away the row migration 0002 guarantees.
func softDeleteMain(db *sql.DB, id int64) error { func softDeleteMain(db *sql.DB, id int64) error {
_, err := db.Exec( _, err := db.Exec(
`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = ?`, id) `UPDATE main_dishes SET deleted_at = datetime('now')
WHERE id = ? AND special = 0`, id)
return err return err
} }
@@ -432,7 +438,12 @@ type HistoryPage struct {
// history walks back day by day from a given day, so a day nobody wrote down // 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 // 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. // 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) { //
// reach names a day that must appear whatever the window says. The log board
// opens inside the selected day's row, so a date picked from before the first
// entry ever logged used to render nothing at all: no row, no board, no way to
// log it. Pass the zero time to ask for the plain window.
func history(db *sql.DB, loc *time.Location, from time.Time, days int, reach time.Time) (HistoryPage, error) {
var first sql.NullString var first sql.NullString
if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil { if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -457,14 +468,92 @@ func history(db *sql.DB, loc *time.Location, from time.Time, days int) (HistoryP
oldest = firstDate oldest = firstDate
page.More = false page.More = false
} }
if !reach.IsZero() && reach.Before(oldest) {
oldest = reach
page.More = firstDate.Before(oldest)
}
page.Next = oldest.AddDate(0, 0, -1) page.Next = oldest.AddDate(0, 0, -1)
entries, err := entriesBetween(db, oldest, from)
if err != nil {
return HistoryPage{}, err
}
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) { for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
entry, err := entryFor(db, d) row := HistoryRow{Date: d}
if err != nil { if e := entries[d.Format(dateLayout)]; e != nil {
return HistoryPage{}, err e.Date = d
row.Entry = e
} }
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry}) page.Rows = append(page.Rows, row)
} }
return page, nil return page, nil
} }
// entriesBetween loads every logged day in the inclusive range, keyed by
// stored date string, in two queries rather than two per day. The widest
// window a URL can ask for is five years, which day-at-a-time made 3,600 round
// trips through a pool of exactly one connection.
func entriesBetween(db *sql.DB, from, to time.Time) (map[string]*Entry, error) {
lo, hi := from.Format(dateLayout), to.Format(dateLayout)
rows, err := db.Query(`
SELECT l.id, l.date, m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category)
FROM main_dish_categories c
WHERE c.main_dish_id = m.id), '')
FROM meal_log l
JOIN main_dishes m ON m.id = l.main_dish_id
WHERE l.date BETWEEN ? AND ?`, lo, hi)
if err != nil {
return nil, err
}
defer rows.Close()
byDate := map[string]*Entry{}
byLog := map[int64]*Entry{}
for rows.Next() {
var logID int64
var date, cats string
var e Entry
if err := rows.Scan(
&logID, &date, &e.Main.ID, &e.Main.Name, &e.Main.HasSides, &cats,
); err != nil {
return nil, err
}
if cats != "" {
e.Main.Categories = strings.Split(cats, ",")
}
byDate[date] = &e
byLog[logID] = &e
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(byLog) == 0 {
return byDate, nil
}
sides, err := db.Query(`
SELECT ls.meal_log_id, s.id, s.name
FROM meal_log_sides ls
JOIN side_dishes s ON s.id = ls.side_dish_id
JOIN meal_log l ON l.id = ls.meal_log_id
WHERE l.date BETWEEN ? AND ?
ORDER BY s.name`, lo, hi)
if err != nil {
return nil, err
}
defer sides.Close()
for sides.Next() {
var logID int64
var s Side
if err := sides.Scan(&logID, &s.ID, &s.Name); err != nil {
return nil, err
}
if e := byLog[logID]; e != nil {
e.Sides = append(e.Sides, s)
}
}
return byDate, sides.Err()
}
+65 -4
View File
@@ -235,7 +235,7 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
t.Fatalf("save -3: %v", err) t.Fatalf("save -3: %v", err)
} }
page, err := history(h.db, loc, now, 60) page, err := history(h.db, loc, now, 60, time.Time{})
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
@@ -271,7 +271,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
t.Fatalf("save -9: %v", err) t.Fatalf("save -9: %v", err)
} }
first, err := history(h.db, loc, now, 5) first, err := history(h.db, loc, now, 5, time.Time{})
if err != nil { if err != nil {
t.Fatalf("first window: %v", err) t.Fatalf("first window: %v", err)
} }
@@ -286,7 +286,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
} }
// The windows must meet exactly: no day repeated, none skipped. // The windows must meet exactly: no day repeated, none skipped.
second, err := history(h.db, loc, first.Next, 5) second, err := history(h.db, loc, first.Next, 5, time.Time{})
if err != nil { if err != nil {
t.Fatalf("second window: %v", err) t.Fatalf("second window: %v", err)
} }
@@ -305,7 +305,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
func TestHistoryEmptyWithoutEntries(t *testing.T) { func TestHistoryEmptyWithoutEntries(t *testing.T) {
h := seeded(t) h := seeded(t)
page, err := history(h.db, time.UTC, today(time.UTC), 60) page, err := history(h.db, time.UTC, today(time.UTC), 60, time.Time{})
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
@@ -316,3 +316,64 @@ func TestHistoryEmptyWithoutEntries(t *testing.T) {
t.Error("More is set although there is no history at all") t.Error("More is set although there is no history at all")
} }
} }
// A date picked from before the first entry ever logged used to fall outside
// the window entirely: no row, so the log board had nothing to open in and the
// day could not be filled in at all.
func TestHistoryReachesDaysOlderThanTheFirstEntry(t *testing.T) {
h := seeded(t)
loc := time.UTC
now := today(loc)
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
t.Fatalf("save today: %v", err)
}
want := now.AddDate(0, 0, -100)
page, err := history(h.db, loc, now, 30, want)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(page.Rows) != 101 {
t.Fatalf("%d rows, want 101 (today back to the selected day)", len(page.Rows))
}
last := page.Rows[len(page.Rows)-1]
if !last.Date.Equal(want) {
t.Errorf("last row is %s, want the selected %s",
last.Date.Format(dateLayout), want.Format(dateLayout))
}
if page.More {
t.Error("More is set although the window reached past the oldest entry")
}
}
// The sides of every day come back in one query now; each still has to land on
// its own day.
func TestHistoryKeepsSidesWithTheirOwnDay(t *testing.T) {
h := seeded(t)
loc := time.UTC
now := today(loc)
muusi := h.sideNamed(t, "Perunamuusi")
riisi := h.sideNamed(t, "Riisi")
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), []int64{muusi}); err != nil {
t.Fatalf("save today: %v", err)
}
if err := saveEntry(h.db, now.AddDate(0, 0, -1), h.mainNamed(t, "Lihapullat"), []int64{riisi}); err != nil {
t.Fatalf("save -1: %v", err)
}
page, err := history(h.db, loc, now, 30, time.Time{})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(page.Rows) != 2 {
t.Fatalf("%d rows, want 2", len(page.Rows))
}
for i, want := range []string{"Perunamuusi", "Riisi"} {
got := page.Rows[i].Entry
if got == nil || len(got.Sides) != 1 || got.Sides[0].Name != want {
t.Errorf("row %d sides = %+v, want just %s", i, got.Sides, want)
}
}
}
+20 -12
View File
@@ -11,6 +11,12 @@ cd "$(dirname "$0")/.."
addr=127.0.0.1:8099 addr=127.0.0.1:8099
pass=smoke pass=smoke
# Dates are relative, never literal. A hardcoded one turns into "some day in
# the past" at the next midnight, and the assertions quietly start meaning
# something else.
d0=$(date +%F)
d1=$(date -d yesterday +%F)
tmp=$(mktemp -d) tmp=$(mktemp -d)
trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
@@ -134,51 +140,53 @@ check "picking a dish opens the sides step" \
check "saving redirects back to the day" \ check "saving redirects back to the day" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d "pvm=2026-09-05&ruoka=$ruoka" "http://$addr/kirjaa")" "303" -d "pvm=$d0&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
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=$d0")" "kirjattu"
# 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=$d0")
check "the selected day expands in place" "$day" 'class="open"' 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"
# ---- the day list patches in place instead of navigating ---------------- # ---- 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") dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=$d0")
check "opening a day patches the list" "$dayp" 'id="paivat"' check "opening a day patches the list" "$dayp" 'id="paivat"'
refute "and returns a fragment, not a page" "$dayp" "<html" refute "and returns a fragment, not a page" "$dayp" "<html"
check "picking a dish patches to the sides step" \ check "picking a dish patches to the sides step" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \ "$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/paiva?pvm=2026-09-05&ruoka=$ruoka")" "Tallenna" "http://$addr/paiva?pvm=$d0&ruoka=$ruoka")" "Tallenna"
check "saving from Datastar patches back" \ check "saving from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \ "$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"' -d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
check "deleting from Datastar patches back" \ check "deleting from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \ "$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04" "http://$addr/poista")" 'id="paivat"' -d "pvm=$d1" "http://$addr/poista")" 'id="paivat"'
# Without the header it must still redirect, for no JavaScript. # Without the header it must still redirect, for no JavaScript.
check "a plain save still redirects to the day" \ 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")" "pvm=2026-09-04" -d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=$d1"
# 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=$d0&poista=1")
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?" check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
refute "and does not delete while asking" "$saved" "Ei merkintää" # Assert the entry is still shown, rather than that no gap row exists anywhere
# on the page: other days are legitimately unlogged and render their own.
check "and the entry is still there while asking" "$saved" "kirjattu"
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" \
-d "pvm=2026-09-05" "http://$addr/poista")" "303" -d "pvm=$d0" "http://$addr/poista")" "303"
check "the day is empty again" \ check "the day is empty again" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "Etsi" "$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")" "Etsi"
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"