Structure - Kirjaa and Historia are one page. They were two views of the same thing — every history row already linked into the logger, and the logger had a day switcher. Two tabs instead of three. Also closed a gap: on an already-logged day there was no way to swap to a different dish, only to re-pick its sides. - Ruoat → Ruuat, label and route. - The catalog has a structure. It had no top-level headings at all — the mains simply began with "Liha". Both halves now carry a heading and a count, categories are visibly subordinate, and the add/edit forms collapse instead of filling the screen before any content. Finding things - Dishes grouped by category on both screens, Sekalaiset for multi-category ones. Derived from the stored set, not a fifth category, so one Tortillat still covers all four for the §8.1 suggester later. - Live search on both lists, 250 ms after typing stops. Both remain plain GET forms, so they still filter with JavaScript off. - History is paged 30 days at a time — it previously rendered every day back to the first entry, forever. Correctness - Future meals refused. The picker offered them and ?pvm= accepted them. - today() wasn't midnight, so it never equalled a date parsed from ?pvm= — after saving, the card read "la 5.9. kirjattu" instead of "Tänään kirjattu". - Deletes ask first, for dishes and logged meals. The meal is the more destructive: a dish is only soft-deleted. - DB open failures name the path and uid, instead of unable to open database file (14). Visual - Category icons replace colour dots — steak, drumstick, fish, leaf, quartered circle. - Row actions are a pencil and a bin; the header has a surface. Housekeeping - Datastar SDK dropped — one JSON decode was pulling in four modules including an HTTP compression stack. Five lines replace it. - Release policy documented: main protected, releases arrive as PRs. Co-authored-by: Esa Kataja <[email protected]> Reviewed-on: #1
430 lines
20 KiB
Markdown
430 lines
20 KiB
Markdown
# 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. A single shared
|
||
password gates the whole app (§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. The app *is* reachable from the internet
|
||
(§9, §10), gated by a single shared password over TLS.
|
||
|
||
## 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. Anyone on the
|
||
home network who knows the shared password can open the app and interact with
|
||
it.
|
||
|
||
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.
|
||
|
||
### 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 1–2 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**: HTTP Basic with one shared household password read from
|
||
`FOODSTER_PASSWORD`; the username is ignored. Compared using
|
||
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
|
||
its length leaks through timing. `/healthz` is the only route outside auth.
|
||
- **Exposure**: the app is served on a public hostname behind Traefik, which
|
||
terminates TLS, so Basic credentials are encrypted in transit. A shared
|
||
password is therefore the only thing between the internet and the app, and
|
||
it is guarded by a per-address rate limiter: five wrong guesses, then one
|
||
per ten seconds, answered with `429`. Only requests that actually present
|
||
a wrong password spend the allowance — a request with no `Authorization`
|
||
header is the normal browser handshake that opens every session.
|
||
`X-Forwarded-For` is trusted only when the connection arrived from a
|
||
private address, so a direct client cannot forge a new identity per
|
||
attempt. None of this substitutes for a strong password; it only removes
|
||
brute force as a practical route.
|
||
- **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 locally, 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`. `make image`
|
||
additionally refuses to run outside `main` — that one has to be local,
|
||
because the tag and the image are made before anything reaches the remote.
|
||
- **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,
|
||
creates the new tag, and bakes the version into the binary through
|
||
`-ldflags -X main.version`. `make release` builds, tags and pushes.
|
||
- **Tooling**: a `Makefile` is the single entry point — `make` on its own
|
||
lists every target. Build, test, lint, format, image and compose commands
|
||
all live there rather than in loose scripts.
|
||
- **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 `FOODSTER_UID`/`FOODSTER_GID`
|
||
to match whoever owns that directory. `restart: unless-stopped`.
|
||
- **Configuration**, entirely through environment variables (see
|
||
`.env.example`):
|
||
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates.
|
||
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to
|
||
start without it.
|
||
- `FOODSTER_DB` — database file path, default `./data/foodster.db`. The
|
||
directory is created on startup if missing.
|
||
- `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`.
|
||
- `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
|
||
`FOODSTER_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 is exempt from
|
||
auth. 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. `make vendor` refreshes 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.
|