Files
foodster/PRD.md
T
Esa Kataja cf2cb0ce0a
check / check (push) Successful in 46s
feat!: drop the built-in auth in favour of Authelia
BREAKING CHANGE: PASSWORD is gone and AUTH and CERTRESOLVER are required.
The server's compose.yaml and .env must be updated in the same deploy — the
new image ignores PASSWORD, and the old one refuses to start without it.

Authelia now sits in front of Traefik, so the app was asking for a second
password at the same door. Two prompts, and the weaker of the two was the one
holding a single shared secret with no sessions, no MFA and no revocation.
Deleting it is the whole change: Authelia already does this properly, once,
for every service on the host.

Gone: auth(), challenge(), the whole of throttle.go and its tests, and
golang.org/x/time with them. routes() returns the bare mux, /healthz is an
ordinary route on it, and the smoke script drops sixty -u flags. Roughly 230
lines removed and nothing written to replace them.

What holds the app up now, both asserted in compose.yaml:

- The router names the Authelia middleware through AUTH. Traefik takes a
  router out of service when its middleware does not resolve, so a typo or an
  unset variable fails shut rather than serving the app open.
- The container still publishes no ports, so the proxy is the only thing that
  can reach it. Publishing 8080 would now bypass authentication outright, not
  merely TLS — the comment there says so.

certresolver replaces the bare tls=true, parameterised as CERTRESOLVER: the
server had been carrying that label by hand since the first deploy. Naming a
resolver implies tls=true, so it stays one label.

TestAuth and TestHealthzSkipsAuth are replaced by one test asserting every
route answers without credentials — a 401 from here would now mean auth had
crept back in.
2026-09-06 13:39:35 +03:00

