commit 6ed047aafd25adaea7b03b710c6fd261379c9eea Author: Esa Kataja Date: Sat Sep 5 17:35:37 2026 +0300 Initial commit: PRD, build tooling, and design mockups Foodster is a self-hosted dinner log and meal suggester for one household. Stage 1 (eating history) is in development; stage 2 (the suggester) follows once there is enough history to weight against. Replace the PRD's original stack (Nuxt, Postgres, Drizzle, Pico CSS) with Go 1.27, net/http, templ, Datastar and SQLite — one static binary, no Node.js in the build. Sections 3, 4, 5, 9, 10 and 11 are rewritten to match. Record the decisions made while reviewing mockups: - the UI is written in Finnish; PRD, code and comments stay English - access is one shared password over HTTP Basic rather than open on the LAN - images are CalVer vYYYYMMDD-N, built with Podman, run under Docker Compose - registry coordinates live in .env, so no infrastructure detail is committed and the MIT publication option stays open mockups/ holds standalone HTML design studies. log-fi.html is the current one; the others are superseded exploration kept for reference. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0d93225 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Copy to .env and fill in. .env is gitignored — the real registry hostname +# must not end up in the repository. + +# Image coordinates. FOODSTER_REPO carries no tag. +FOODSTER_REPO=registry.example.com/you/foodster +FOODSTER_TAG=latest + +# Shared household password. The app will not start without it. +FOODSTER_PASSWORD=changeme + +# Host port to publish on. +FOODSTER_PORT=8080 + +# Used for every calendar-day calculation. Set it in development too: under +# UTC the date rolls over three hours late, which is exactly when dinner +# gets logged. +TZ=Europe/Helsinki diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a51c71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Local config — holds the registry hostname and the shared password. +.env + +# Build output +/foodster + +# Generated by `templ generate` during the container build. +*_templ.go + +# Local database +*.db +*.db-shm +*.db-wal diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..a65fb18 --- /dev/null +++ b/Containerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.27-alpine AS build +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN go tool templ generate && \ + CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o /foodster ./cmd/foodster + +# Nothing but the binary. modernc.org/sqlite is pure Go, so there is no libc +# to carry along, and time/tzdata is compiled in because scratch has no +# zoneinfo. +FROM scratch +COPY --from=build /foodster /foodster +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/foodster"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c7f6b7c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kessinen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2e3b804 --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +# Foodster. Run `make` for the target list. + +COMPOSE ?= podman compose +BIN := foodster +PKG := ./cmd/foodster + +# Registry coordinates, shared password and TZ live here. Gitignored. +ifneq (,$(wildcard .env)) +include .env +export +endif + +# Generated templ output is excluded — it is not ours to format. +GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null) + +.DEFAULT_GOAL := help +.PHONY: help generate build run test lint fix image push release up down logs clean + +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ + | awk -F':.*## ' '{printf " \033[1m%-9s\033[0m %s\n", $$1, $$2}' + +generate: ## Generate Go from .templ files + go tool templ generate + +build: generate ## Build ./foodster + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG) + +run: generate ## Run locally on :8080 + FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} \ + FOODSTER_DB=$${FOODSTER_DB:-./foodster.db} \ + go run $(PKG) + +test: generate ## Run tests + go test ./... + +lint: ## go vet, gofmt check, golangci-lint when installed + go vet ./... + @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ + if [ -n "$$bad" ]; then echo "gofmt needed:"; echo "$$bad"; exit 1; fi + @if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run; \ + else echo "golangci-lint not installed - skipped"; fi + +fix: ## Format Go and templ sources, tidy go.mod + @if [ -n "$(GOFILES)" ]; then gofmt -w $(GOFILES); fi + go tool templ fmt . + go mod tidy + +image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. + @test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } + @day=$$(date +%Y%m%d); \ + tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \ + echo "==> $$tag"; \ + git tag "$$tag"; \ + podman build --platform linux/amd64 --build-arg VERSION="$$tag" \ + -t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . + +push: ## Push the newest tag and :latest + @test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } + @tag=$$(git tag -l 'v*' --sort=-creatordate | head -n1); \ + test -n "$$tag" || { echo "no tags yet - run make image"; exit 1; }; \ + podman push "$(FOODSTER_REPO):$$tag"; \ + podman push "$(FOODSTER_REPO):latest" + +release: image push ## Build, tag and push in one go + +up: ## Start the stack + $(COMPOSE) up -d + +down: ## Stop the stack + $(COMPOSE) down + +logs: ## Follow app logs + $(COMPOSE) logs -f app + +clean: ## Remove the binary and generated templates + rm -f $(BIN) + find . -name '*_templ.go' -delete diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..a091568 --- /dev/null +++ b/PRD.md @@ -0,0 +1,366 @@ +# 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 external internet exposure. Runs on the home LAN. + +## 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 Title Case (server normalizes on save). +- `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 Title Case. +- `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 Title Case on save. Delete is a soft-delete. +- Add / edit / delete side dishes. Same Title 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 Title-Cased + 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.)* + +## 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. +- **Schema**: a single `schema.sql` embedded with `embed.FS` and applied on + startup with `CREATE TABLE IF NOT EXISTS`. A migration tool arrives the + first time a live table genuinely needs altering, not before. +- **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. A failed attempt sleeps 500 ms, which is + throttle enough for a LAN-only app. Note that Basic credentials travel in + cleartext over plain HTTP — acceptable on a private LAN, and the reason to + add TLS if this is ever reachable from anywhere else. `/healthz` is the + only route outside auth. +- **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. + +- **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` on a named volume. `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`. + - `TZ` — default `Europe/Helsinki`. +- The registry hostname exists only in `.env`, which is gitignored, because + §11 leaves open the possibility of publishing this repository. +- **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. +- Schema is applied on app start. +- 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..67e4f2e --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# 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 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. + +Stage 2 — the seven-meal suggester — starts once that history exists. + +## 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` | + +## 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 build ./foodster +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 +``` + +## 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. | +| `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. + +```sh +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 on a named volume, so a backup +is a file copy. + +## 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](LICENSE). diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..d04a75b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,15 @@ +services: + app: + image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest} + restart: unless-stopped + ports: + - "${FOODSTER_PORT:-8080}:8080" + environment: + FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env} + FOODSTER_DB: /data/foodster.db + TZ: ${TZ:-Europe/Helsinki} + volumes: + - foodster-data:/data + +volumes: + foodster-data: diff --git a/mockups/a-chit-rail.html b/mockups/a-chit-rail.html new file mode 100644 index 0000000..6e99239 --- /dev/null +++ b/mockups/a-chit-rail.html @@ -0,0 +1,229 @@ + + + + + +Foodster — A · Chit rail + + + + + + +
+ +
+ + + +
+ +
+

