4 Commits
Author SHA1 Message Date
Esa Kataja 00b737cf8d ci: add a demo workflow to verify the Gitea runner
demo / check (push) Successful in 37s
2026-09-06 10:06:15 +03:00
Esa Kataja 0f0f04240a fix(kirjaa): open days older than the first entry ever logged
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.
2026-09-06 00:23:10 +03:00
Esa Kataja 2695b8c01b 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.
2026-09-06 00:08:49 +03:00
Esa Kataja 85bf5e1a25 test: make the smoke script use relative dates
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.
2026-09-06 00:04:32 +03:00
10 changed files with 159 additions and 180 deletions
-22
View File
@@ -1,22 +0,0 @@
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
+8
View File
@@ -0,0 +1,8 @@
name: demo
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
- run: echo "hello from ${{ gitea.repository }} on ${{ gitea.ref }}"
-58
View File
@@ -1,58 +0,0 @@
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"
+3
View File
@@ -4,6 +4,9 @@
# 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
+4 -4
View File
@@ -29,9 +29,9 @@ git switch dev && git reset --hard main
git push --force-with-lease origin dev
```
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.
`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
@@ -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 — CI creates the CalVer tag after the merge, so it is not known yet.
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.
+81 -2
View File
@@ -3,8 +3,18 @@
COMPOSE ?= podman compose
BIN := foodster
PKG := ./cmd/foodster
STATIC := cmd/foodster/static
# Shared password and TZ live here. Gitignored.
# 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.
ifneq (,$(wildcard .env))
include .env
export
@@ -14,7 +24,7 @@ endif
GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null)
.DEFAULT_GOAL := help
.PHONY: help generate build run test smoke check lint fix up down logs clean
.PHONY: help generate build run seed test smoke check lint fix icons vendor image push release up down logs clean
help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
@@ -30,6 +40,9 @@ 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 ./...
@@ -44,6 +57,21 @@ 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); \
@@ -56,6 +84,57 @@ 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
+12 -17
View File
@@ -372,27 +372,22 @@ Explicitly *not* React.
## 10. Deployment
Images are built by CI, pushed to a private container registry, then pulled on
the server and run with Docker Compose.
Images are built locally, pushed to a private container registry, then pulled
on the server and run with Docker Compose.
- **Branches**: `main` carries released versions only, so its history is the
deployment history and every release tag points into it. Development happens
on `dev`, and `main` is protected on the remote: it accepts no direct
pushes, so a release arrives as a pull request from `dev`. The release
workflow runs only on `main`, so a release cannot be built from anywhere
else and nothing needs to check for it.
pushes, so a release arrives as a pull request from `dev`. `make image`
additionally refuses to run outside `main` — that one has to be local,
because the tag and the image are made before anything reaches the remote.
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that
day. 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.
day. `make image` derives `N` by counting the day's existing git tags,
creates the new tag, and bakes the version into the binary through
`-ldflags -X main.version`. `make release` builds, tags and pushes.
- **Tooling**: a `Makefile` is the single entry point — `make` on its own
lists every target. Build, test, lint, format, image and compose commands
all live there rather than in loose scripts.
- **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a
static binary; the runtime stage is `FROM scratch` holding only that
binary, running as UID 65534.
@@ -430,7 +425,7 @@ 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. The README says how to refresh it; the pinned
CDN link would break an offline LAN. `make vendor` refreshes it; the pinned
version lives in the `Makefile` and in the file's first line.
- No internet exposure; the server binds to the LAN.
+49 -75
View File
@@ -11,35 +11,35 @@ See [PRD.md](PRD.md) for the full specification.
## Status
**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.
**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.
Working:
- **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.
- **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.
- **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. 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.
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.
- **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 # CI runs make check too
git push origin dev
tea pr create --base main --head dev # or open it in the forge
# squash-merge the pull request — that is the whole release
# 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
```
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.
`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.
## Quick start
@@ -102,39 +102,15 @@ 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/[email protected]/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
@@ -165,7 +141,7 @@ The same importer runs from the command line when you just want to repopulate
a scratch database:
```sh
go run ./cmd/foodster -import seeds/testi.json
make seed # or: SEED=seeds/other.json make seed
```
## Icons
@@ -176,8 +152,12 @@ 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.
The PNGs are committed so the build needs no rasterizer. The commands to
regenerate them are under [Occasional commands](#occasional-commands).
```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`.
## Migrations
@@ -202,24 +182,25 @@ 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 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.
Images are built with Podman and run under Docker Compose on a LAN server.
They are OCI images, so either engine works.
```sh
# on the server, once CI reports the build finished:
make release # build, tag, push
# on the server:
docker compose pull && docker compose up -d
```
@@ -228,15 +209,8 @@ 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 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.
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
+1 -1
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="Foodster">
<!-- Source for the home-screen PNGs; the README says how to rasterise it.
<!-- Source for the home-screen PNGs; `make icons` rasterises it.
Unlike favicon.svg this one is opaque and fixed-colour: a home screen
icon cannot be transparent and must not change with the system theme.
The bowl sits inside the central 80% so Android can mask it to any

Before

Width:  |  Height:  |  Size: 927 B

After

Width:  |  Height:  |  Size: 918 B

+1 -1
View File
@@ -32,7 +32,7 @@ import (
//go:embed static
var staticFS embed.FS
// version is replaced at build time with the CalVer tag by the release workflow.
// 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