# 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](PRD.md) for the full specification. ## Status **Stage 1 — eating history: in use.** The meal catalog and the daily log came 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 grouped by category, then 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 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 by paste or file upload. Grouped by category and alphabetical inside, since this is a list you manage rather than one you pick from. Edit and delete are 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 the theme that is on — moon while dark, sun while light — not the one a 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: - 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](https://templ.guide), server-rendered | | Interactivity | [Datastar](https://data-star.dev) — 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](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. ```sh git switch dev # where the work happens # ... commits ... make check # lint, unit tests, smoke git push origin dev # CI runs make check too tea pr create --base main --head dev # or open it in the forge # squash-merge the pull request — that is the whole release ``` Merging is the release. CI builds the image, tags it `vYYYYMMDD-N` and `latest`, pushes both to the registry, and creates the matching git tag. There is nothing to run locally afterwards; pull the new image on the server when 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 ```sh 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 smoke end-to-end check against a scratch server make check lint + test + smoke — run before every commit make build ./foodster 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/datastar@v1.0.3/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 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. ```json { "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: ```sh go run ./cmd/foodster -import seeds/testi.json ``` ## 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. The PNGs are committed so the build needs no rasterizer. The commands to regenerate them are under [Occasional commands](#occasional-commands). ## 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. | | `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. `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. 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 by CI when a pull request merges into `main`, and run under Docker Compose on a LAN server. They are OCI images, so either engine works. ```sh # on the server, once CI reports the build finished: 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 you can inspect the file with any sqlite client without 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` 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: ```sh 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](LICENSE).