Seven
dinners

+

Drawn from what this house actually eats. Swap anything that doesn't appeal — nothing here is a schedule.

+ +
+ +
+ No fish + Every fish dish was eaten in the last 14 days. Fish sits this week out — shorten the cooldown in Catalog if that's too strict. +
+ +
+
+ +
+
meat
+

Jauheliha­kastike

+
  • Perunamuusi
  • Vihersalaatti
+

last eaten 23 days ago

+
+
+ +
+
chicken
+

Broileri­kastike

+
  • Riisi
+

last eaten 31 days ago

+
+
+ +
+
+ meatchicken + fishveg +
+

Tortillat

+

served on its own

+

last eaten 40 days ago

+
+ + +
+
+ +
+
vegetarian
+

Kasvis­pyörykät

+
  • Perunamuusi
  • Höyrytetyt Porkkanat
+

last eaten 52 days ago

+
+
+ +
+
meat
+

Makaroni­laatikko

+

served on its own

+

last eaten 19 days ago

+
+
+ +
+
vegetarian
+

Hernekeitto

+

served on its own

+

last eaten 27 days ago

+
+
+ +
+
meat
+

Lihapullat

+
  • Perunamuusi
  • Vihersalaatti
+

last eaten 16 days ago

+
+
+ +
+
+ +
+

