Stage 1's point is collecting eating history, so the logging path is the one that has to be frictionless: pick a dish, tick sides, done. Kirjaa: - dishes ordered and sized by how often they have been eaten, so the likely answer is the biggest target on the screen - picking a dish opens the sides step; a dish with has_sides false says so instead of offering an empty list - saving redirects, so a refresh cannot double-post - a day already logged shows the entry with edit and delete. Editing reopens the sides step with the existing sides ticked, which makes editing and creating the same screen - day switcher and a server-side search over the catalog Historia walks back day by day to the oldest entry, so a day nobody wrote down appears as an explicit gap rather than quietly missing. No JavaScript is involved: every interaction is a link or a form, and the checked-chip styling is :has(input:checked). Datastar stays loaded but unused until an interaction genuinely needs to avoid a page load. Also fix a Makefile ordering bug: lint did not depend on generate, so `go vet` could run against templ output that was being rewritten. check now runs its phases as sub-makes so `make -j` cannot interleave them. Records two features that are specified but not built: dish CRUD in the UI (§7.3, only import exists today) and a per-device light/dark switch (§7.4). The switch must show the state that is active, not the one clicking produces.
5.9 KiB
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 sized by how often they are eaten. Edit or delete the day's entry.
- Historia — every day back to the first entry, with unlogged days shown as explicit gaps.
- Ruoat — import a bundle of dishes by paste or file upload.
Still to build:
- Adding, editing and deleting dishes in the UI (PRD §7.3). Right now the catalog can only be filled by importing a bundle.
- Light / dark theme switch, saved per device (PRD §7.4).
- 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 |
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 Ruoat tab takes a bundle of mains and sides: paste the JSON or upload a file, and the app reports row by row what it did.
{
"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
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 |
|---|---|---|
FOODSTER_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. |
FOODSTER_UID / FOODSTER_GID |
1000 |
Host owner of ./data, for the bind mount. |
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. |
FOODSTER_PORT |
8080 |
Host port to publish. |
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 FOODSTER_UID/FOODSTER_GID in .env tell the container who
that is. Get them from id -u and id -g.
Security
Access is a single shared password over HTTP Basic — no accounts, no sessions. Credentials are compared in constant time, but Basic auth sends them in cleartext, so this belongs on a private LAN. Put TLS in front of it before exposing it anywhere else.
Mockups
mockups/ holds standalone HTML design studies. Open them directly in a
browser; they are references, not part of the build.
License
MIT.