Two changes that keep the lists usable after years of entries, and the first
real use of the Datastar client that has been shipping unused.
Paging: history() now walks back from any given day and returns the rows plus
whether older ones exist. The page shows a 30-day window and "Näytä lisää"
grows it by another 30 through ?paivat=, capped at five years so a
hand-edited URL cannot ask for a decade of rows at once. Two tests check that
consecutive windows meet exactly, repeating no day and skipping none, which
is the mistake this shape invites.
Live search: both search boxes bind to a Datastar signal and re-render their
list 250 ms after typing stops. The board search and the catalog search each
return only their own fragment, and the catalog search covers sides as well
as mains.
Three things worth knowing about the Datastar side. Its attribute syntax is
colon-separated in v1.0.3 — data-on:input, not data-on-input; the dashed form
parses as a plugin named "on-input", matches nothing, and fails silently. A
plain text/html response is accepted and matched to the element by id, so
there is no SSE stream to manage and the SDK is used only for ReadSignals.
And both boxes are still ordinary GET forms, so ?haku= filters server-side
with JavaScript off: the live version is an enhancement, not a requirement.
Smoke checks send the real ?datastar={"haku":"..."} wire format and assert
the response is a fragment rather than a whole page.
A coloured dot means nothing until the legend has been learned, which is the
same objection that got the checkboxes made visible. The marks now carry
colour and shape together: a steak, a drumstick, a fish, a leaf, and a
quartered circle for the dishes covering several categories at once.
The steak's bone is a real hole rather than a shape painted in the background
colour, and the leaf lost the midrib it had in the mockup for the same
reason: these sit on cards in the board and on the page background in the
history list, so anything relying on knowing the backdrop breaks in one of
them.
One component, three sizes driven from CSS: 16px normally, 19 in the large
pills, 22 on the logged card. It covers the board, the sides step, the logged
card, the history rows, the catalog rows and the category checkboxes, so no
dot markup or CSS is left.
The brand row and theme toggle sit on their own background with a hairline
under it, so the header reads as an object rather than text floating on the
page. The page title and day switcher stay on the page background: they are
content, not chrome.
The colour is its own --header variable rather than a reuse of --card, so the
header can be recoloured without dragging every card along when the palette
gets overhauled. The theme-color meta tags match it, so on a phone the browser
chrome continues the header instead of butting a different shade against it;
those need literal hex, so each file points at the other.
Muokkaa and Poista become a pencil and a bin, which stops the catalog rows
being two words wide. Both carry a Finnish aria-label and title, so nothing
is lost by dropping the text.
An icon is easier to hit by accident than a word, so neither delete happens
immediately now. A tapped bin turns that row's actions into "Poista? Kyllä /
Peruuta", and a logged meal asks "Poistetaanko merkintä?" before it goes.
The meal is the more destructive of the two: a dish is only soft-deleted and
its name still resolves in old entries, while the log row is dropped outright.
Both confirmations are plain links and forms, so they work with the back
button and need no client code.
Also fixes a test that was passing for the wrong reason. The delete check only
asserted a 303 and took the first dish id on the page, which stopped being the
one it had just created when the catalog became grouped and alphabetical — so
it was deleting an unrelated dish. It now finds that dish's own id, and a new
refute helper asserts the dish is actually gone afterwards.
The log is a record of what was eaten, so 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. A future date is now clamped to today.
The clamp lives in the one function every read and write already goes
through, so ?pvm=, the date picker, saving and deleting are all covered. The
picker also gets max=today, which avoids offering the dead end at all.
Fixes a latent bug found alongside it: today() returned the current instant
with its time of day, while dates parsed from ?pvm= are midnight, so the two
never compared equal. After saving today's dinner the redirect landed on
/?pvm=... and the card then read "la 5.9. kirjattu" instead of "Tänään
kirjattu", and "Tänään" stopped highlighting whenever the date was spelled
out. today() now truncates to midnight in the configured location.
Kirjaa and Historia were two views of the same thing: every history row was
already a link back into the logger, and the logger had a day switcher. They
are now one page — the day being logged on top, history underneath, each row
loading its day into the logger above. Two tabs instead of three.
That also closed a gap. On an already-logged day there was no way to swap to
a different dish; "Muokkaa" only reopened the sides for the same one. It now
opens the board, so changing a dish and choosing one for the first time are
the same path.
Dishes are grouped by category on both screens, with Sekalaiset collecting
the ones covering more than one. That group is derived from the stored set
rather than being a fifth category, so a single Tortillat still satisfies
meat, chicken, fish and vegetarian at once when the §8.1 suggester arrives.
The two screens sort differently on purpose. The log board keeps frequency
then name inside each group, so favourites surface without wandering between
categories as counts change. The catalog sorts by name, because there you are
hunting a specific dish to edit rather than picking one to eat. groupDishes
preserves the order it is handed; the caller decides which it wants.
Ruoat is renamed Ruuat throughout, label and route both.
main is protected on the remote and takes no direct pushes, so a release
arrives as a pull request from dev. Document that flow.
`make image` refuses to run outside main. That check has to live locally:
the tag and the image are made before anything reaches the remote, so branch
protection cannot catch a release built from the wrong branch.
A pre-commit hook was tried and dropped. It needed installing per clone, so
it enforced nothing that the remote was not already enforcing, while implying
it did.
sql.Open is lazy, so a permission problem surfaced from whichever query ran
first: "create schema_migrations: unable to open database file (14)", which
names neither the file nor the reason. Ping on open and report the path and
the effective uid and gid instead.
The cause in practice is a bind-mounted ./data that Docker created as root
while the container runs as FOODSTER_UID. Documented in the README.
The deployment is a public hostname behind Traefik rather than a LAN-only
box, which changes two things.
TLS is now terminated by the proxy, so Basic credentials are no longer in
cleartext. The container publishes no ports: doing so would leave 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 and the MIT publication option stays open.
The password is now the only thing between the internet and the app, and a
500 ms sleep is not a defence at that exposure. Wrong guesses are rate
limited per client address: five in a burst, then one per ten seconds,
answered with 429.
Two details that decide whether this works at all:
- a request with no Authorization header is not charged. That is the
handshake every browser session opens with, and counting it would lock the
household out for simply opening the app a few times.
- X-Forwarded-For is believed only when the connection arrived from a private
address, i.e. through the proxy, and then only its last entry, which is the
one the proxy observed. A direct client could otherwise forge a fresh
address per attempt and walk past the limiter entirely.
None of this substitutes for a strong password. It removes brute force as a
practical route, nothing more. PRD §3, §9 and §10 are updated: "no external
internet exposure" is no longer true.
main carries released versions only, so its history is the deployment
history and every release tag points into it. Development happens on dev
and merges into main when a release is cut.
Three pieces of chrome that were missing or misleading.
A header with the bowl mark and the app name, on every page. The page title
bar below it is no longer sticky: on a phone a tall sticky header eats the
screen, and the bottom tab bar already handles navigation.
A theme toggle, remembered per device in localStorage, because a shared
instance with no accounts should let the kitchen phone and the laptop
disagree. Dark by default, and the default lives in the server-rendered
markup so it survives with JavaScript off and never flashes. The button shows
the theme that is on — moon while dark, sun while light — rather than the one
a click would bring, and its label names the state before the action. Both
icons ship on every page and CSS picks one, so the server never needs to know
what this device chose. Following the system was considered and dropped: a
third state costs a control harder to read than the choice is worth.
The checkbox chips no longer hide the native control behind opacity: 0. With
only a background colour to go on there was no way to tell "Tarjoillaan
lisukkeiden kanssa" on from off. Colour is not a state indicator; a checkbox
is. This covers the side, category and has_sides chips alike.
Smoke checks cover the parts that would regress silently: that dark is the
no-JS default, and that both theme icons are present for CSS to choose from.
The favicon is a bowl of soup: this household's catalog is half keitto, so it
is at least honest. favicon.svg follows the system theme in the tab strip.
Home-screen icons cannot do the same. They must be opaque and must not change
with the theme, so assets/icon.svg is a separate fixed-colour source, with the
artwork inside the central 80% for Android to mask to any launcher shape. The
same 512 is declared maskable in the manifest.
`make icons` rasterises with rsvg-convert and compresses with
`oxipng -o max --zopfli`, which beat `optipng -o7` at every size — 5860 bytes
against 6109 for the three files. Plain `oxipng -o max` was not an upgrade: it
won the two small icons and lost the 512, ending up larger overall. All output
verified pixel-identical to the rasterizer. The PNGs are committed so the
build needs no rasterizer.
Two things that only fail on a real device, so both are covered by smoke
checks: Go has no mime entry for .webmanifest and served it as octet-stream,
which browsers ignore; and a manifest fetch carries no credentials by default,
so behind Basic auth it needs crossorigin="use-credentials" or it 401s.
The catalog could only be filled by importing JSON, which is a poor way to
add the one dish you are about to eat.
Ruoat now covers PRD §7.3 in full: add and edit mains with their categories
and has_sides, add and edit sides, and delete either. Deletes are soft, so a
log entry keeps resolving the dish it used and the freed name can be reused.
Validation messages are Finnish and the rejected form comes back filled in
rather than blank. Bulk import moves into a details element, since it is now
the occasional path rather than the only one.
Kirjaa gets the same ability without the detour: a search that finds nothing
offers to add what was typed, and saving creates the dish and continues
straight to the sides step. An empty catalog shows the same card instead of
dead-ending on a link to another tab, and the search box is no longer hidden
behind the empty state.
The importer's own insert is gone; it and the UI both go through createMain
and createSide, so duplicate detection lives in one place and reason() can
match on errNameTaken instead of poking at driver strings.
Stage 1's point is collecting eating history, so the logging path is the one
that has to be frictionless: pick a dish, tick sides, done.
Kirjaa:
- dishes ordered and sized by how often they have been eaten, so the likely
answer is the biggest target on the screen
- picking a dish opens the sides step; a dish with has_sides false says so
instead of offering an empty list
- saving redirects, so a refresh cannot double-post
- a day already logged shows the entry with edit and delete. Editing reopens
the sides step with the existing sides ticked, which makes editing and
creating the same screen
- day switcher and a server-side search over the catalog
Historia walks back day by day to the oldest entry, so a day nobody wrote
down appears as an explicit gap rather than quietly missing.
No JavaScript is involved: every interaction is a link or a form, and the
checked-chip styling is :has(input:checked). Datastar stays loaded but unused
until an interaction genuinely needs to avoid a page load.
Also fix a Makefile ordering bug: lint did not depend on generate, so `go vet`
could run against templ output that was being rewritten. check now runs its
phases as sub-makes so `make -j` cannot interleave them.
Records two features that are specified but not built: dish CRUD in the UI
(§7.3, only import exists today) and a per-device light/dark switch (§7.4).
The switch must show the state that is active, not the one clicking produces.
SQLite writes three files — the database plus its -wal and -shm companions.
Putting them in one directory means a deployment mounts a single path and a
backup copies a single directory.
- FOODSTER_DB defaults to ./data/foodster.db, and openDB creates the parent
directory on startup rather than failing on a fresh checkout
- compose bind-mounts ./data instead of using a named volume, so the file can
be listed, copied and opened with any sqlite client without going through
the container engine
- the image runs as UID 65534, which cannot write to a host directory owned
by someone else, so compose now sets user: from FOODSTER_UID/FOODSTER_GID
- `make up` creates ./data first: left to the engine it appears root-owned
and the app silently cannot write to it
Bring up the stage 1 skeleton described in the PRD, enough that the app
builds, serves, and can be populated with dishes.
- net/http server with shared-password Basic auth, /healthz outside it,
graceful shutdown, and TZ-aware calendar days
- SQLite via modernc (pure Go, static binary), opened with WAL and a single
connection
- migration runner: numbered SQL files embedded and applied once each inside
a transaction, recorded in schema_migrations
- bundle import (PRD §7.3) as a live feature on the Ruoat tab: paste JSON or
upload a file, get a per-row Finnish report. The same importer is reachable
as `foodster -import` for repopulating a scratch database
- templ views and hand-written CSS with light-dark() theming; the Datastar
v1.0.3 client is vendored, since the Go SDK ships no browser asset and a
CDN would break an offline LAN
Names are normalized to sentence case rather than title case: Finnish
capitalizes only the first word of a phrase, so "Keitetyt perunat" is right
and "Keitetyt Perunat" is not. PRD §6 and §7.3 are amended to match.
Testing is behind make targets rather than ad-hoc commands: `make check` runs
lint, unit tests and scripts/smoke.sh, which exercises auth, static assets
and every import path against a scratch database on a spare port.
Foodster is a self-hosted dinner log and meal suggester for one household.
Stage 1 (eating history) is in development; stage 2 (the suggester) follows
once there is enough history to weight against.
Replace the PRD's original stack (Nuxt, Postgres, Drizzle, Pico CSS) with
Go 1.27, net/http, templ, Datastar and SQLite — one static binary, no
Node.js in the build. Sections 3, 4, 5, 9, 10 and 11 are rewritten to match.
Record the decisions made while reviewing mockups:
- the UI is written in Finnish; PRD, code and comments stay English
- access is one shared password over HTTP Basic rather than open on the LAN
- images are CalVer vYYYYMMDD-N, built with Podman, run under Docker Compose
- registry coordinates live in .env, so no infrastructure detail is
committed and the MIT publication option stays open
mockups/ holds standalone HTML design studies. log-fi.html is the current
one; the others are superseded exploration kept for reference.