459 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Foodster — Product Requirements Document
## 1. Overview
Foodster is a self-hosted web app that helps a single household decide what to
eat for the next seven dinners. Given a database of meals the family already
likes, the app proposes a week's worth of dinners (main dish, optional side
dishes) using their eating history as input. The proposal is a *suggestion*,
not a schedule — the user can swap items, ignore the suggestions entirely, or
log what was actually eaten after the fact.
The project is built for one family's use. If it matures into something
polished, it may be released as FOSS under MIT.
## 2. Goals
- Reduce weekly "what's for dinner?" decision fatigue.
- Keep meal variety high while still leaning on family favorites.
- Capture a history of what was actually eaten (not what was planned) so the
suggester gets better over time.
- Stay boring to run: a single `docker compose up` on a home server.
## 3. Non-goals (MVP)
- No grocery list generation (possible future add-on).
- No per-recipe ingredient tracking — meals are just names.
- No calendar/scheduling with times, reminders, or calendar exports.
- No user accounts, per-person profiles, or permissions *in the app*.
Authentication is the reverse proxy's job (§9).
- No nutrition tracking, calorie counting, or dietary-goal optimization.
- No mobile-native apps. Web only (mobile-friendly responsive is enough).
- No per-user accounts or sessions in the app. It *is* reachable from the
internet (§9, §10), behind Authelia at the proxy.
## 4. Delivery stages
The app ships in two stages. The suggester needs real eating history to
produce useful weightings, so history capture comes first and the family
seeds the database by logging meals for a few weeks before the suggester
has anything meaningful to do.
### Stage 1 — Eating history
Everything needed to manage the meal catalog and log what was eaten.
Concretely:
- Domain model: main dishes (with `categories` and `has_sides`), side
dishes, meal log entries (§6).
- Admin view: add / edit / delete mains and sides, JSON mass import (§7.3).
- Meal log view ("Mark"): date picker, main + sides selector, recent
entries list with edit/delete (§7.2).
- Deployment scaffolding: `compose.yaml`, SQLite, schema applied on app
start (§9, §10).
At the end of stage 1 the app is useful as a "what did we eat?" journal
even without any suggestions. There is no Plan view yet.
### Stage 2 — Meal suggester
Layered on top of the stage 1 foundation:
- Current suggestion cache (§6) persisted server-side.
- Plan view with seven suggested meals, two swap modes per slot, and
regenerate-all (§7.1).
- Suggestion algorithm: cooldown, category coverage with multi-category
mains, favorites-biased weighting with a floor, side attachment rules
(§8).
- Settings page for the cooldown window (§7.3).
Stage 2 can start once stage 1 has collected enough history for the
weighting to be meaningful (a few weeks of logged meals).
## 5. Users
A single household. One shared instance, no per-person accounts. Everyone who
gets past Authelia sees and edits the same log; the app draws no distinction
between them.
The interface is written in **Finnish** — every user of this instance is a
Finnish speaker, so there is no i18n layer and no language switcher. Strings
are hardcoded in the templates. This PRD, the source, and code comments stay
in English.
## 6. Domain model
### Main dish (stage 1)
- `id`
- `name` — stored in sentence case: the server collapses whitespace and
capitalizes the first letter only, leaving the rest as typed. Finnish
capitalizes just the first word of a phrase, so "Keitetyt perunat" is
correct and "Keitetyt Perunat" is not. Existing capitals are preserved, so
"BBQ-kylkeä" and "Kotipizza" survive.
- `categories` — non-empty set drawn from `meat`, `chicken`, `fish`,
`vegetarian`. Most mains will have a single category. Dishes where each
diner builds their own plate from a shared spread (e.g., tortillas,
build-your-own pizza) list every category present at the meal; one
appearance of such a main simultaneously covers **all** its listed
categories for that week, because all the proteins are actually served at
the same meal.
- `has_sides` — boolean. If `false`, the suggester never attaches side dishes
to this main (covers casseroles, one-pot meals, etc.).
- `created_at`
- `deleted_at` — nullable. Soft-delete marker: soft-deleted mains are hidden
from the admin list and all pickers, but remain resolvable when displaying
historical log entries.
### Side dish (stage 1)
- `id`
- `name` — stored in sentence case, as for mains.
- `created_at`
- `deleted_at` — nullable. Same soft-delete semantics as mains.
Side dishes live in their own table and have no category. The pool is
expected to stay small.
### Tähteet — leftovers (stage 1)
A single built-in entry, flagged `special` on the main dish table. It is
**not food**: it exists so a day can be recorded as "we ate what was already
there" without inventing a meal that was never cooked.
- No category, which is why it cannot be an ordinary main: those must have
at least one.
- Created by a migration. The household does not add, edit or delete it, and
it never appears in the Ruuat catalog.
- Loggable exactly like any other entry, and shown on the log board apart
from the categories.
- **The stage 2 suggester must never propose it.** It is excluded from the
eligible pool outright, so cooldown, category coverage (§8.1) and
frequency weighting (§8.2) all skip it — despite it being among the
most-logged entries.
### Meal log entry ("what was actually eaten") (stage 1)
- `id`
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field
and no timezone concern; the cooldown and display logic compare dates as
pure calendar days.
- `main_dish_id`
- `side_dish_ids` (zero or more)
- `created_at`
A unique constraint enforces **one log entry per date** — editing an
existing entry replaces it; no second meal can be logged for the same day.
The log entry is the source of truth for history. Foreign keys to mains
and sides are preserved even after the referenced item is soft-deleted, so
old entries still display their meal names.
### Current suggestion (cache) (stage 2)
To survive accidental tab/browser closes and crashes, the seven-meal
suggestion is persisted server-side until the user regenerates it. A single
row keyed by "current suggestion" is enough — there's only ever one active
list. Reopening the app shows the same seven meals until the user explicitly
regenerates or swaps.
- `id`
- `slots` — ordered list of `{ main_dish_id, side_dish_ids[] }`
- `updated_at`
## 7. Features
### 7.1 Suggestion view ("Plan") (stage 2)
- Displays seven suggested meals.
- Each meal = one main dish + zero or more side dishes (sides only if the
main's `has_sides` flag is true).
- Each meal position has two swap controls:
- **Swap (same category)** — default. Replaces the main with a different
main of the same category.
- **Swap (any category)** — replaces the main with any eligible main
regardless of category. May break category coverage; see §8.1.
- Both honor the cooldown.
- A **regenerate all** action produces a fresh list of seven and overwrites
the cached current suggestion.
- No dates, no ordering beyond "these are the next seven." Order is not
tied to calendar days.
- The suggestion list is the front page / default view, and is restored from
the server-side cache on page load so a browser crash doesn't lose it.
### 7.2 Meal log view ("Mark") (stage 1)
- Form to record what was actually eaten:
- Date picker (defaults to today).
- Main dish selector.
- Side dish selector (multi-select, optional).
- List of recent log entries with edit/delete.
- Entries are editable and deletable — history is not sacred, typos happen.
### 7.3 Admin (stage 1 — catalog; stage 2 — settings)
- Add / edit / delete main dishes (with categories and `has_sides`). Names
are normalized to sentence case on save. Delete is a soft-delete.
- Add / edit / delete side dishes. Same sentence-case normalization and
soft-delete behavior.
- Duplicate detection on create/edit is **case-insensitive** against the
set of non-soft-deleted items ("chicken curry" and "Chicken Curry" are
treated as the same name).
- **Mass import** of mains and sides via JSON paste or file upload.
- JSON shape (proposed):
```json
{
"mains": [
{"name": "Spaghetti Bolognese", "categories": ["meat"], "has_sides": false},
{"name": "Chicken Curry", "categories": ["chicken"], "has_sides": true},
{"name": "Tortillas", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
],
"sides": [
{"name": "Green salad"},
{"name": "Rice"}
]
}
```
- Import is **best-effort, not atomic**: valid rows are inserted, invalid
rows (bad categories, missing fields, duplicates) are skipped and
reported back to the user in a per-row summary. Names are normalized to
sentence case on import; duplicate detection is case-insensitive.
- Settings page for the cooldown window (default **14 days**, configurable).
*(Stage 2 — only relevant once the suggester exists.)*
### 7.4 Appearance (stage 1)
- A light / dark theme switch, remembered **per device**. The instance is
shared and has no accounts, so this is a device preference in
`localStorage`, never a server-side setting — the phone in the kitchen and
the laptop must be able to disagree.
- Two states only, **dark by default**. Following the system was considered
and dropped: a third state costs a control that is harder to read than the
choice is worth.
- One button, and its icon **is the theme that is currently on** — a moon
while dark, a sun while light. Not the state a click would produce. A
lightswitch does not read "off" while the lights are on. The accessible
label names the state first and the action second: "Tumma teema. Vaihda
vaaleaan."
- Implementation is `color-scheme` on the root element: `only light` or
`only dark`. The default is in the server-rendered markup, so it survives
having no JavaScript. Because the palette is built from `light-dark()`
custom properties, no other CSS changes.
## 8. Suggestion algorithm (stage 2)
Produce seven mains, then attach sides per meal.
### 8.1 Hard constraints (mains)
1. **Cooldown**: exclude any main dish that appears in the meal log within
the last *N* days, where *N* is configurable (default 14).
2. **Category coverage**: the union of all seven mains' `categories` sets
must include `meat`, `chicken`, `fish`, and `vegetarian`. A
multi-category main covers every category in its set at once, so a
single tortillas appearance can satisfy several requirements
simultaneously.
3. If a category cannot be covered by any eligible main (including
multi-category ones) after cooldown, the UI surfaces a clear warning and
that category is **excluded from this round's list**. The seven meals
are drawn from the remaining mains. Cooldown is never silently relaxed.
### 8.2 Weighting (mains)
Within the eligible pool, each main's probability of being picked is
proportional to how often it appears in the meal log — favorites are picked
**more often**, not less. This is a deliberate inversion of the typical
"recommend what you haven't tried" heuristic: the household wants to keep
eating what they already like.
To prevent the list from being dominated by the top few favorites, the
weight is clamped so that no eligible main has a zero or near-zero
probability. Concretely (MVP):
```
weight(main) = max(base_weight, times_eaten)
```
where `base_weight` is a small positive floor (e.g., `1`). Sampling is
without replacement within the seven-meal list.
This is tunable later; the important invariant is **no dead meals**.
### 8.3 Sides
- Only mains with `has_sides = true` get sides attached. Mains with
`has_sides = false` (casseroles, one-pots) always get zero sides.
- For eligible mains, pick 12 side dishes uniformly at random from the
side pool. No cooldown, no frequency weighting on sides.
### 8.4 Swap action
Two swap modes, exposed as separate controls on each slot:
- **Swap within same category** (default). The replacement main's
`categories` must be a superset of the slot's current `categories`, so
the week's coverage cannot be weakened by the swap.
- For a single-category slot, any main sharing that category qualifies.
- For a multi-category slot (e.g., tortillas), only mains covering the
same full set qualify — often only other wildcards. If nothing
qualifies, the button is disabled and the user falls back to "any
category".
- **Swap any category**. The replacement can be any eligible main. If this
breaks coverage (e.g., removes the only fish), the UI warns but allows
it — the user is explicitly asking for freedom.
Both modes honor the cooldown. After a swap, the cached current suggestion
is updated in place.
## 9. Tech stack
The whole app is one static Go binary. There is no Node.js anywhere in the
build and no asset bundler.
- **Language**: Go 1.27. No `toolchain` directive is pinned in `go.mod`, so
moving to a newer Go is a one-line change.
- **HTTP**: standard library `net/http` with `http.ServeMux`. Its method +
path patterns cover the ~15 routes this app needs; no third-party router.
- **Views**: [templ](https://templ.guide) components, rendered server-side.
`templ` is a `go tool` dependency and runs at build time; the generated
`*_templ.go` files are not committed.
- **Interactivity**: [Datastar](https://data-star.dev). One ~11 kB script
supplies both the reactive client state and the server-driven DOM patching,
replacing what would otherwise be htmx *and* Alpine.js. Its reference SDK
is Go (`github.com/starfederation/datastar-go`): handlers read client state
with `datastar.ReadSignals` and reply over SSE with `PatchElements`.
- **Styling**: hand-written CSS, one file, no framework and no build step.
Light and dark themes come from `color-scheme: light dark` plus
`light-dark()` custom properties, which also gets native form controls,
scrollbars, and focus rings themed for free. Explicitly not Pico, not
Tailwind, not SCSS.
- **Fonts**: self-hosted `woff2` under `static/`. No external font CDN — the
app has to work with no internet access at all.
- **Database**: SQLite through `modernc.org/sqlite`, which is pure Go and so
keeps `CGO_ENABLED=0` and a fully static binary. One file on a named
volume; there is no separate database service. A single household writing
one row per day does not need Postgres.
- **Data access**: `database/sql` and hand-written SQL. No ORM.
- **Migrations**: numbered `.sql` files under `cmd/foodster/migrations`,
embedded with `embed.FS` and applied in filename order on startup. Each one
runs inside a transaction and is recorded in a `schema_migrations` table, so
it applies exactly once. No migration library — the runner is about sixty
lines of `database/sql`. There are no down-migrations: restoring the
database file is the rollback for a single-household app.
- **Bundle import**: the §7.3 mass import is a live feature of the running
app, on the Ruuat tab — paste JSON or upload a file, get a per-row report
back. A plain multipart form rather than a Datastar round trip, since the
response is a whole-page report and a form needs no client code. Uploads
are capped at 1 MiB. The same importer is also reachable as
`foodster -import <file.json>` for repopulating a scratch database without
starting the server; `seeds/testi.json` is the committed fixture. One code
path serves both, so the format is exercised twice.
- **Time**: the `TZ` environment variable, defaulting to `Europe/Helsinki`,
loaded via `time.LoadLocation` and fatal on a bad value — a silent fallback
to UTC would shift logged dinners to the wrong calendar day. `time/tzdata`
is imported because the runtime image carries no zoneinfo. All date logic
uses that location explicitly and never `time.Local`.
- **Auth**: none in the app. Every route is served unauthenticated, because
the only client that can reach the app is Traefik, which forwards each
request to **Authelia** first. Sessions, brute-force protection and
multi-factor are configured there once for every service on the host.
Deliberately not reimplemented per app: the earlier in-app HTTP Basic layer
meant two prompts for one door, and the weaker of the two was the one
holding a shared password.
- **Exposure**: served on a public hostname behind Traefik, which terminates
TLS. Two invariants carry the whole security model, and both are asserted
in `compose.yaml`. The router names the Authelia middleware through `AUTH`
— unset or misspelt, Traefik takes the router out of service, so a typo
fails shut. And the container publishes no ports, so it is reachable only
over the shared proxy network; publishing `8080` would expose an
unauthenticated plaintext copy on the host. `/healthz` returns only the
version and is safe to bypass in Authelia for monitoring.
- **Containers**: built with Podman in development, run under Docker Compose
in production. Images are OCI, so one image works with both engines.
Explicitly *not* React.
## 10. Deployment
Images are built by CI, pushed to a private container registry, then pulled on
the server and run with Docker Compose.
- **Branches**: `main` carries released versions only, so its history is the
deployment history and every release tag points into it. Development happens
on `dev`, and `main` is protected on the remote: it accepts no direct
pushes, so a release arrives as a pull request from `dev`. The release
workflow runs only on `main`, so a release cannot be built from anywhere
else and nothing needs to check for it.
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that
day. The release workflow derives `N` by counting the day's existing git
tags, creates the new tag, and bakes the version into the binary through
`-ldflags -X main.version`.
- **CI**: Gitea Actions, workflows in `.gitea/workflows/`. `check.yaml` runs
`make check` on every push to `dev`; `release.yaml` builds and pushes the
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
static binary; the runtime stage is `FROM scratch` holding only that
binary, running as UID 65534.
- **Compose**: a single service. No database container — SQLite lives at
`/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
through the container engine. Backup is `cp -r data`. Because the image
runs as UID 65534, compose sets `user:` from `PUID`/`PGID`
to match whoever owns that directory. `restart: unless-stopped`.
- **Configuration**, entirely through environment variables (see
`.env.example`):
Names carry no application prefix: the container namespaces them already.
- `REPO` and `TAG` — image coordinates.
- `AUTH` — the Traefik middleware that authenticates the app, e.g.
`authelia@docker`. Required; it is the app's only access control.
- `HOST` and `CERTRESOLVER` — the hostname Traefik matches on and the
resolver that issues its certificate.
- `DB` — database file path, default `./data/foodster.db`. The directory is
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`.
- The registry hostname exists only in `.env`, which is gitignored, because
§11 leaves open the possibility of publishing this repository.
- **Routing**: Traefik on an external `traefik` network, matching on
`HOST` and terminating TLS. The container publishes no ports —
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
infrastructure detail is committed.
- **Health**: `GET /healthz` returns the build version and nothing else, so it
is safe to exempt in Authelia. There is no Docker `HEALTHCHECK` directive,
because a `scratch` image
has no shell to run one and `restart: unless-stopped` already covers a dead
process. Adding one would mean giving the binary a `-healthcheck` flag that
calls its own endpoint.
- Pending migrations are applied on app start.
- 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
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.
- No internet exposure; the server binds to the LAN.
## 11. Licensing
MIT from the outset — see `LICENSE`, copyright Kessinen. The repository stays
private during build-out, so releasing it later is a matter of flipping
visibility rather than relicensing.
This is why no infrastructure detail (registry hostname, server name, port
mapping) may be committed: those live in `.env`, which is gitignored.
## 12. Open questions / future work
- Grocery list generation from the seven-meal suggestion (requires adding
ingredients to the meal model — out of scope for MVP).
- Tags on mains (e.g., "quick", "summer", "comfort") to filter suggestions.
- Seasonal biasing (e.g., soups in winter).
- Optional "pin" on a suggested slot so `regenerate all` doesn't replace it.
- Backup / export of the meal log (JSON dump).
- Telemetry-free analytics: a stats page showing most-eaten mains, category
distribution over time, etc.