Recently eaten

+
LohikeittoRuisleipä
+
KanacurryRiisi
+
UunimakkaraPerunamuusi · Vihersalaatti
+
+ +
+ + + diff --git a/mockups/b-enamel-tile.html b/mockups/b-enamel-tile.html new file mode 100644 index 0000000..d623fae --- /dev/null +++ b/mockups/b-enamel-tile.html @@ -0,0 +1,214 @@ + + + + + +Foodster — B · Enamel tile + + + + + + +
+ +
+ + + +
+ +
+

Seven dinners, no schedule.

+

Pick any of these on any night. Swap what doesn't appeal.

+
+ +
+ +
+ Meat ×3 + Chicken ×1 + Fish ×1 + Vegetarian ×2 +
+
+ +
+ +
+ +
+

Meat

+

Jauhelihakastike

+

Perunamuusi · Vihersalaatti

+

Last eaten 23 days ago

+
+
+ +
+

Chicken

+

Broilerikastike

+

Riisi

+

Last eaten 31 days ago

+
+
+ +
+

Meat · Chicken · Fish · Veg

+

Tortillat

+

Served on its own

+

Last eaten 40 days ago

+
+ + +
+
+ +
+

Fish

+

Uunilohi

+

Riisi · Vihersalaatti

+

Last eaten 34 days ago

+
+
+ +
+

Vegetarian

+

Kasvispyörykät

+

Perunamuusi · Höyrytetyt Porkkanat

+

Last eaten 52 days ago

+
+
+ +
+

Meat

+

Makaronilaatikko

+

Served on its own

+

Last eaten 19 days ago

+
+
+ +
+

Vegetarian

+

Hernekeitto

+

Served on its own

+

Last eaten 27 days ago

+
+
+ +
+ +
+

Recently eaten

+
LohikeittoRuisleipä
+
KanacurryRiisi
+
UunimakkaraPerunamuusi · Vihersalaatti
+
+ +
+ + + diff --git a/mockups/c-fridge-board.html b/mockups/c-fridge-board.html new file mode 100644 index 0000000..e6040d9 --- /dev/null +++ b/mockups/c-fridge-board.html @@ -0,0 +1,199 @@ + + + + + +Foodster — C · Fridge board + + + + + + +
+ +
+ + + +
+ +
+

Seven dinners on the door.

+

Take them in any order. Swap what nobody's in the mood for.

+

— last regenerated Sunday

+
+ +
+ +
+ +
+

Meat

+

Jauhelihakastike

+

Perunamuusi · Vihersalaatti

+

Last eaten 23 days ago

+
+
+ +
+

Chicken

+

Broilerikastike

+

Riisi

+

Last eaten 31 days ago

+
+
+ +
+

Meat · Chicken · Fish · Veg

+

Tortillat

+

Served on its own

+

Last eaten 40 days ago

+
+ + +
+
+ +
+

Fish

+

Uunilohi

+

Riisi · Vihersalaatti

+

Last eaten 34 days ago

+
+
+ +
+

Vegetarian

+

Kasvispyörykät

+

Perunamuusi · Höyrytetyt Porkkanat

+

Last eaten 52 days ago

+
+
+ +
+

Meat

+

Makaronilaatikko

+

Served on its own

+

Last eaten 19 days ago

+
+
+ +
+

Vegetarian

+

Hernekeitto

+

Served on its own

+

Last eaten 27 days ago

+
+
+ +
+ +
+

What we actually ate

+

Printed above is the app guessing. Below is the house, on the record.

