Picking a date from before the oldest log entry rendered nothing at all. history() truncates its window at min(date) — correct, there is no history before the first entry to be missing — and loadDays widened the window by inflating the day count, which that truncation then undid. No row for the selected day meant no board to log it in, so the one thing you would want an old empty day for was the one thing you could not do. The same happened past maxHistoryDays, reachable straight from the date picker. history() now takes the selected day as an explicit floor instead of the caller guessing a day count, and date() clamps the past at maxHistoryDays the way it already clamped the future — the list runs unbroken from today down to the selection, so a picker set to 1994 would otherwise ask for eleven thousand rows. While in there: - entriesBetween replaces the day-at-a-time entryFor loop. Two queries for the whole window rather than two per day; the widest window a URL can ask for was 3,600 round trips through a pool of exactly one connection. - softDeleteMain and updateMain filter on special = 0. The catalog never lists Tähteet, but a stale tab or a hand-made POST could still have removed the row migration 0002 guarantees. - WriteTimeout and IdleTimeout on the server. With one database connection, a reader stalling on a long history response blocks everything behind it. Two tests, both of which fail on the old code: a day 100 back with only today logged, and sides landing on their own day now that they arrive in one query.
Foodster
A self-hosted dinner log and meal suggester for a single household. Record what the family actually ate, and — later — let the app propose seven dinners drawn from that history.
The interface is in Finnish. The code, comments and documentation are in English.
See PRD.md for the full specification.
Status
Stage 1 — eating history: in development. The meal catalog and the daily log come first, because the suggester is worthless until there are a few weeks of real history to weight against.
Working:
- Kirjaa — log a dinner: pick a dish, tick sides, save. Dishes are ordered and sized by how often they are eaten, so the likely answer is the biggest target. The history sits on the same page underneath: every day back to the first entry, unlogged days shown as explicit gaps, and every row a link that loads that day into the logger above it.
- Ruuat — add, edit and delete mains and sides, or import a whole bundle by paste or file upload. Grouped by category and alphabetical inside, since this is a list you manage rather than one you pick from. Deletes are soft, so old log entries keep showing the dish they used.
- Light / dark, remembered per device, dark by default. The button shows the theme that is on — moon while dark, sun while light — not the one a click would bring.
Still to build:
- Live search as you type, and paging for the history and catalog lists once years of entries make them long. Both via Datastar.
- Category icons instead of plain colour dots — colour and shape together, so a red blob and a yellow blob are told apart by more than hue.
- Edit and delete as icons in the catalog rows, and a confirmation step before a delete actually happens.
- A background for the header. Something subtle; the palette gets overhauled later.
- Stage 2: the seven-meal suggester, which starts once there is history to weight against.
Stack
One static Go binary. No Node.js, no bundler, no separate database server.
| Language | Go 1.27 |
| HTTP | stdlib net/http + http.ServeMux |
| Views | templ, server-rendered |
| Interactivity | Datastar — signals and DOM patching in one ~11 kB script |
| Styling | hand-written CSS, light-dark() for themes |
| Database | SQLite via modernc.org/sqlite (pure Go) |
| Auth | HTTP Basic, one shared household password |
| Runtime image | FROM scratch |
Working on it: CONTRIBUTING.md — branches, commit messages, and the conventions that are easy to miss.
Branches
main holds released versions only. Every release tag points at a commit on
main, so its history is the deployment history. Nothing is committed to
main directly — it moves only by fast-forwarding dev into it.
All development happens on dev. main is protected on the remote: it takes
no direct pushes, so a release arrives through a pull request.
git switch dev # where the work happens
# ... commits ...
make check # lint, unit tests, smoke
git push origin dev
tea pr create --base main --head dev # or open it in the forge
# merge the pull request, then:
git switch main && git pull --ff-only
make release # builds, tags vYYYYMMDD-N, pushes the image
git push origin --tags
make image additionally refuses to run from any branch but main, so a
release tag can never point at a commit that was not released. That check
lives locally because it has to: tags and images are built before anything
reaches the remote, so protection there cannot catch it.
Quick start
cp .env.example .env # then edit it
make run # http://localhost:8080
make on its own lists every target:
make fix gofmt, templ fmt, go mod tidy
make lint go vet, gofmt check, golangci-lint when installed
make test go test ./...
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
Importing dishes
The Ruuat tab takes a bundle of mains and sides: paste the JSON or upload a file, and the app reports row by row what it did.
{
"mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}],
"sides": [{"name": "Riisi"}]
}
Categories are meat, chicken, fish and vegetarian — stored in English
even though the UI is Finnish. A dish may list several, which is how
build-your-own meals like tortillas cover every category at once. has_sides
defaults to true when omitted.
Import is best-effort, never atomic. Valid rows land; invalid ones are skipped
and named (tuntematon kategoria, ei kategorioita, jo listalla).
Names are normalized to sentence case on the way in — whitespace collapses and
the first letter is capitalized, the rest is left as typed, because Finnish
capitalizes only the first word of a phrase. keitetyt perunat is stored as
Keitetyt perunat, while BBQ-kylkeä and Kotipizza keep their capitals.
Duplicates are caught case-insensitively, so re-importing a bundle is safe.
The same importer runs from the command line when you just want to repopulate a scratch database:
make seed # or: SEED=seeds/other.json make seed
Icons
cmd/foodster/static/favicon.svg is the browser-tab icon and follows the
system theme. The home-screen icons are PNGs, because a home-screen icon
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
central 80% so Android can mask it to any shape.
make icons # rsvg-convert, then optipng -o7
The PNGs are committed so the build needs no rasterizer. Re-run make icons
after editing assets/icon.svg.
Migrations
Numbered SQL files in cmd/foodster/migrations, embedded in the binary and
applied in filename order at startup. Each runs in a transaction and is
recorded in schema_migrations, so it applies exactly once.
Adding one means dropping a new NNNN_what_it_does.sql into that directory.
Never edit a migration that has already shipped — a released file has run on
a live database and will not run again.
Configuration
Everything is environment variables. .env is gitignored; start from
.env.example.
| Variable | Default | Purpose |
|---|---|---|
PASSWORD |
required | Shared password. The app will not start without it. |
DB |
./data/foodster.db |
SQLite file path; the directory is created if missing. |
ENV |
prod |
Anything else is prefixed to the tab title (dev · Foodster). |
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. |
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
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,
which is exactly when dinner gets logged.
Deployment
Images are built with Podman and run under Docker Compose on a LAN server. They are OCI images, so either engine works.
make release # build, tag, push
# on the server:
docker compose pull && docker compose up -d
Versions are CalVer — vYYYYMMDD-N, where N is the Nth build that day. The
running version is served at GET /healthz, which is the one route outside
authentication.
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
any sqlite client without going through the engine.
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
that is. Get them from id -u and id -g.
If the app exits with cannot open /data/foodster.db ... unable to open database file (14), the ownership does not match. Docker creates a missing
bind-mount directory as root, and the container is not root:
ls -ldn data # whose is it?
sudo chown -R 1000:1000 data # match PUID / PGID
docker compose restart
Security
Access is a single shared password over HTTP Basic — no accounts, no sessions. Credentials are compared in constant time over SHA-256 digests, so neither the password nor its length leaks through timing.
The app is served on a public hostname behind Traefik, which terminates TLS,
so the credentials are encrypted in transit. That leaves the password as the
only thing between the internet and the app, so wrong guesses are rate
limited per client address: five in a burst, then one per ten seconds,
answered with 429. Requests carrying no Authorization header are not
charged — that is the handshake every browser session begins with, and
counting it would lock the household out for simply opening the app.
X-Forwarded-For is trusted only when the connection came from a private
address, meaning it arrived through the proxy. A client connecting directly
could otherwise forge a new address per attempt and skip the limiter.
None of this replaces a strong PASSWORD. Rate limiting removes
brute force as a practical route; it does not make a guessable password safe.
Mockups
mockups/ holds standalone HTML design studies. Open them directly in a
browser; they are references, not part of the build.
License
MIT.