From 85bf5e1a2518f073ecbdce4e604b42aab1091635 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 6 Sep 2026 00:04:32 +0300 Subject: [PATCH 1/9] test: make the smoke script use relative dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script hardcoded 2026-09-05, so at the next midnight that became "some day in the past" and the assertions quietly changed meaning: today turned into an unlogged row, and a refute on "Ei merkintää" started matching it instead of the day under test. Dates are now computed at run time. That refute was wrong regardless. It denied the string across the whole page, but every unlogged day legitimately renders one. It now asserts positively that the entry is still shown while the delete is being confirmed. --- scripts/smoke.sh | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 573b37d..de13d2d 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -11,6 +11,12 @@ cd "$(dirname "$0")/.." addr=127.0.0.1:8099 pass=smoke + +# Dates are relative, never literal. A hardcoded one turns into "some day in +# the past" at the next midnight, and the assertions quietly start meaning +# something else. +d0=$(date +%F) +d1=$(date -d yesterday +%F) tmp=$(mktemp -d) trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT @@ -134,51 +140,53 @@ check "picking a dish opens the sides step" \ check "saving redirects back to the day" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ - -d "pvm=2026-09-05&ruoka=$ruoka" "http://$addr/kirjaa")" "303" + -d "pvm=$d0&ruoka=$ruoka" "http://$addr/kirjaa")" "303" check "the saved day shows what was eaten" \ - "$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu" + "$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")" "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") +day=$(curl -s -u ":$pass" "http://$addr/?pvm=$d0") 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") +dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=$d0") check "opening a day patches the list" "$dayp" 'id="paivat"' refute "and returns a fragment, not a page" "$dayp" " Date: Sun, 6 Sep 2026 00:08:49 +0300 Subject: [PATCH 2/9] docs: note that the server's compose and env are copied by hand The variable rename landed in the repository but not on the server, which keeps its own compose.yaml and .env. Neither is pulled from here, so the container came up against the old names. A release touching either now has to say so in its notes. --- CONTRIBUTING.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9cef1c..15a8a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,16 @@ 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. +## Deploying + +The server keeps its own `compose.yaml` and `.env`. Neither is pulled from +here, so a release that renames a variable, adds one, or changes a mount +needs both copied across **in the same deploy** — otherwise the container +comes up against the old names and the app refuses to start. + +Anything in this repository that reaches the server by hand belongs in the +release notes, flagged as breaking. + ## Things that are easy to get wrong - **The interface is Finnish.** Code, comments, this file and the PRD are -- 2.54.0 From 0f0f04240ab255d752c57ea9f889bc9313a93f96 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 6 Sep 2026 00:23:10 +0300 Subject: [PATCH 3/9] fix(kirjaa): open days older than the first entry ever logged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a date from before the oldest log entry rendered nothing at all. history() truncates its window at min(date) — correct, there is no history before the first entry to be missing — and loadDays widened the window by inflating the day count, which that truncation then undid. No row for the selected day meant no board to log it in, so the one thing you would want an old empty day for was the one thing you could not do. The same happened past maxHistoryDays, reachable straight from the date picker. history() now takes the selected day as an explicit floor instead of the caller guessing a day count, and date() clamps the past at maxHistoryDays the way it already clamped the future — the list runs unbroken from today down to the selection, so a picker set to 1994 would otherwise ask for eleven thousand rows. While in there: - entriesBetween replaces the day-at-a-time entryFor loop. Two queries for the whole window rather than two per day; the widest window a URL can ask for was 3,600 round trips through a pool of exactly one connection. - softDeleteMain and updateMain filter on special = 0. The catalog never lists Tähteet, but a stale tab or a hand-made POST could still have removed the row migration 0002 guarantees. - WriteTimeout and IdleTimeout on the server. With one database connection, a reader stalling on a long history response blocks everything behind it. Two tests, both of which fail on the old code: a day 100 back with only today logged, and sides landing on their own day now that they arrive in one query. --- cmd/foodster/handlers.go | 21 +++++--- cmd/foodster/main.go | 5 ++ cmd/foodster/store.go | 103 ++++++++++++++++++++++++++++++++++--- cmd/foodster/store_test.go | 69 +++++++++++++++++++++++-- 4 files changed, 179 insertions(+), 19 deletions(-) diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index 85062f7..98fab4d 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -60,12 +60,19 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) { // there is nothing to write down for a dinner that has not happened, and a // stray entry dated next year would sit at the top of the history forever. // Every read and write goes through here, so the clamp covers them all. +// +// The past is clamped too, at maxHistoryDays. The day list runs unbroken from +// today down to the selected day, so a picker set to 1994 would ask for eleven +// thousand rows. Same ceiling ?paivat= already has. func (a *app) date(r *http.Request) time.Time { now := today(a.loc) if raw := r.FormValue("pvm"); raw != "" { if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil { - if d.After(now) { + switch floor := now.AddDate(0, 0, -maxHistoryDays+1); { + case d.After(now): return now + case d.Before(floor): + return floor } return d } @@ -205,15 +212,13 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView { // 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) + // The window has to reach the selected day, or it would have nowhere to + // expand. history() takes it as a floor rather than the caller inflating + // the day count, because the window also truncates at the first entry ever + // logged — and a day older than that still has to be loggable. + page, err := history(a.db, a.loc, v.Today, v.HistoryDays, v.Date) if err != nil { log.Printf("history: %v", err) } diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 96d1078..8cb97f7 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -100,10 +100,15 @@ func run() error { // local instance can pick another port. addr := cmp.Or(os.Getenv("ADDR"), listenAddr) + // WriteTimeout and IdleTimeout matter more than they look: the pool holds + // exactly one database connection, so a reader stalling on a long history + // response blocks every other request behind it. srv := &http.Server{ Addr: addr, Handler: routes(db, loc, password), ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/cmd/foodster/store.go b/cmd/foodster/store.go index 161fae7..6c38a71 100644 --- a/cmd/foodster/store.go +++ b/cmd/foodster/store.go @@ -305,7 +305,8 @@ func updateMain(db *sql.DB, id int64, name string, categories []string, hasSides defer tx.Rollback() if _, err := tx.Exec( - `UPDATE main_dishes SET name = ?, has_sides = ? WHERE id = ? AND deleted_at IS NULL`, + `UPDATE main_dishes SET name = ?, has_sides = ? + WHERE id = ? AND deleted_at IS NULL AND special = 0`, name, hasSides, id, ); err != nil { return taken(err) @@ -345,9 +346,14 @@ func updateSide(db *sql.DB, id int64, name string) error { // Soft delete: the row stays so historical log entries keep resolving their // names, but it disappears from the catalog and every picker (PRD §6). +// +// `special = 0` here and in updateMain: the catalog never lists Tähteet, so +// the UI cannot reach it, but a stale tab or a hand-made POST could — and +// removing it would take away the row migration 0002 guarantees. func softDeleteMain(db *sql.DB, id int64) error { _, err := db.Exec( - `UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = ?`, id) + `UPDATE main_dishes SET deleted_at = datetime('now') + WHERE id = ? AND special = 0`, id) return err } @@ -432,7 +438,12 @@ type HistoryPage struct { // history walks back day by day from a given day, so a day nobody wrote down // shows up as an explicit gap rather than silently missing. It stops at the // first entry ever recorded — before that there is no history to be missing. -func history(db *sql.DB, loc *time.Location, from time.Time, days int) (HistoryPage, error) { +// +// reach names a day that must appear whatever the window says. The log board +// opens inside the selected day's row, so a date picked from before the first +// entry ever logged used to render nothing at all: no row, no board, no way to +// log it. Pass the zero time to ask for the plain window. +func history(db *sql.DB, loc *time.Location, from time.Time, days int, reach time.Time) (HistoryPage, error) { var first sql.NullString if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil { if err == sql.ErrNoRows { @@ -457,14 +468,92 @@ func history(db *sql.DB, loc *time.Location, from time.Time, days int) (HistoryP oldest = firstDate page.More = false } + if !reach.IsZero() && reach.Before(oldest) { + oldest = reach + page.More = firstDate.Before(oldest) + } page.Next = oldest.AddDate(0, 0, -1) + entries, err := entriesBetween(db, oldest, from) + if err != nil { + return HistoryPage{}, err + } for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) { - entry, err := entryFor(db, d) - if err != nil { - return HistoryPage{}, err + row := HistoryRow{Date: d} + if e := entries[d.Format(dateLayout)]; e != nil { + e.Date = d + row.Entry = e } - page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry}) + page.Rows = append(page.Rows, row) } return page, nil } + +// entriesBetween loads every logged day in the inclusive range, keyed by +// stored date string, in two queries rather than two per day. The widest +// window a URL can ask for is five years, which day-at-a-time made 3,600 round +// trips through a pool of exactly one connection. +func entriesBetween(db *sql.DB, from, to time.Time) (map[string]*Entry, error) { + lo, hi := from.Format(dateLayout), to.Format(dateLayout) + + rows, err := db.Query(` + SELECT l.id, l.date, m.id, m.name, m.has_sides, + coalesce((SELECT group_concat(c.category) + FROM main_dish_categories c + WHERE c.main_dish_id = m.id), '') + FROM meal_log l + JOIN main_dishes m ON m.id = l.main_dish_id + WHERE l.date BETWEEN ? AND ?`, lo, hi) + if err != nil { + return nil, err + } + defer rows.Close() + + byDate := map[string]*Entry{} + byLog := map[int64]*Entry{} + for rows.Next() { + var logID int64 + var date, cats string + var e Entry + if err := rows.Scan( + &logID, &date, &e.Main.ID, &e.Main.Name, &e.Main.HasSides, &cats, + ); err != nil { + return nil, err + } + if cats != "" { + e.Main.Categories = strings.Split(cats, ",") + } + byDate[date] = &e + byLog[logID] = &e + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(byLog) == 0 { + return byDate, nil + } + + sides, err := db.Query(` + SELECT ls.meal_log_id, s.id, s.name + FROM meal_log_sides ls + JOIN side_dishes s ON s.id = ls.side_dish_id + JOIN meal_log l ON l.id = ls.meal_log_id + WHERE l.date BETWEEN ? AND ? + ORDER BY s.name`, lo, hi) + if err != nil { + return nil, err + } + defer sides.Close() + + for sides.Next() { + var logID int64 + var s Side + if err := sides.Scan(&logID, &s.ID, &s.Name); err != nil { + return nil, err + } + if e := byLog[logID]; e != nil { + e.Sides = append(e.Sides, s) + } + } + return byDate, sides.Err() +} diff --git a/cmd/foodster/store_test.go b/cmd/foodster/store_test.go index 1a2fdb7..a9a043d 100644 --- a/cmd/foodster/store_test.go +++ b/cmd/foodster/store_test.go @@ -235,7 +235,7 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) { t.Fatalf("save -3: %v", err) } - page, err := history(h.db, loc, now, 60) + page, err := history(h.db, loc, now, 60, time.Time{}) if err != nil { t.Fatalf("history: %v", err) } @@ -271,7 +271,7 @@ func TestHistoryPagesInWindows(t *testing.T) { t.Fatalf("save -9: %v", err) } - first, err := history(h.db, loc, now, 5) + first, err := history(h.db, loc, now, 5, time.Time{}) if err != nil { t.Fatalf("first window: %v", err) } @@ -286,7 +286,7 @@ func TestHistoryPagesInWindows(t *testing.T) { } // The windows must meet exactly: no day repeated, none skipped. - second, err := history(h.db, loc, first.Next, 5) + second, err := history(h.db, loc, first.Next, 5, time.Time{}) if err != nil { t.Fatalf("second window: %v", err) } @@ -305,7 +305,7 @@ func TestHistoryPagesInWindows(t *testing.T) { func TestHistoryEmptyWithoutEntries(t *testing.T) { h := seeded(t) - page, err := history(h.db, time.UTC, today(time.UTC), 60) + page, err := history(h.db, time.UTC, today(time.UTC), 60, time.Time{}) if err != nil { t.Fatalf("history: %v", err) } @@ -316,3 +316,64 @@ func TestHistoryEmptyWithoutEntries(t *testing.T) { t.Error("More is set although there is no history at all") } } + +// A date picked from before the first entry ever logged used to fall outside +// the window entirely: no row, so the log board had nothing to open in and the +// day could not be filled in at all. +func TestHistoryReachesDaysOlderThanTheFirstEntry(t *testing.T) { + h := seeded(t) + loc := time.UTC + now := today(loc) + + if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil { + t.Fatalf("save today: %v", err) + } + + want := now.AddDate(0, 0, -100) + page, err := history(h.db, loc, now, 30, want) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(page.Rows) != 101 { + t.Fatalf("%d rows, want 101 (today back to the selected day)", len(page.Rows)) + } + last := page.Rows[len(page.Rows)-1] + if !last.Date.Equal(want) { + t.Errorf("last row is %s, want the selected %s", + last.Date.Format(dateLayout), want.Format(dateLayout)) + } + if page.More { + t.Error("More is set although the window reached past the oldest entry") + } +} + +// The sides of every day come back in one query now; each still has to land on +// its own day. +func TestHistoryKeepsSidesWithTheirOwnDay(t *testing.T) { + h := seeded(t) + loc := time.UTC + now := today(loc) + + muusi := h.sideNamed(t, "Perunamuusi") + riisi := h.sideNamed(t, "Riisi") + if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), []int64{muusi}); err != nil { + t.Fatalf("save today: %v", err) + } + if err := saveEntry(h.db, now.AddDate(0, 0, -1), h.mainNamed(t, "Lihapullat"), []int64{riisi}); err != nil { + t.Fatalf("save -1: %v", err) + } + + page, err := history(h.db, loc, now, 30, time.Time{}) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(page.Rows) != 2 { + t.Fatalf("%d rows, want 2", len(page.Rows)) + } + for i, want := range []string{"Perunamuusi", "Riisi"} { + got := page.Rows[i].Entry + if got == nil || len(got.Sides) != 1 || got.Sides[0].Name != want { + t.Errorf("row %d sides = %+v, want just %s", i, got.Sides, want) + } + } +} -- 2.54.0 From 00b737cf8daa510a1d3a1a5fdc7f4a9b7cbb16c1 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 6 Sep 2026 10:06:15 +0300 Subject: [PATCH 4/9] ci: add a demo workflow to verify the Gitea runner --- .gitea/workflows/demo.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitea/workflows/demo.yaml diff --git a/.gitea/workflows/demo.yaml b/.gitea/workflows/demo.yaml new file mode 100644 index 0000000..5b275b4 --- /dev/null +++ b/.gitea/workflows/demo.yaml @@ -0,0 +1,8 @@ +name: demo +on: [push] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - run: echo "hello from ${{ gitea.repository }} on ${{ gitea.ref }}" -- 2.54.0 From 5e3650deea5b809e969133809b0f41898cab3a07 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 6 Sep 2026 10:07:46 +0300 Subject: [PATCH 5/9] ci: add checkout to the demo workflow --- .gitea/workflows/demo.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/demo.yaml b/.gitea/workflows/demo.yaml index 5b275b4..0cd34ab 100644 --- a/.gitea/workflows/demo.yaml +++ b/.gitea/workflows/demo.yaml @@ -6,3 +6,5 @@ jobs: runs-on: ubuntu-latest steps: - run: echo "hello from ${{ gitea.repository }} on ${{ gitea.ref }}" + - uses: actions/checkout@v4 + - run: ls -la && git log --oneline -1 -- 2.54.0 From f2ac6c83732102a006efd9b9e58c59b741c0460a Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 6 Sep 2026 10:09:20 +0300 Subject: [PATCH 6/9] ci: skip TLS verification until the registry has a real cert --- .gitea/workflows/demo.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitea/workflows/demo.yaml b/.gitea/workflows/demo.yaml index 0cd34ab..5b4a7ce 100644 --- a/.gitea/workflows/demo.yaml +++ b/.gitea/workflows/demo.yaml @@ -1,6 +1,12 @@ name: demo on: [push] +# ponytail: GIT_SSL_NO_VERIFY is here only because Traefik still serves its +# default self-signed cert for git.kessinen.com. Remove it once the +# LE-DNS01-cloudflare runbook has been run. +env: + GIT_SSL_NO_VERIFY: "true" + jobs: check: runs-on: ubuntu-latest -- 2.54.0 From c0b48ee71a2b6bd4ebe0796453616754fcfffc4c Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 6 Sep 2026 10:12:32 +0300 Subject: [PATCH 7/9] ci: run make check on every dev push --- .gitea/workflows/check.yaml | 22 ++++++++++++++++++++++ .gitea/workflows/demo.yaml | 16 ---------------- 2 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 .gitea/workflows/check.yaml delete mode 100644 .gitea/workflows/demo.yaml diff --git a/.gitea/workflows/check.yaml b/.gitea/workflows/check.yaml new file mode 100644 index 0000000..5bc7e68 --- /dev/null +++ b/.gitea/workflows/check.yaml @@ -0,0 +1,22 @@ +name: check +on: + push: + branches: [dev] + +# ponytail: only because Traefik still serves its default self-signed cert for +# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run. +env: + GIT_SSL_NO_VERIFY: "true" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - run: make check diff --git a/.gitea/workflows/demo.yaml b/.gitea/workflows/demo.yaml deleted file mode 100644 index 5b4a7ce..0000000 --- a/.gitea/workflows/demo.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: demo -on: [push] - -# ponytail: GIT_SSL_NO_VERIFY is here only because Traefik still serves its -# default self-signed cert for git.kessinen.com. Remove it once the -# LE-DNS01-cloudflare runbook has been run. -env: - GIT_SSL_NO_VERIFY: "true" - -jobs: - check: - runs-on: ubuntu-latest - steps: - - run: echo "hello from ${{ gitea.repository }} on ${{ gitea.ref }}" - - uses: actions/checkout@v4 - - run: ls -la && git log --oneline -1 -- 2.54.0 From 6590c184122a2bf173dcf787741e847cdacd5057 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 6 Sep 2026 10:54:22 +0300 Subject: [PATCH 8/9] build: move releases to CI and trim the Makefile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging a pull request into main is now the whole release. A Gitea Actions workflow derives the CalVer tag, builds the image and pushes it with :latest, so nothing is built locally any more. That made image/push/release redundant, and with them the .release-tag state file and the main-branch guard — the workflow only runs on main, which is protected, so the guard had nothing left to catch. The digest verification went too: it guarded a `make -j` race between image and push that cannot happen in a single CI job. seed, icons and vendor ran a few times a year and are written out in the README instead. Makefile: 151 lines to 71. Docs referenced the removed targets in sixteen places, including a CONTRIBUTING note claiming the branch check "has to be local". --- .gitea/workflows/release.yaml | 58 ++++++++++++++++ .gitignore | 3 - CONTRIBUTING.md | 8 +-- Makefile | 83 +---------------------- PRD.md | 29 ++++---- README.md | 124 ++++++++++++++++++++-------------- assets/icon.svg | 2 +- cmd/foodster/main.go | 2 +- 8 files changed, 158 insertions(+), 151 deletions(-) create mode 100644 .gitea/workflows/release.yaml diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..144dcd9 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,58 @@ +name: release +on: + push: + branches: [main] + +# ponytail: only because Traefik still serves its default self-signed cert for +# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run. +env: + GIT_SSL_NO_VERIFY: "true" + REGISTRY: git.kessinen.com + IMAGE: git.kessinen.com/kessinen/foodster + +jobs: + image: + runs-on: ubuntu-latest + steps: + # Full history and tags: the release number is derived by counting the + # tags already cut today. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The job container is node:22-bookworm and has no docker client. The + # static binary is one file; installing docker.io would pull a daemon + # that is never used, since the build runs against the host's. + - name: Install the docker client + run: | + curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker + docker version --format '{{.Client.Version}}' + + - name: Work out the release tag + id: rel + run: | + day=$(date +%Y%m%d) + tag="v$day-$(( $(git tag -l "v$day-*" | wc -l) + 1 ))" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "==> $tag" + + - name: Tag the commit + run: | + git tag "${{ steps.rel.outputs.tag }}" + git push origin "${{ steps.rel.outputs.tag }}" + + - name: Log in to the registry + run: | + echo "${{ secrets.GITEA_TOKEN }}" \ + | docker login "$REGISTRY" -u "${{ gitea.actor }}" --password-stdin + + - name: Build and push + run: | + tag="${{ steps.rel.outputs.tag }}" + docker build --platform linux/amd64 --build-arg VERSION="$tag" \ + -f Containerfile \ + -t "$IMAGE:$tag" -t "$IMAGE:latest" . + docker push "$IMAGE:$tag" + docker push "$IMAGE:latest" + echo "pushed $IMAGE:$tag and :latest - pull it in dockge when ready" diff --git a/.gitignore b/.gitignore index 356629e..cf5bd37 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,6 @@ # Build output /foodster -# The tag `make image` last built, handed to `make push`. -/.release-tag - # Generated by `templ generate` during the container build. *_templ.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15a8a89..c95a2cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,9 +29,9 @@ 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. +The release workflow only triggers on `main`, and `main` only moves through a +pull request, so a release can never be built from the wrong branch. Nothing +needs to check for it. ## Commit messages @@ -71,7 +71,7 @@ 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. +title — CI creates the CalVer tag after the merge, so it is not known yet. The types above are for `dev`, where a commit really does do one thing. diff --git a/Makefile b/Makefile index 0468112..a6cda68 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,8 @@ COMPOSE ?= podman compose BIN := foodster PKG := ./cmd/foodster -STATIC := cmd/foodster/static -# What `make image` last built. push reads it rather than re-deriving the tag: -# sorting tags by date is ambiguous when two point at the same commit, and -# re-deriving is what let a parallel make push the wrong one. -TAGFILE := .release-tag - -# Vendored Datastar client. Bump, run `make vendor`, commit the result. -DATASTAR_VERSION ?= v1.0.3 -SEED ?= seeds/testi.json - -# Registry coordinates, shared password and TZ live here. Gitignored. +# Shared password and TZ live here. Gitignored. ifneq (,$(wildcard .env)) include .env export @@ -24,7 +14,7 @@ endif GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null) .DEFAULT_GOAL := help -.PHONY: help generate build run seed test smoke check lint fix icons vendor image push release up down logs clean +.PHONY: help generate build run test smoke check lint fix up down logs clean help: ## Show this help @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ @@ -40,9 +30,6 @@ build: generate ## Build ./foodster run: generate ## Run locally on :8080 (database in ./data) PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG) -seed: ## Import a dish bundle (SEED=seeds/testi.json) - go run $(PKG) -import $(SEED) - test: generate ## Run unit tests go test ./... @@ -57,21 +44,6 @@ check: ## Everything that must pass before a commit @$(MAKE) --no-print-directory smoke @echo "check: all passed" -icons: ## Rasterise home-screen PNGs from assets/icon.svg and optimise them - rsvg-convert -w 180 -h 180 assets/icon.svg -o $(STATIC)/apple-touch-icon.png - rsvg-convert -w 192 -h 192 assets/icon.svg -o $(STATIC)/icon-192.png - rsvg-convert -w 512 -h 512 assets/icon.svg -o $(STATIC)/icon-512.png - # oxipng -o max alone loses to optipng on the 512; --zopfli wins at every - # size. Slow, but these are three tiny files built by hand. - oxipng -o max --zopfli --quiet \ - $(STATIC)/apple-touch-icon.png $(STATIC)/icon-192.png $(STATIC)/icon-512.png - @ls -l $(STATIC)/*.png - -vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3) - curl -sSfL -o $(STATIC)/datastar.js \ - "https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js" - @head -1 $(STATIC)/datastar.js - lint: generate ## go vet, gofmt check, golangci-lint when installed go vet ./... @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ @@ -84,57 +56,6 @@ fix: ## Format Go and templ sources, tidy go.mod go tool templ fmt . 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; } - @# A release tag must point into main, or the tag records a commit that - @# was never released. - @branch=$$(git symbolic-ref --short HEAD); \ - if [ "$$branch" != "main" ]; then \ - echo "releases are cut from main, not $$branch:"; \ - echo " git switch main && git merge --ff-only dev"; \ - exit 1; \ - fi - @day=$$(date +%Y%m%d); \ - tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \ - echo "==> $$tag"; \ - git tag "$$tag"; \ - podman build --platform linux/amd64 --build-arg VERSION="$$tag" \ - -t "$(REPO):$$tag" -t "$(REPO):latest" . ; \ - echo "$$tag" > $(TAGFILE) - -# Pushing reported success while uploading the previous release once, because -# nothing compared what was built against what arrived. So afterwards, ask the -# 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 -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \ - tag=$$(cat $(TAGFILE)); \ - built=$$(podman image inspect "$(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"; \ - echo "==> verifying $$tag"; \ - for ref in "$$tag" latest; do \ - podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \ - { echo " FAIL $$ref is not in the registry"; exit 1; }; \ - served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \ - if [ "$$served" != "$$built" ]; then \ - echo " FAIL $$ref serves $$served"; \ - echo " expected $$built"; \ - exit 1; \ - fi; \ - echo " ok $$ref"; \ - done - -# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on -# at least one machine here — these run concurrently, so push resolves the -# newest tag and uploads :latest before image has finished building and -# tagging. That silently ships the previous release a second time. -release: ## Build, tag and push in one go - @$(MAKE) --no-print-directory image - @$(MAKE) --no-print-directory push - up: ## Start the stack @mkdir -p data # or the engine creates it root-owned and the app cannot write $(COMPOSE) up -d diff --git a/PRD.md b/PRD.md index c7716d2..3439bb0 100644 --- a/PRD.md +++ b/PRD.md @@ -372,22 +372,27 @@ Explicitly *not* React. ## 10. Deployment -Images are built locally, pushed to a private container registry, then pulled -on the server and run with Docker Compose. +Images are built by CI, pushed to a private container registry, then pulled on +the server and run with Docker Compose. - **Branches**: `main` carries released versions only, so its history is the deployment history and every release tag points into it. Development happens on `dev`, and `main` is protected on the remote: it accepts no direct - pushes, so a release arrives as a pull request from `dev`. `make image` - additionally refuses to run outside `main` — that one has to be local, - because the tag and the image are made before anything reaches the remote. + pushes, so a release arrives as a pull request from `dev`. The release + workflow runs only on `main`, so a release cannot be built from anywhere + else and nothing needs to check for it. - **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that - day. `make image` derives `N` by counting the day's existing git tags, - creates the new tag, and bakes the version into the binary through - `-ldflags -X main.version`. `make release` builds, tags and pushes. -- **Tooling**: a `Makefile` is the single entry point — `make` on its own - lists every target. Build, test, lint, format, image and compose commands - all live there rather than in loose scripts. + day. The release workflow derives `N` by counting the day's existing git + tags, creates the new tag, and bakes the version into the binary through + `-ldflags -X main.version`. +- **CI**: Gitea Actions, workflows in `.gitea/workflows/`. `check.yaml` runs + `make check` on every push to `dev`; `release.yaml` builds and pushes the + image when a pull request merges into `main`. Merging is the release — + there is no local build step. +- **Tooling**: a `Makefile` covers development — `make` on its own lists every + target. Build, test, lint, format and compose commands live there rather + than in loose scripts. Commands run a handful of times a year are written + out in the README instead of earning a target. - **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a static binary; the runtime stage is `FROM scratch` holding only that binary, running as UID 65534. @@ -425,7 +430,7 @@ on the server and run with Docker Compose. - Pending migrations are applied on app start. - The Datastar client is vendored at `cmd/foodster/static/datastar.js` and served from the app's own origin — the SDK ships no browser asset, and a - CDN link would break an offline LAN. `make vendor` refreshes it; the pinned + CDN link would break an offline LAN. The README says how to refresh it; the pinned version lives in the `Makefile` and in the file's first line. - No internet exposure; the server binds to the LAN. diff --git a/README.md b/README.md index 886700a..646ca1e 100644 --- a/README.md +++ b/README.md @@ -11,35 +11,35 @@ See [PRD.md](PRD.md) for the full specification. ## Status -**Stage 1 — eating history: in development.** The meal catalog and the daily -log come first, because the suggester is worthless until there are a few -weeks of real history to weight against. +**Stage 1 — eating history: in use.** The meal catalog and the daily log came +first, because the suggester is worthless until there are a few weeks of real +history to weight against. Working: -- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered - and sized by how often they are eaten, so the likely answer is the biggest - target. The history sits on the same page underneath: every day back to the - first entry, unlogged days shown as explicit gaps, and every row a link that - loads that day into the logger above it. +- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are grouped + by category, then ordered and sized by how often they are eaten, so the + likely answer is the biggest target. The history sits on the same page + underneath: every day back to the first entry, unlogged days shown as + explicit gaps, and every row opening that day's logger in place. Older days + arrive a window at a time. - **Ruuat** — add, edit and delete mains and sides, or import a whole bundle by paste or file upload. Grouped by category and alphabetical inside, since - this is a list you manage rather than one you pick from. Deletes are soft, - so old log entries keep showing the dish they used. + this is a list you manage rather than one you pick from. Edit and delete are + row icons, and a delete asks first. Deletes are soft, so old log entries + keep showing the dish they used. +- **Search as you type** on both tabs, debounced, patching just the list. +- **Category icons**, not colour dots: shape and colour together, so two marks + are told apart by more than hue. - **Light / dark**, remembered per device, dark by default. The button shows the theme that is on — moon while dark, sun while light — not the one a click would bring. +Nothing navigates. Every interaction patches the page through Datastar, so +the scroll position survives; links and forms still work with JavaScript off. + Still to build: -- Live search as you type, and paging for the history and catalog lists once - years of entries make them long. Both via Datastar. -- Category icons instead of plain colour dots — colour and shape together, so - a red blob and a yellow blob are told apart by more than hue. -- Edit and delete as icons in the catalog rows, and a confirmation step before - a delete actually happens. -- A background for the header. Something subtle; the palette gets overhauled - later. - Stage 2: the seven-meal suggester, which starts once there is history to weight against. @@ -74,20 +74,20 @@ no direct pushes, so a release arrives through a pull request. git switch dev # where the work happens # ... commits ... make check # lint, unit tests, smoke -git push origin dev +git push origin dev # CI runs make check too tea pr create --base main --head dev # or open it in the forge -# merge the pull request, then: - -git switch main && git pull --ff-only -make release # builds, tags vYYYYMMDD-N, pushes the image -git push origin --tags +# squash-merge the pull request — that is the whole release ``` -`make image` additionally refuses to run from any branch but `main`, so a -release tag can never point at a commit that was not released. That check -lives locally because it has to: tags and images are built before anything -reaches the remote, so protection there cannot catch it. +Merging is the release. CI builds the image, tags it `vYYYYMMDD-N` and +`latest`, pushes both to the registry, and creates the matching git tag. There +is nothing to run locally afterwards; pull the new image on the server when +you are ready. + +A release tag can therefore never point at a commit that was not released: +the workflow only runs on `main`, and `main` only moves through a pull +request. ## Quick start @@ -102,15 +102,39 @@ make run # http://localhost:8080 make fix gofmt, templ fmt, go mod tidy make lint go vet, gofmt check, golangci-lint when installed make test go test ./... +make smoke end-to-end check against a scratch server +make check lint + test + smoke — run before every commit make build ./foodster -make seed import a dish bundle (SEED=seeds/testi.json) -make vendor re-download the Datastar client -make image build and tag vYYYYMMDD-N (creates a git tag) -make push push the newest tag and :latest -make release image + push make up/down/logs compose ``` +Images are built by CI, not here — see [Deployment](#deployment). + +### Occasional commands + +Rare enough not to earn a `make` target. Both write into +`cmd/foodster/static/`, and the results are committed. + +Re-download the vendored Datastar client after bumping the version: + +```sh +curl -sSfL -o cmd/foodster/static/datastar.js \ + "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.3/bundles/datastar.js" +``` + +Re-rasterise the home-screen icons after editing `assets/icon.svg`: + +```sh +cd cmd/foodster/static +rsvg-convert -w 180 -h 180 ../../../assets/icon.svg -o apple-touch-icon.png +rsvg-convert -w 192 -h 192 ../../../assets/icon.svg -o icon-192.png +rsvg-convert -w 512 -h 512 ../../../assets/icon.svg -o icon-512.png +oxipng -o max --zopfli --quiet apple-touch-icon.png icon-192.png icon-512.png +``` + +`oxipng -o max` on its own loses to optipng on the 512; `--zopfli` wins at +every size. Slow, but these are three tiny files built by hand. + ## Importing dishes The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload @@ -141,7 +165,7 @@ The same importer runs from the command line when you just want to repopulate a scratch database: ```sh -make seed # or: SEED=seeds/other.json make seed +go run ./cmd/foodster -import seeds/testi.json ``` ## Icons @@ -152,12 +176,8 @@ cannot be transparent and must not change with the theme; they are rasterised from `assets/icon.svg`, which is opaque and keeps the artwork inside the central 80% so Android can mask it to any shape. -```sh -make icons # rsvg-convert, then optipng -o7 -``` - -The PNGs are committed so the build needs no rasterizer. Re-run `make icons` -after editing `assets/icon.svg`. +The PNGs are committed so the build needs no rasterizer. The commands to +regenerate them are under [Occasional commands](#occasional-commands). ## Migrations @@ -182,25 +202,24 @@ Everything is environment variables. `.env` is gitignored; start from | `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. | +| `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. | +| `TAG` | `latest` | Tag to run under compose. | +| `HOST` | *required to run* | Hostname Traefik routes to. | 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. ## Deployment -Images are built with Podman and run under Docker Compose on a LAN server. -They are OCI images, so either engine works. +Images are built by CI when a pull request merges into `main`, and run under +Docker Compose on a LAN server. They are OCI images, so either engine works. ```sh -make release # build, tag, push -# on the server: +# on the server, once CI reports the build finished: docker compose pull && docker compose up -d ``` @@ -209,8 +228,15 @@ running version is served at `GET /healthz`, which is the one route outside authentication. There is no database container. SQLite lives in `./data`, bind-mounted into -the container, so a backup is `cp -r data` and you can inspect the file with -any sqlite client without going through the engine. +the container, so you can inspect the file with any sqlite client without +going through the engine. Back it up with + +```sh +sqlite3 data/foodster.db ".backup data/foodster-$(date +%F).db" +``` + +rather than copying the directory: the database runs in WAL mode, and a plain +copy of a live database can catch the `.db` and its `-wal` mid-write. 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 diff --git a/assets/icon.svg b/assets/icon.svg index 5560ce2..6ae433d 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1,6 +1,6 @@ -