+
Lohikeittoruisleipä
+
Kanacurryriisi
+
Uunimakkaraperunamuusi, vihersalaatti
+
+ +
+ + + diff --git a/mockups/log-fi.html b/mockups/log-fi.html new file mode 100644 index 0000000..53eeb57 --- /dev/null +++ b/mockups/log-fi.html @@ -0,0 +1,345 @@ + + + + + +Foodster — kirjaus ja historia + + + + + + +
+ +
+

Foodster · vaihe 1

+

390 px · Kirjaa, Historia, ja jo kirjattu ‑tila

+ +
+ +
+ + + +
+ +
+ + +
+

1 · Kirjaa — napauta ruoka, valitse lisukkeet

+
+
+
+

Mitä syötiin?

+
+ + + +
+
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+

Lohikeitto

+

Lisukkeita?

+
+ + + + +
+ + +
+
+ +
+
+ + +
+

2 · Päivä jo kirjattu — yksi merkintä per päivä

+
+
+
+

Mitä syötiin?

+
+ + + +
+
+
+
+

Tänään kirjattu

+

Lohikeitto

+

Ruisleipä

+
+ + +
+
+

Väärä päivä?

+
+
+ +
+
+ + +
+

3 · Historia — lista, ei kalenteria

+
+
+
+

Historia

+
+
+
Syyskuu
+
Ei merkintää
+
+ +
Lihapullat
Vihersalaatti
+ +
+
+ +
Lohikeitto
Ruisleipä
+ +
+
+ +
Kanacurry
Riisi
+ +
+ +
Elokuu
+
+ +
Uunimakkara
Perunamuusi
+ +
+
+ +
Kasvispyörykät
Ei lisukkeita
+ +
+
+ +
Lihapullat
Perunamuusi
+ +
+
Ei merkintää
+
+ +
Kanacurry
Riisi
+ +
+
+ +
Jauhelihakastike
Perunamuusi, vihersalaatti
+ +
+
+
+ +
+
+ +
+
+ + + diff --git a/mockups/log-layouts.html b/mockups/log-layouts.html new file mode 100644 index 0000000..3c96097 --- /dev/null +++ b/mockups/log-layouts.html @@ -0,0 +1,440 @@ + + + + + +Foodster — stage 1 log · mobile layouts + + + + + + +
+ +
+

Foodster · stage 1 log

+

390 px. Three structures, one skin.

+ +
+ +
+ + + +
+ +
+ + +
+

1 · Calendar-led — history first, calendar navigates

+
+
+
+

What we ate

+

Last five weeks · 3 Aug – 6 Sep

+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ 30of 35 logged + +
+ +
+

Tonight · Sat 5 Sep

+ +
+ +

Recent

+
+ +
Lihapullat
Vihersalaatti
+ +
+
+ +
Lohikeitto
Ruisleipä
+ +
+
+ +
Kanacurry
Riisi
+ +
+
+
+ +
+
+ + +
+

2 · Tap board — fastest entry, sheet in the thumb zone

+
+
+
+

Tap what you ate

+
+ + + +
+
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+

Lohikeitto

+

Anything on the side?

+
+ + + + +
+ + +
+
+ +
+
+ + +
+

3 · Journal — one column, gaps called out inline

+
+
+
+

Dinner journal

+

One line per night

+
+
+ +
+

Tonight · Sat 5 Sep

+

Nothing written down yet.

+ +
+ +
September
+ +
Not logged
+ +
+ +
Lihapullat
Vihersalaatti
+ +
+
+ +
Lohikeitto
Ruisleipä
+ +
+
+ +
Kanacurry
Riisi
+ +
+ +
August
+ +
+ +
Uunimakkara
Perunamuusi
+ +
+
+ +
Kasvispyörykät
Served on its own
+ +
+
+ +
Lihapullat
Perunamuusi
+ +
+
Not logged
+
+ +
Kanacurry
Riisi
+ +
+
+
+ +
+
+ +
+
+ + + +