1 Commits
Author SHA1 Message Date
Esa Kataja 2a148aa2a8 Make releases push what they built
A release reported success while uploading the previous one a second time.
Three separate faults, one of which hid the others.

release declared image and push as prerequisites. Make runs targets in
parallel by default here (-j16), so push resolved a tag and uploaded :latest
before image had finished building and tagging. They are sub-makes now, as
check already was.

push re-derived the tag with `git tag --sort=-creatordate | head -1`. That is
ambiguous when two tags point at the same commit, so it could pick the wrong
one even without a race — and re-deriving is what made the race possible at
all. image now records what it built in .release-tag and push reads it.

Nothing compared what was built against what arrived, so the failure was
silent: the build log said "Successfully tagged v...-3" while the registry
received the older image. push now pulls each tag back afterwards and
compares image ids, failing if the registry serves something else.

The tag ambiguity surfaced because a test of the failure path did not fail.
That was worth more than the fix it was checking.
2026-09-05 22:42:30 +03:00
16 changed files with 184 additions and 865 deletions
+7 -14
View File
@@ -1,29 +1,22 @@
# Copy to .env and fill in. .env is gitignored — the real registry hostname
# must not end up in the repository.
# Image coordinates. REPO carries no tag.
REPO=registry.example.com/you/foodster
TAG=latest
# 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.
PASSWORD=changeme
FOODSTER_PASSWORD=changeme
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no
# infrastructure detail is committed.
HOST=foodster.example.com
# Anything other than prod is written into the browser tab title, so a dev
# instance open beside the real one can be told apart.
ENV=prod
FOODSTER_HOST=foodster.example.com
# The database lives in ./data, bind-mounted into the container. These must
# match whoever owns that directory on the host, or the container cannot
# write to it. `id -u` and `id -g` will tell you.
#
# Named PUID/PGID because UID is read-only in bash and a plain UID here would
# be quietly replaced by the invoking shell's own.
PUID=1000
PGID=1000
FOODSTER_UID=1000
FOODSTER_GID=1000
# 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
-94
View File
@@ -1,94 +0,0 @@
# Contributing
A household project, so this is less a set of rules than a note to whoever
picks it up next — including me in six months.
## Getting set up
```sh
cp .env.example .env # then edit it
make run # http://localhost:8080, tab titled "dev · Foodster"
make # every target, with a one-line description
```
`make check` is the gate: `go vet`, gofmt, unit tests, and `scripts/smoke.sh`,
which drives a real server over HTTP. Run it before every commit.
## Branches
`dev` is where work happens. `main` holds released versions only — it is
protected on the remote and takes no direct pushes, so a release arrives as a
pull request from `dev`, squash-merged.
After a squash merge, reset `dev` onto it or the next pull request will offer
the same commits again:
```sh
git switch main && git pull --ff-only
git switch dev && git reset --hard main
git push --force-with-lease origin dev
```
`make image` refuses to run outside `main`. That check has to be local: the
tag and the image are made before anything reaches the remote, so branch
protection cannot catch a release built from the wrong branch.
## Commit messages
Conventional Commits — a type, an optional scope, then a short subject in the
imperative.
```
feat(kirjaa): expand the selected day in place
fix: redirect the catalog to /ruuat, not /ruoat
chore(deps): bump the vendored Datastar client
```
| Type | For |
|---|---|
| `feat` | new behaviour someone will notice |
| `fix` | a bug, ideally naming what broke |
| `refactor` | same behaviour, different shape |
| `test` | tests only |
| `docs` | documentation only |
| `build` | Makefile, Containerfile, compose, CI |
| `chore` | anything else: dependencies, seeds, tidying |
**The body matters more than the type.** Explain *why*, and what the
alternative was — the diff already says what changed. If a fix was subtle,
say what made it subtle; if a test caught something, say what. Commits here
are the only design record this project has.
### Release pull requests
Because `main` is squash-merged, a pull request title becomes a commit message
on `main`. A release spans a fix, a feature and some chores at once, so none
of the types above fits it honestly. Use `release:` instead:
```
release: repair the catalog 404 and stop the page jumping
```
`main`'s log is then one line per deployment, which is what that branch is
for, and the pull request body serves as the release notes. No version in the
title — the CalVer tag is not created until `make image` runs after the merge.
The types above are for `dev`, where a commit really does do one thing.
## Things that are easy to get wrong
- **The interface is Finnish.** Code, comments, this file and the PRD are
English. There is no i18n layer and no language switcher.
- **No infrastructure detail is committed** — no hostnames, registry paths or
ports. They live in `.env`, which is gitignored, because the PRD leaves the
door open to publishing this repository.
- **Migrations are immutable once shipped.** A released migration has run on a
live database and will not run again. Add a new numbered file instead.
- **Interactions patch, they do not navigate.** Anything that reloads the page
loses the scroll position, which on a long list is maddening. Links stay
links and forms stay forms so it works without JavaScript; Datastar layers
over them with `data-on:click__prevent` and `data-on:submit__prevent`.
- **A `ponytail:` comment marks a deliberate shortcut** and names its ceiling,
so the next reader can tell a decision from an oversight.
- **Assert what a response does, not just that it responded.** A redirect to a
dead URL is still a 303; that one shipped.
+9 -9
View File
@@ -38,7 +38,7 @@ build: generate ## Build ./foodster
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
run: generate ## Run locally on :8080 (database in ./data)
PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG)
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG)
seed: ## Import a dish bundle (SEED=seeds/testi.json)
go run $(PKG) -import $(SEED)
@@ -85,7 +85,7 @@ fix: ## Format Go and templ sources, tidy go.mod
go mod tidy
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
@# A release tag must point into main, or the tag records a commit that
@# was never released.
@branch=$$(git symbolic-ref --short HEAD); \
@@ -99,7 +99,7 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
echo "==> $$tag"; \
git tag "$$tag"; \
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
-t "$(REPO):$$tag" -t "$(REPO):latest" . ; \
-t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . ; \
echo "$$tag" > $(TAGFILE)
# Pushing reported success while uploading the previous release once, because
@@ -107,18 +107,18 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
# registry what it actually serves for each tag and fail if it is not the
# image we just built.
push: ## Push the newest tag and :latest, then verify the registry
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
tag=$$(cat $(TAGFILE)); \
built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \
podman push "$(REPO):$$tag"; \
podman push "$(REPO):latest"; \
podman push "$(FOODSTER_REPO):$$tag"; \
podman push "$(FOODSTER_REPO):latest"; \
echo "==> verifying $$tag"; \
for ref in "$$tag" latest; do \
podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \
podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \
served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \
served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \
if [ "$$served" != "$$built" ]; then \
echo " FAIL $$ref serves $$served"; \
echo " expected $$built"; \
+9 -30
View File
@@ -113,23 +113,6 @@ in English.
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
@@ -351,7 +334,7 @@ build and no asset bundler.
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
`PASSWORD`; the username is ignored. Compared using
`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
@@ -395,25 +378,21 @@ on the server and run with Docker Compose.
`/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`
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`):
Names carry no application prefix: the container namespaces them already.
- `REPO` and `TAG` — image coordinates.
- `PASSWORD` — the shared password. Required; the app refuses to start
without it.
- `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.
- `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
`HOST` and terminating TLS. The container publishes no ports —
`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.
+9 -18
View File
@@ -58,9 +58,6 @@ One static Go binary. No Node.js, no bundler, no separate database server.
| 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
@@ -176,19 +173,13 @@ Everything is environment variables. `.env` is gitignored; start from
| 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. |
| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. |
| `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. |
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
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.
| `REPO` | *required to build* | Image repository, no tag. |
| `TAG` | `latest` | Tag to run under compose. |
| `HOST` | *required to run* | Hostname Traefik routes to. |
| `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.
@@ -213,7 +204,7 @@ the container, so a backup is `cp -r data` and you can inspect the file with
any sqlite client without going through the engine.
That directory must exist and be owned by the user compose runs as — `make up`
creates it, and `PUID`/`PGID` in `.env` tell the container who
creates it, and `FOODSTER_UID`/`FOODSTER_GID` 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
@@ -222,7 +213,7 @@ 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
sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID
docker compose restart
```
@@ -244,7 +235,7 @@ counting it would lock the household out for simply opening the app.
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
**None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes
brute force as a practical route; it does not make a guessable password safe.
## Mockups
-49
View File
@@ -131,55 +131,6 @@ func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) {
}
}
func TestTahteetIsLoggableButNotFood(t *testing.T) {
h := seeded(t)
// The migration creates it; nobody adds it.
special, err := listSpecial(h.db, "")
if err != nil {
t.Fatalf("listSpecial: %v", err)
}
if len(special) != 1 || special[0].Name != "Tähteet" {
t.Fatalf("special = %+v, want exactly Tähteet", special)
}
// It must not turn up among the dishes: not on the board's categories,
// not in the catalog, and not in whatever the suggester later draws from.
dishes, err := listDishes(h.db, "")
if err != nil {
t.Fatalf("listDishes: %v", err)
}
for _, d := range dishes {
if d.Name == "Tähteet" {
t.Fatal("Tähteet appears among the dishes")
}
}
// It carries no category at all, which is why it cannot be an ordinary
// dish: those are required to have one.
if len(special[0].Categories) != 0 {
t.Errorf("categories = %v, want none", special[0].Categories)
}
// And it gets its own mark: no categories is not the same as several, so
// it must not fall through to the mixed Sekalaiset one.
if got := special[0].CategoryKey(); got != "tahteet" {
t.Errorf("CategoryKey = %q, want tahteet", got)
}
// Logging it has to work exactly like logging a real meal.
date := day(t, "2026-09-05")
if err := saveEntry(h.db, date, special[0].ID, nil); err != nil {
t.Fatalf("saveEntry: %v", err)
}
entry, err := entryFor(h.db, date)
if err != nil || entry == nil {
t.Fatalf("entryFor: %v, %v", entry, err)
}
if entry.Main.Name != "Tähteet" {
t.Errorf("logged %q, want Tähteet", entry.Main.Name)
}
}
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
h := seeded(t)
id := h.sideNamed(t, "Riisi")
+41 -185
View File
@@ -86,7 +86,6 @@ type logView struct {
ShowBoard bool
Dishes []Dish // flat, only to know whether anything matched
Groups []DishGroup // what the board actually renders
Special []Dish // Tähteet and the like: loggable, but not food
Sides []Side
New mainForm // inline "add the dish you were looking for"
History HistoryPage
@@ -98,57 +97,17 @@ type logView struct {
Confirming bool
}
// logOptions is what the Kirjaa screen is being asked to show. Pulled out of
// the request for a page load or a patch, and set directly after a write,
// where the answer is simply "that day, nothing else open".
type logOptions struct {
Date time.Time
Dish string // ?ruoka=, opening the sides step
Changing bool // ?muuta=, swapping the dish on a logged day
Confirming bool // ?poista=, asking before deleting the entry
Search string
}
func (a *app) logOptionsFrom(r *http.Request) logOptions {
q := r.URL.Query()
return logOptions{
Date: a.date(r),
Dish: q.Get("ruoka"),
Changing: q.Get("muuta") != "",
Confirming: q.Get("poista") != "",
Search: strings.TrimSpace(q.Get("haku")),
}
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
render(w, r, logPage(a.buildLog(r, a.logOptionsFrom(r))))
}
// day patches the list in place. Every link in it calls here rather than
// loading a page, so opening a day leaves the scroll position alone.
func (a *app) day(w http.ResponseWriter, r *http.Request) {
fragment(w, r, dayList(a.buildLog(r, a.logOptionsFrom(r))))
}
// finishDay answers a write: a patch for Datastar, a redirect otherwise.
func (a *app) finishDay(w http.ResponseWriter, r *http.Request, date time.Time) {
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, logOptions{Date: date})))
return
}
a.redirectToDay(w, r, date)
}
func (a *app) buildLog(r *http.Request, o logOptions) logView {
date := o.Date
date := a.date(r)
v := logView{
Date: date,
Today: today(a.loc),
Search: o.Search,
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
Checked: map[int64]bool{},
Confirming: o.Confirming,
}
v.Confirming = r.URL.Query().Get("poista") != ""
entry, err := entryFor(a.db, date)
if err != nil {
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
@@ -158,8 +117,8 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
// ?ruoka= opens the sides step for that dish. When it is the dish already
// logged, the existing sides come back ticked, which makes editing an
// entry the same screen as creating one.
if o.Dish != "" {
if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil {
if raw := r.URL.Query().Get("ruoka"); raw != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
if dish, err := dishByID(a.db, id); err == nil {
v.Chosen = dish
if entry != nil && entry.Main.ID == id {
@@ -174,7 +133,7 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
// The board shows when there is nothing logged yet, or when the entry is
// being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here,
// so swapping the dish and picking one for the first time are one path.
changing := o.Changing || v.Search != ""
changing := r.URL.Query().Get("muuta") != "" || v.Search != ""
if v.Chosen == nil && (v.Entry == nil || changing) {
v.ShowBoard = true
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
@@ -183,9 +142,6 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
// listDishes already orders by frequency then name, so grouping keeps
// the favourites at the top of each category.
v.Groups = groupDishes(v.Dishes)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("list special: %v", err)
}
// Seed the inline add form with whatever was searched for, so a miss
// turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
@@ -196,33 +152,13 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
}
}
a.loadDays(r, &v)
return v
}
// loadDays fills the day list. The selected day expands inside it rather than
// in a panel above it, so choosing a day from the list does not reorder the
// list underneath the tap.
func (a *app) loadDays(r *http.Request, v *logView) {
v.HistoryDays = historyWindow(r)
// The window has to reach the selected day, or it would have nowhere to
// expand.
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
v.HistoryDays = min(reach, maxHistoryDays)
}
v.HistoryMore = v.HistoryDays + historyDays
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
if err != nil {
if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
log.Printf("history: %v", err)
}
// Nothing logged ever: the selected day is still the one being worked on,
// so it needs a row of its own to open in.
if len(page.Rows) == 0 {
page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}}
}
v.History = page
render(w, r, logPage(v))
}
// searchSignals is what Datastar sends back: for a GET it JSON-encodes the
@@ -255,50 +191,6 @@ func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
}
}
// isDatastar reports whether the request came from the client library, which
// tags its own. Everything below keeps working without JavaScript: the same
// handlers redirect instead of patching when the header is absent.
func isDatastar(r *http.Request) bool {
return r.Header.Get("Datastar-Request") != ""
}
// patchElements sends one Datastar event carrying several elements, each
// matched to the page by its id. A text/html response can only replace one
// element, and the catalog has to move its list and its forms together —
// opening an edit form also has to un-highlight whatever was open before.
//
// ponytail: about twenty lines instead of the SDK, which brought four modules
// for an SSE generator we would otherwise never call.
func patchElements(w http.ResponseWriter, r *http.Request, components ...templ.Component) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
var out strings.Builder
out.WriteString("event: datastar-patch-elements\n")
for _, c := range components {
var html strings.Builder
if err := c.Render(r.Context(), &html); err != nil {
log.Printf("patch %s: %v", r.URL.Path, err)
return
}
// One `data: elements` line per line of HTML, as the protocol wants.
for _, line := range strings.Split(html.String(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
out.WriteString("data: elements ")
out.WriteString(line)
out.WriteString("\n")
}
}
out.WriteString("\n")
io.WriteString(w, out.String())
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// searchBoard re-renders the dish board as the search box is typed into.
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
var signals searchSignals
@@ -318,9 +210,6 @@ func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
}
v.Dishes = dishes
v.Groups = groupDishes(dishes)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("search special: %v", err)
}
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
fragment(w, r, boardList(v))
@@ -384,12 +273,6 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
log.Printf("quick add: %v", err)
form.Err = "Tallennus epäonnistui."
default:
// Created: straight on to its sides step.
opts := logOptions{Date: date, Dish: strconv.FormatInt(id, 10)}
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, opts)))
return
}
a.redirectToPick(w, r, date, id)
return
}
@@ -397,11 +280,23 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
// Rejected: back to the board with the form filled in and the search
// still narrowed, so the add card stays on screen.
v := a.buildLog(r, logOptions{Date: date, Search: form.Name})
v.New = form
if isDatastar(r) {
fragment(w, r, dayList(v))
return
v := logView{
Date: date,
Today: today(a.loc),
Search: form.Name,
Checked: map[int64]bool{},
New: form,
ShowBoard: true,
}
var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
}
v.Groups = groupDishes(v.Dishes)
v.HistoryDays = historyWindow(r)
v.HistoryMore = v.HistoryDays + historyDays
if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
log.Printf("history: %v", err)
}
render(w, r, logPage(v))
}
@@ -437,7 +332,7 @@ func (a *app) save(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
return
}
a.finishDay(w, r, date)
a.redirectToDay(w, r, date)
}
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
@@ -447,12 +342,15 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return
}
a.finishDay(w, r, date)
a.redirectToDay(w, r, date)
}
// redirectToDay is the no-JavaScript path back after a write.
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther)
target := "/"
if !date.Equal(today(a.loc)) {
target += "?pvm=" + date.Format(dateLayout)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// mainForm and sideForm carry what the user typed, so a rejected submission
@@ -486,20 +384,7 @@ type catalogView struct {
DeleteKind string
}
// catalog renders the whole page. show patches the same state in place, so
// nothing navigates: both build the view the same way.
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
a.renderCatalog(w, r, a.catalogState(r))
}
// show is what every catalog link actually calls. It patches the list and both
// forms rather than loading a page, so opening an edit form or asking to
// delete a row leaves the scroll position exactly where it was.
func (a *app) show(w http.ResponseWriter, r *http.Request) {
a.patchCatalog(w, r, a.catalogState(r))
}
func (a *app) catalogState(r *http.Request) catalogView {
v := catalogView{
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
@@ -535,11 +420,10 @@ func (a *app) catalogState(r *http.Request) catalogView {
}
}
return v
a.renderCatalog(w, r, v)
}
// fillCatalog loads the lists into a view built from the request.
func (a *app) fillCatalog(v *catalogView) {
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
if v.Main.Categories == nil {
v.Main.Categories = map[string]bool{}
}
@@ -555,20 +439,9 @@ func (a *app) fillCatalog(v *catalogView) {
if v.Sides, err = listSides(a.db, v.Search); err != nil {
log.Printf("list sides: %v", err)
}
}
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
render(w, r, catalogPage(v))
}
// patchCatalog swaps the list and both forms in one event. They move together:
// opening an edit form also has to clear whatever delete was being confirmed.
func (a *app) patchCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
patchElements(w, r, catalogList(v), mainForm_(v.Main), sideForm_(v.Side))
}
// saveMain adds or updates a main dish. A rejected form is re-rendered with
// the values still in it; a good one redirects, so refresh cannot re-submit.
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
@@ -609,28 +482,11 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
log.Printf("save main: %v", err)
form.Err = "Tallennus epäonnistui."
default:
// Saved: hand back a blank form so it collapses, and a list with
// the dish in it.
a.finishCatalog(w, r, catalogView{})
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
return
}
}
a.finishCatalog(w, r, catalogView{Main: form})
}
// finishCatalog answers a catalog write: a patch for Datastar, a redirect for
// a plain form post. Without the redirect, submitting with JavaScript off
// would leave the browser sitting on a POST it could not reload.
func (a *app) finishCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
if isDatastar(r) {
a.patchCatalog(w, r, v)
return
}
if v.Main.Err != "" || v.Side.Err != "" {
a.renderCatalog(w, r, v)
return
}
http.Redirect(w, r, "/ruuat", http.StatusSeeOther)
a.renderCatalog(w, r, catalogView{Main: form})
}
func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
@@ -655,11 +511,11 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
log.Printf("save side: %v", err)
form.Err = "Tallennus epäonnistui."
default:
a.finishCatalog(w, r, catalogView{})
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
return
}
}
a.finishCatalog(w, r, catalogView{Side: form})
a.renderCatalog(w, r, catalogView{Side: form})
}
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
@@ -683,7 +539,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return
}
a.finishCatalog(w, r, catalogView{})
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
}
// importDishes takes a bundle either pasted into the textarea or uploaded as a
+6 -25
View File
@@ -35,21 +35,6 @@ var staticFS embed.FS
// version is replaced at build time with the CalVer tag (see `make image`).
var version = "dev"
// envTag marks the browser tab of anything that is not production, so a dev
// instance and the real one open side by side are told apart at a glance.
// Empty in production, which is the default.
var envTag string
func setEnvTag(value string) {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "prod") || strings.EqualFold(value, "production") {
envTag = ""
return
}
// Whatever it says, so ENV=staging labels itself too.
envTag = strings.ToLower(value)
}
const (
listenAddr = ":8080"
defaultTZ = "Europe/Helsinki"
@@ -70,7 +55,7 @@ func run() error {
"import a JSON dish bundle (PRD §7.3 shape) and exit")
flag.Parse()
db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB))
db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB))
if err != nil {
return err
}
@@ -81,9 +66,9 @@ func run() error {
return runImport(db, *importPath)
}
password := os.Getenv("PASSWORD")
password := os.Getenv("FOODSTER_PASSWORD")
if password == "" {
return errors.New("PASSWORD is not set")
return errors.New("FOODSTER_PASSWORD is not set")
}
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
@@ -94,11 +79,9 @@ func run() error {
return fmt.Errorf("TZ: %w", err)
}
setEnvTag(os.Getenv("ENV"))
// The container always publishes :8080; ADDR exists so tests and a second
// local instance can pick another port.
addr := cmp.Or(os.Getenv("ADDR"), listenAddr)
// The container always publishes :8080; FOODSTER_ADDR exists so tests and
// a second local instance can pick another port.
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
srv := &http.Server{
Addr: addr,
@@ -176,11 +159,9 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd)
mux.HandleFunc("GET /etsi", a.searchBoard)
mux.HandleFunc("GET /paiva", a.day)
mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
mux.HandleFunc("GET /ruuat/nayta", a.show)
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
+7 -32
View File
@@ -105,29 +105,6 @@ func TestReadSignals(t *testing.T) {
}
}
func TestEnvTagMarksNonProduction(t *testing.T) {
t.Cleanup(func() { envTag = "" })
cases := []struct{ env, want string }{
// Production is the default and must stay unmarked: the tag exists to
// pick the dev tab out of two identical ones.
{"", "Foodster"},
{"prod", "Foodster"},
{"PRODUCTION", "Foodster"},
{" ", "Foodster"},
{"dev", "dev · Foodster"},
{"DEV", "dev · Foodster"},
{"staging", "staging · Foodster"},
}
for _, c := range cases {
setEnvTag(c.env)
if got := pageTitle("Foodster"); got != c.want {
t.Errorf("ENV=%q: title = %q, want %q", c.env, got, c.want)
}
}
}
func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
@@ -269,16 +246,14 @@ func TestMealLogOneEntryPerDate(t *testing.T) {
}
defer db.Close()
// Ids well clear of anything the migrations create.
if _, err := db.Exec(
`INSERT INTO main_dishes (id, name) VALUES (101, 'Lohikeitto'), (102, 'Lihapullat')`); err != nil {
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); err != nil {
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil {
t.Fatalf("first entry: %v", err)
}
// PRD §6: a second dinner for the same day must be refused.
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 102)`); err == nil {
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil {
t.Error("second entry for the same date was accepted, want a unique violation")
}
}
@@ -290,18 +265,18 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (101, 'Kanacurry')`); err != nil {
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil {
t.Fatalf("first insert: %v", err)
}
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err == nil {
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil {
t.Error("case-variant duplicate was accepted, want a unique violation")
}
// Soft-deleting the original frees the name again (PRD §7.3).
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 101`); err != nil {
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
t.Fatalf("soft delete: %v", err)
}
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err != nil {
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil {
t.Errorf("name still blocked after soft delete: %v", err)
}
}
-19
View File
@@ -1,19 +0,0 @@
-- Tähteet: leftovers.
--
-- Not a dish. It exists so a day can be recorded as "we ate what was already
-- there" without inventing a meal that was never cooked. It has no category,
-- it is not something the household adds or edits, and the stage 2 suggester
-- must never propose it (PRD §8).
--
-- Modelled as a flagged row in main_dishes rather than a nullable
-- main_dish_id on meal_log: the log keeps one shape, and every foreign key
-- and join carries on working untouched.
ALTER TABLE main_dishes
ADD COLUMN special INTEGER NOT NULL DEFAULT 0 CHECK (special IN (0, 1));
-- OR IGNORE in case a household already typed a dish by this name: the unique
-- index on lower(name) would otherwise fail the migration. Their row stays as
-- an ordinary dish, which is wrong but harmless and fixable by hand.
INSERT OR IGNORE INTO main_dishes (name, has_sides, special)
VALUES ('Tähteet', 0, 1);
+2 -36
View File
@@ -134,8 +134,6 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
.c-kana { color: var(--kana); }
.c-kala { color: var(--kala); }
.c-kasvis { color: var(--kasvis); }
/* Not a category, so not a category colour. */
.c-tahteet { color: var(--muted); }
/* Day switcher */
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
@@ -210,20 +208,6 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
font-size: 10px;
color: var(--muted);
}
/* Not food: set apart from the categories, and deliberately quiet. */
.special {
margin-top: 22px;
padding-top: 16px;
border-top: 1px dashed var(--line);
}
.pill.plain {
font-size: 16px;
padding: 12px 16px;
background: var(--sunk);
color: var(--muted);
font-weight: 600;
}
.pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
.pill.lg { font-size: 18px; padding: 12px 16px; }
.pill.md { font-size: 15.5px; padding: 11px 14px; }
@@ -326,26 +310,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-transform: uppercase;
color: var(--muted);
}
/* The day list is the page; rows are links and the selected one expands. */
.history { margin-top: 4px; }
/* Marked with a bar down the side, not rules above and below: the rows either
side already draw a bottom border, so a horizontal rule here doubled up.
scroll-margin keeps the anchor off the viewport edge. */
.open {
scroll-margin-top: 12px;
margin: 8px 0 18px;
padding: 10px 0 4px 13px;
border-left: 3px solid var(--accent);
}
.openday {
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
/* History sits under the logger on the same page, so whole rows are links. */
.history { margin-top: 8px; }
.entry, .gapline {
display: flex;
gap: 12px;
+5 -28
View File
@@ -28,24 +28,13 @@ type Dish struct {
TimesEaten int
}
// Rows flagged `special` in the database — Tähteet — are loggable but are not
// food. They carry no category, never appear in the catalog, and PRD §8
// excludes them from the suggester: cooldown, coverage and weighting all skip
// them. dishByID deliberately does not filter on the flag, because logging one
// has to work like logging anything else.
// CategoryKey picks the mark for a dish. One covering several categories
// (tortillas, build-your-own pizza) gets the mixed one; carrying none at all
// means it is not food, and Tähteet is not a mixture of anything.
// CategoryKey is the class suffix for the colour dot. A dish covering several
// categories (tortillas, build-your-own pizza) gets the mixed marker.
func (d Dish) CategoryKey() string {
switch len(d.Categories) {
case 0:
return "tahteet"
case 1:
if len(d.Categories) == 1 {
return categoryFI[d.Categories[0]]
default:
return "sek"
}
return "sek"
}
// Size buckets the dish by how often it has been eaten. The board draws
@@ -88,17 +77,6 @@ func (e Entry) SidesLabel() string {
// listDishes returns live mains ordered by how often they have been eaten.
// An empty search matches everything.
func listDishes(db *sql.DB, search string) ([]Dish, error) {
return queryDishes(db, search, false)
}
// listSpecial returns the entries that are not food — Tähteet and anything
// like it. They are loggable but never suggested, and never appear in the
// catalog, so they are fetched deliberately rather than by accident.
func listSpecial(db *sql.DB, search string) ([]Dish, error) {
return queryDishes(db, search, true)
}
func queryDishes(db *sql.DB, search string, special bool) ([]Dish, error) {
rows, err := db.Query(`
SELECT m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category)
@@ -107,9 +85,8 @@ func queryDishes(db *sql.DB, search string, special bool) ([]Dish, error) {
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
FROM main_dishes m
WHERE m.deleted_at IS NULL
AND m.special = ?
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%')
ORDER BY 5 DESC, m.name`, special, search, search)
ORDER BY 5 DESC, m.name`, search, search)
if err != nil {
return nil, err
}
+38 -171
View File
@@ -43,23 +43,6 @@ func dayURL(base string, d, now time.Time) string {
return base + "?pvm=" + isoDate(d)
}
// showURL turns a catalog page link into the patch endpoint behind it, so the
// href and the Datastar call never drift apart.
func showURL(pageURL string) string {
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
}
// dayPatch is the endpoint behind every link in the day list. The href beside
// it stays a real page URL for anyone without JavaScript; Datastar calls this
// instead and swaps the list where it stands.
func dayPatch(d time.Time, param string) string {
url := "/paiva?pvm=" + isoDate(d)
if param != "" {
url += "&" + param
}
return url
}
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
// any day but today.
func pickSeparator(v logView) string {
@@ -69,16 +52,6 @@ func pickSeparator(v logView) string {
return "&"
}
// stepURL is the page URL for a link inside the open day: the fallback when
// there is no JavaScript to intercept it.
func stepURL(v logView, param string) string {
url := dayURL("/", v.Date, v.Today)
if param != "" {
url += pickSeparator(v) + param
}
return url
}
// jsString renders a Go string as a JavaScript literal, for the data-signals
// attribute that seeds the search box.
func jsString(s string) string {
@@ -108,16 +81,6 @@ func categoryLabels(d Dish) string {
return strings.Join(names, ", ")
}
// pageTitle prefixes the tab title on any instance that is not production.
// The tab is the only place a browser shows which of two identical apps you
// are looking at.
func pageTitle(title string) string {
if envTag == "" {
return title
}
return envTag + " · " + title
}
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
// after every number except one.
func countFI(n int, one, many string) string {
@@ -140,7 +103,7 @@ templ page(title, current string) {
// header rather than butting against it.
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ pageTitle(title) }</title>
<title>{ title }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and
@@ -220,10 +183,6 @@ templ categoryIcon(key string) {
<span class="cat c-kasvis">
@glyphKasvis()
</span>
case "tahteet":
<span class="cat c-tahteet">
@glyphTahteet()
</span>
default:
<span class="cat">
@glyphSekalaiset()
@@ -231,15 +190,6 @@ templ categoryIcon(key string) {
}
}
// A lidded tub. Tähteet is not food and not a mixture of categories, so it
// gets neither a category colour nor the quartered mark.
templ glyphTahteet() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
<rect x="1.4" y="2.6" width="13.2" height="3" rx="1.3"></rect>
<path d="M2.8 6.8h10.4l-.9 6.6a1.6 1.6 0 0 1-1.6 1.4H5.3a1.6 1.6 0 0 1-1.6-1.4z"></path>
</svg>
}
// A steak, its bone knocked out with fill-rule so the hole is transparent on
// whatever background the icon lands on.
templ glyphLiha() {
@@ -318,30 +268,6 @@ templ logPage(v logView) {
@daySwitch(v)
</header>
<main class="pad">
@dayList(v)
</main>
}
}
// dayList is the whole page: every day back through the window, with the
// selected one expanded where it sits. Opening a day used to swap in a panel
// above the list and drop that day out of it, so the rows below jumped up
// under the tap. Now nothing moves — the row grows.
templ dayList(v logView) {
<section class="history" id="paivat">
for i, row := range v.History.Rows {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Date.Equal(v.Date) {
<div class="open">
<p class="openday">
if row.Date.Equal(v.Today) {
Tänään
} else {
{ longDateFI(row.Date) }
}
</p>
switch {
case v.Chosen != nil:
@sidesStep(v)
@@ -350,13 +276,26 @@ templ dayList(v logView) {
default:
@loggedCard(v)
}
</div>
} else if row.Entry != nil {
<a
class="entry"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
@historyList(v)
</main>
}
}
// historyList sits under the day being logged: the two were always one thing,
// since every row here is a link back into the logger above it.
templ historyList(v logView) {
<section class="history">
<h3 class="sechead">Aiemmin</h3>
if len(v.History.Rows) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p>
}
for i, row := range v.History.Rows {
if !row.Date.Equal(v.Date) {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<a class="entry" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
@@ -368,22 +307,18 @@ templ dayList(v logView) {
<span class="chev"></span>
</a>
} else {
<a
class="gapline"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<a class="gapline" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<span class="act">Merkitse</span>
</a>
}
}
}
if v.History.More {
<a
class="more"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "paivat="+strconv.Itoa(v.HistoryMore)) + "')" }
>Näytä lisää</a>
}
</section>
@@ -450,29 +385,6 @@ templ boardList(v logView) {
}
</div>
}
// Tähteet is not food, so it sits apart from the categories rather
// than inside one. Fixed size: it will be among the most-logged
// entries, and it should not tower over the actual cooking.
if len(v.Special) > 0 {
<div class="special">
for _, d := range v.Special {
<a
class="pill plain"
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
>
@categoryIcon(d.CategoryKey())
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
</div>
}
// Tähteet always matches an empty search, so the add card keys off the
// real dishes only: otherwise a fresh install would show leftovers and
// no way to add anything.
if len(v.Dishes) == 0 {
@quickAddCard(v)
}
@@ -495,11 +407,7 @@ templ quickAddCard(v logView) {
if v.New.Err != "" {
<p class="formerr">{ v.New.Err }</p>
}
<form
method="post"
action="/lisaa"
data-on:submit__prevent="@post('/lisaa', {contentType: 'form'})"
>
<form method="post" action="/lisaa">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<label class="field">
<span>Nimi</span>
@@ -530,8 +438,7 @@ templ quickAddCard(v logView) {
templ dishPill(d Dish, v logView) {
<a
class={ "pill", d.Size() }
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
>
@categoryIcon(d.CategoryKey())
{ d.Name }
@@ -547,11 +454,7 @@ templ sidesStep(v logView) {
@categoryIcon(v.Chosen.CategoryKey())
{ v.Chosen.Name }
</h3>
<form
method="post"
action="/kirjaa"
data-on:submit__prevent="@post('/kirjaa', {contentType: 'form'})"
>
<form method="post" action="/kirjaa">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
if v.Chosen.HasSides && len(v.Sides) > 0 {
@@ -573,11 +476,7 @@ templ sidesStep(v logView) {
}
<button class="primary" type="submit">Tallenna</button>
</form>
<a
class="ghost"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
</section>
}
@@ -598,31 +497,21 @@ templ loggedCard(v logView) {
if v.Confirming {
<p class="q">Poistetaanko merkintä?</p>
<div class="pair">
<form
method="post"
action="/poista"
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
>
<form method="post" action="/poista">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button>
</form>
<a
class="btn"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
<a class="btn" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
</div>
} else {
<div class="pair">
<a
class="btn"
href={ templ.SafeURL(stepURL(v, "muuta=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "muuta=1") }
>Muokkaa</a>
<a
class="btn del"
href={ templ.SafeURL(stepURL(v, "poista=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "poista=1") }
>Poista</a>
</div>
}
@@ -736,39 +625,25 @@ templ emptyNote(search string) {
// rowActions is a pencil and a bin, until the bin is tapped: then the row
// asks. An icon is a smaller target to hit by accident than a word, and the
// dish disappears from every picker the moment it goes.
// Every control here is a real link or form, so the page still works without
// JavaScript. Datastar intercepts them and patches the list in place instead,
// which is the whole point: a delete confirmation halfway down a long list
// must not send the browser back to the top.
templ rowActions(v catalogView, editURL string, id int64, kind string) {
if v.DeleteID == id && v.DeleteKind == kind {
<div class="rowactions confirming">
<span>Poista?</span>
<form
method="post"
action="/ruuat/poista"
data-on:submit__prevent="@post('/ruuat/poista', {contentType: 'form'})"
>
<form method="post" action="/ruuat/poista">
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del">Kyllä</button>
</form>
<a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
<a href="/ruuat">Peruuta</a>
</div>
} else {
<div class="rowactions">
<a
href={ templ.SafeURL(editURL) }
data-on:click__prevent={ "@get('" + showURL(editURL) + "')" }
aria-label="Muokkaa"
title="Muokkaa"
>
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa" title="Muokkaa">
@iconPencil()
</a>
<a
class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
data-on:click__prevent={ "@get('/ruuat/nayta?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind + "')" }
aria-label="Poista"
title="Poista"
>
@@ -807,11 +682,7 @@ templ mainForm_(f mainForm) {
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form
method="post"
action="/ruuat/paaruoka"
data-on:submit__prevent="@post('/ruuat/paaruoka', {contentType: 'form'})"
>
<form method="post" action="/ruuat/paaruoka">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
@@ -839,7 +710,7 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
<a class="ghost" href="/ruuat">Peruuta</a>
}
</details>
}
@@ -868,11 +739,7 @@ templ sideForm_(f sideForm) {
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form
method="post"
action="/ruuat/lisuke"
data-on:submit__prevent="@post('/ruuat/lisuke', {contentType: 'form'})"
>
<form method="post" action="/ruuat/lisuke">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
@@ -883,7 +750,7 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
<a class="ghost" href="/ruuat">Peruuta</a>
}
</details>
}
+9 -13
View File
@@ -1,23 +1,19 @@
services:
app:
image: ${REPO:?set REPO in .env}:${TAG:-latest}
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
restart: unless-stopped
# A bind mount rather than a named volume: the database sits in ./data on
# the host, where it can be listed, copied and backed up without going
# through the container engine. The image runs as UID 65534, so the
# container has to be told which host user owns that directory.
#
# PUID/PGID rather than UID/GID: UID is a read-only variable in bash, so a
# value set here would be silently replaced by the invoking shell's own.
user: "${PUID:-1000}:${PGID:-1000}"
# The database is a bind mount, not a named volume: it sits in ./data on
# the host where it can be listed, copied and opened with any sqlite
# client. The image runs as UID 65534, so the container has to be told
# which host user owns that directory.
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}"
volumes:
- ./data:/data
environment:
PASSWORD: ${PASSWORD:?set PASSWORD in .env}
DB: /data/foodster.db
ENV: ${ENV:-prod}
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
FOODSTER_DB: /data/foodster.db
TZ: ${TZ:-Europe/Helsinki}
# No published ports: Traefik reaches the container over the shared
@@ -26,7 +22,7 @@ services:
labels:
- traefik.enable=true
- traefik.http.routers.foodster.entrypoints=websecure
- traefik.http.routers.foodster.rule=Host(`${HOST:?set HOST in .env}`)
- traefik.http.routers.foodster.rule=Host(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`)
- traefik.http.routers.foodster.tls=true
- traefik.http.services.foodster.loadbalancer.server.port=8080
- traefik.docker.network=traefik
+12 -75
View File
@@ -16,7 +16,7 @@ trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
go build -o "$tmp/foodster" ./cmd/foodster
PASSWORD="$pass" DB="$tmp/smoke.db" ADDR="$addr" \
FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
srv=$!
@@ -72,8 +72,6 @@ check "theme script is served" \
home=$(curl -s -u ":$pass" "http://$addr/")
check "the header carries the brand" "$home" "Foodster"
# ENV is unset here, so this instance is production and unmarked.
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
check "the theme toggle is present" "$home" "data-theme-toggle"
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
@@ -116,11 +114,6 @@ check "malformed JSON is explained" \
board=$(curl -s -u ":$pass" "http://$addr/")
check "board lists imported dishes" "$board" "Lihapullat"
# Tähteet is loggable but is not food: on the board, never in the catalog.
check "leftovers are on the board" "$board" "Tähteet"
refute "leftovers are not in the catalog" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet"
# Pull a real dish id out of the board rather than assuming one.
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$ruoka" ]; then
@@ -139,34 +132,8 @@ check "saving redirects back to the day" \
check "the saved day shows what was eaten" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
# The selected day expands inside the list rather than in a panel above it,
# so the rows below do not shift when one is tapped.
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
check "the selected day expands in place" "$day" 'class="open"'
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
# ---- the day list patches in place instead of navigating ----------------
dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=2026-09-05")
check "opening a day patches the list" "$dayp" 'id="paivat"'
refute "and returns a fragment, not a page" "$dayp" "<html"
check "picking a dish patches to the sides step" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/paiva?pvm=2026-09-05&ruoka=$ruoka")" "Tallenna"
check "saving from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
check "deleting from Datastar patches back" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "pvm=2026-09-04" "http://$addr/poista")" 'id="paivat"'
# Without the header it must still redirect, for no JavaScript.
check "a plain save still redirects to the day" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=2026-09-04"
check "history is on the same page as the logger" \
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
# Deleting a logged meal drops the row outright, so it asks first.
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
@@ -240,12 +207,9 @@ check "the quick-added dish is on the board" \
# ---- catalog CRUD from the UI -------------------------------------------
# Assert where it redirects, not just that it does: these pointed at the old
# /ruoat spelling for a while and every 303-only check was happy.
check "adding a main redirects back to the catalog" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
check "adding a main redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303"
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
@@ -262,10 +226,9 @@ check "a nameless dish is refused" \
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Anna nimi."
check "adding a side redirects back to the catalog" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
"/ruuat"
check "adding a side redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303"
check "the new side is listed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
@@ -288,35 +251,9 @@ check "the bin asks before deleting" \
check "the dish is still there while it asks" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
# ---- the catalog patches in place instead of navigating -----------------
# A delete confirmation halfway down a long list must not send the browser
# back to the top, so these answer with a Datastar patch rather than a page.
patch=$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
check "the patch carries the list" "$patch" 'id="ruokalista"'
check "and both forms, so an open one closes" "$patch" 'id="paaruoka"'
check "the row it patches in is asking" "$patch" "Poista?"
check "patches are served as an event stream" \
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta")" "text/event-stream"
check "deleting from Datastar patches too" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
"event: datastar-patch-elements"
refute "and the dish is gone from the patched list" \
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
"Uunikala"
# Without the header it must still be an ordinary redirect, for no JavaScript.
check "a plain form post still redirects" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
check "confirming the delete redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303"
refute "the dish is gone once confirmed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
+8 -45
View File
@@ -1,56 +1,19 @@
{
"mains": [
{"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true},
{"name": "Kebab", "categories": ["meat"], "has_sides": true},
{"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false},
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
{"name": "Lihapullat/pihvit", "categories": ["meat"], "has_sides": true},
{"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Makaronimössö", "categories": ["meat"], "has_sides": false},
{"name": "Maksalaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Nachopelti", "categories": ["meat"], "has_sides": false},
{"name": "Nakkikeitto", "categories": ["meat"], "has_sides": false},
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
{"name": "Possunsuikalekastike", "categories": ["meat"], "has_sides": true},
{"name": "Possurisotto", "categories": ["meat"], "has_sides": false},
{"name": "Pyttipannu", "categories": ["meat"], "has_sides": false},
{"name": "Uuniliha", "categories": ["meat"], "has_sides": true},
{"name": "Uunimakkara", "categories": ["meat"], "has_sides": true},
{"name": "Broilerin koipireidet", "categories": ["chicken"], "has_sides": true},
{"name": "Kanakastike", "categories": ["chicken"], "has_sides": true},
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
{"name": "Kanamakaronilaatikko", "categories": ["chicken"], "has_sides": false},
{"name": "Kanapasta", "categories": ["chicken"], "has_sides": false},
{"name": "Kanarisotto", "categories": ["chicken"], "has_sides": false},
{"name": "Kalakeitto", "categories": ["fish"], "has_sides": false},
{"name": "Lohicuscus-salaatti", "categories": ["fish"], "has_sides": false},
{"name": "Lohipyörykät", "categories": ["fish"], "has_sides": true},
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
{"name": "Uuniperunat (lohitäytteellä)", "categories": ["fish"], "has_sides": false},
{"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Italianpata (lihaton)", "categories": ["vegetarian"], "has_sides": true},
{"name": "Kasvispihvit", "categories": ["vegetarian"], "has_sides": true},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Pinaattiletut", "categories": ["vegetarian"], "has_sides": false},
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false}
],
"sides": [
{"name": "Keitetyt perunat"},
{"name": "Lohkoperunat"},
{"name": "Muusi"},
{"name": "Pasta"},
{"name": "Ranskalaiset"},
{"name": "Lohkoperunat"},
{"name": "Muussi"},
{"name": "Riisi"},
{"name": "Spagetti"},
{"name": "Tillikastike"},
{"name": "Wokkivihannekset"}
{"name": "Pasta"}
]
}