diff --git a/.env.example b/.env.example index 4ac06c0..4c4f00c 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,29 @@ # 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 +# Image coordinates. REPO carries no tag. +REPO=registry.example.com/you/foodster +TAG=latest # Shared household password. The app will not start without it. -FOODSTER_PASSWORD=changeme +PASSWORD=changeme # Hostname Traefik routes to. Kept here rather than in compose.yaml so no # infrastructure detail is committed. -FOODSTER_HOST=foodster.example.com +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 # 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. -FOODSTER_UID=1000 -FOODSTER_GID=1000 +# +# 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 # 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e9cef1c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# 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. diff --git a/Makefile b/Makefile index 6a5b8da..0468112 100644 --- a/Makefile +++ b/Makefile @@ -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) - FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG) + PASSWORD=$${PASSWORD:-dev} ENV=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 "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } + @test -n "$(REPO)" || { echo "set 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 "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . ; \ + -t "$(REPO):$$tag" -t "$(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 "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } + @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; } @test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \ tag=$$(cat $(TAGFILE)); \ - built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \ + built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \ { echo "no local image tagged $$tag - run make image"; exit 1; }; \ - podman push "$(FOODSTER_REPO):$$tag"; \ - podman push "$(FOODSTER_REPO):latest"; \ + podman push "$(REPO):$$tag"; \ + podman push "$(REPO):latest"; \ echo "==> verifying $$tag"; \ for ref in "$$tag" latest; do \ - podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \ + podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \ { echo " FAIL $$ref is not in the registry"; exit 1; }; \ - served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \ + served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \ if [ "$$served" != "$$built" ]; then \ echo " FAIL $$ref serves $$served"; \ echo " expected $$built"; \ diff --git a/PRD.md b/PRD.md index 6c4ee84..c7716d2 100644 --- a/PRD.md +++ b/PRD.md @@ -351,7 +351,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 - `FOODSTER_PASSWORD`; the username is ignored. Compared using + `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,21 +395,25 @@ 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 `FOODSTER_UID`/`FOODSTER_GID` + runs as UID 65534, compose sets `user:` from `PUID`/`PGID` to match whoever owns that directory. `restart: unless-stopped`. - **Configuration**, entirely through environment variables (see `.env.example`): - - `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`. + 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. - `TZ` — default `Europe/Helsinki`. - The registry hostname exists only in `.env`, which is gitignored, because §11 leaves open the possibility of publishing this repository. - **Routing**: Traefik on an external `traefik` network, matching on - `FOODSTER_HOST` and terminating TLS. The container publishes no ports — + `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. diff --git a/README.md b/README.md index 41eaf01..886700a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ 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 @@ -173,13 +176,19 @@ Everything is environment variables. `.env` is gitignored; start from | Variable | Default | Purpose | |---|---|---| -| `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. | +| `PASSWORD` | *required* | Shared password. The app will not start without it. | +| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. | +| `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). | +| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. | +| `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. | | `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. | -| `FOODSTER_REPO` | *required to build* | Image repository, no tag. | -| `FOODSTER_TAG` | `latest` | Tag to run under compose. | -| `FOODSTER_PORT` | `8080` | Host port to publish. | + +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. | Set `TZ` in development too. Under UTC the date rolls over three hours late, which is exactly when dinner gets logged. @@ -204,7 +213,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 `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who +creates it, and `PUID`/`PGID` in `.env` tell the container who that is. Get them from `id -u` and `id -g`. If the app exits with `cannot open /data/foodster.db ... unable to open @@ -213,7 +222,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 FOODSTER_UID / FOODSTER_GID +sudo chown -R 1000:1000 data # match PUID / PGID docker compose restart ``` @@ -235,7 +244,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 `FOODSTER_PASSWORD`.** Rate limiting removes +**None of this replaces a strong `PASSWORD`.** Rate limiting removes brute force as a practical route; it does not make a guessable password safe. ## Mockups diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index d14ace6..85062f7 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -98,16 +98,56 @@ type logView struct { Confirming bool } -func (a *app) index(w http.ResponseWriter, r *http.Request) { - date := a.date(r) - v := logView{ - Date: date, - Today: today(a.loc), - Search: strings.TrimSpace(r.URL.Query().Get("haku")), - Checked: map[int64]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 +} - v.Confirming = r.URL.Query().Get("poista") != "" +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 + v := logView{ + Date: date, + Today: today(a.loc), + Search: o.Search, + Checked: map[int64]bool{}, + Confirming: o.Confirming, + } entry, err := entryFor(a.db, date) if err != nil { @@ -118,8 +158,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { // ?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 raw := r.URL.Query().Get("ruoka"); raw != "" { - if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + if o.Dish != "" { + if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil { if dish, err := dishByID(a.db, id); err == nil { v.Chosen = dish if entry != nil && entry.Main.ID == id { @@ -134,7 +174,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { // 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 := r.URL.Query().Get("muuta") != "" || v.Search != "" + changing := o.Changing || v.Search != "" if v.Chosen == nil && (v.Entry == nil || changing) { v.ShowBoard = true if v.Dishes, err = listDishes(a.db, v.Search); err != nil { @@ -157,7 +197,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { } a.loadDays(r, &v) - render(w, r, logPage(v)) + return v } // loadDays fills the day list. The selected day expands inside it rather than @@ -215,6 +255,50 @@ 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 @@ -300,6 +384,12 @@ 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 } @@ -307,23 +397,12 @@ 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 := logView{ - Date: date, - Today: today(a.loc), - Search: form.Name, - Checked: map[int64]bool{}, - New: form, - ShowBoard: true, + v := a.buildLog(r, logOptions{Date: date, Search: form.Name}) + v.New = form + if isDatastar(r) { + fragment(w, r, dayList(v)) + return } - 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) - if v.Special, err = listSpecial(a.db, v.Search); err != nil { - log.Printf("list special: %v", err) - } - a.loadDays(r, &v) render(w, r, logPage(v)) } @@ -333,9 +412,7 @@ func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.T if strings.Contains(target, "?") { sep = "&" } - http.Redirect(w, r, - target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date), - http.StatusSeeOther) + http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther) } // save records the meal and redirects, so a refresh cannot double-post. @@ -360,7 +437,7 @@ func (a *app) save(w http.ResponseWriter, r *http.Request) { http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError) return } - a.redirectToDay(w, r, date) + a.finishDay(w, r, date) } func (a *app) delete(w http.ResponseWriter, r *http.Request) { @@ -370,13 +447,12 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) { http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) return } - a.redirectToDay(w, r, date) + a.finishDay(w, r, date) } -// redirectToDay returns to the day in the list, anchor included, so saving or -// deleting leaves the viewport where the work was happening. +// 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, dayLink(date, today(a.loc)), http.StatusSeeOther) + http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther) } // mainForm and sideForm carry what the user typed, so a rejected submission @@ -410,7 +486,20 @@ 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")), @@ -446,10 +535,11 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) { } } - a.renderCatalog(w, r, v) + return v } -func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { +// fillCatalog loads the lists into a view built from the request. +func (a *app) fillCatalog(v *catalogView) { if v.Main.Categories == nil { v.Main.Categories = map[string]bool{} } @@ -465,9 +555,20 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie 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) { @@ -508,11 +609,28 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { log.Printf("save main: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + // Saved: hand back a blank form so it collapses, and a list with + // the dish in it. + a.finishCatalog(w, r, catalogView{}) return } } - a.renderCatalog(w, r, catalogView{Main: form}) + 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) } func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { @@ -537,11 +655,11 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { log.Printf("save side: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + a.finishCatalog(w, r, catalogView{}) return } } - a.renderCatalog(w, r, catalogView{Side: form}) + a.finishCatalog(w, r, catalogView{Side: form}) } // deleteDish soft-deletes, so log entries keep resolving the name (PRD §6). @@ -565,7 +683,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) { http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) return } - http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + a.finishCatalog(w, r, catalogView{}) } // importDishes takes a bundle either pasted into the textarea or uploaded as a diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index b2182a9..96d1078 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -35,6 +35,21 @@ 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" @@ -55,7 +70,7 @@ func run() error { "import a JSON dish bundle (PRD §7.3 shape) and exit") flag.Parse() - db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB)) + db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB)) if err != nil { return err } @@ -66,9 +81,9 @@ func run() error { return runImport(db, *importPath) } - password := os.Getenv("FOODSTER_PASSWORD") + password := os.Getenv("PASSWORD") if password == "" { - return errors.New("FOODSTER_PASSWORD is not set") + return errors.New("PASSWORD is not set") } // Fail rather than fall back to UTC: a silently wrong zone shifts logged @@ -79,9 +94,11 @@ func run() error { return fmt.Errorf("TZ: %w", err) } - // 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) + 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) srv := &http.Server{ Addr: addr, @@ -159,9 +176,11 @@ 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) diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go index 75bd898..0049626 100644 --- a/cmd/foodster/main_test.go +++ b/cmd/foodster/main_test.go @@ -105,6 +105,29 @@ 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 { diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index fa33b29..49f4dbd 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -43,16 +43,21 @@ func dayURL(base string, d, now time.Time) string { return base + "?pvm=" + isoDate(d) } -// dayAnchor names the element a day expands into. -func dayAnchor(d time.Time) string { - return "paiva-" + 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) } -// dayLink opens a day and lands the viewport on it. Without the fragment the -// browser would jump to the top of a page whose selected day might be far -// down the list. -func dayLink(d, now time.Time) string { - return dayURL("/", d, now) + "#" + dayAnchor(d) +// 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 @@ -64,15 +69,14 @@ func pickSeparator(v logView) string { return "&" } -// stepURL is any link inside the open day. It keeps the anchor, so picking a -// dish or cancelling stays where the day is instead of throwing the viewport -// back to the top of the list. +// 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 + "#" + dayAnchor(v.Date) + return url } // jsString renders a Go string as a JavaScript literal, for the data-signals @@ -104,6 +108,16 @@ 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 { @@ -126,7 +140,7 @@ templ page(title, current string) { // header rather than butting against it. - { title } + { pageTitle(title) }