9 Commits
Author SHA1 Message Date
Esa Kataja 1ce2d2b9f5 Decode Datastar signals without the SDK
The SDK was used for exactly one call, ReadSignals, which is a JSON decode of
a query parameter. It brought four modules with it, including an HTTP
compression stack, for an SSE generator this app never touches: the search
handlers answer with plain text/html and let Datastar match the fragment by
id.

Five lines replace it. Absent or empty is deliberately not an error — the
first request carries no signals, and rejecting it would 400 the initial
load. Tests cover absent, empty, malformed, and extra signals present.

Nothing changes on the client: Datastar still sends the same
?datastar={"haku":"..."}, and the smoke checks that assert that wire format
are what make the swap safe.
2026-09-05 22:00:40 +03:00
Esa Kataja 0f2cd9f0a9 Page the history and search both lists as you type
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.
2026-09-05 21:57:15 +03:00
Esa Kataja aa6541addd Replace the category dots with icons
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.
2026-09-05 21:46:14 +03:00
Esa Kataja 3564d74c39 Give the header a surface
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.
2026-09-05 21:37:03 +03:00
Esa Kataja b67c4edd5a Ask before deleting, and put the row actions on icons
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.
2026-09-05 21:34:18 +03:00
Esa Kataja e64c4aa212 Refuse to log meals in the future
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.
2026-09-05 21:31:23 +03:00
Esa Kataja 4ce189d1e4 Merge the log and history into one page, group dishes by category
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.
2026-09-05 21:28:14 +03:00
Esa Kataja 705ad5af26 Cut releases from main only, enforced where it can be
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.
2026-09-05 20:52:16 +03:00
Esa Kataja 813e82ef5c Say which path and user cannot open the database
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.
2026-09-05 20:23:44 +03:00
2 changed files with 52 additions and 108 deletions
+2 -39
View File
@@ -423,52 +423,15 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
cursor: pointer;
}
/* Catalog structure: Pääruuat and Lisukkeet are the two halves of the
catalog, the categories are subdivisions of the first. Two levels, so they
must not look alike. */
.section + .section { margin-top: 34px; }
.sectiontitle {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 4px;
padding-bottom: 8px;
border-bottom: 2px solid var(--ink);
font-size: 18px;
font-weight: 700;
letter-spacing: -0.03em;
}
.sectiontitle .count {
padding: 2px 8px;
border-radius: 999px;
background: var(--sunk);
color: var(--muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
}
/* Catalog rows */
.sechead {
margin: 20px 0 2px;
margin: 26px 0 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--muted);
}
/* Collapsed add/edit forms, so the page opens on the catalog. */
.addform > summary {
cursor: pointer;
font-weight: 600;
font-size: 15px;
min-height: 24px;
}
.addform[open] > summary {
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.row {
display: flex;
align-items: center;
+29 -48
View File
@@ -531,6 +531,8 @@ templ catalogPage(v catalogView) {
if v.Report != nil {
@importReport(v.Report)
}
@mainForm_(v.Main)
@sideForm_(v.Side)
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/ruuat" class="searchrow">
<input
@@ -544,10 +546,6 @@ templ catalogPage(v catalogView) {
data-on:input__debounce.250ms="@get('/ruuat/etsi')"
/>
</form>
// The add and edit forms stay outside the patched fragment, or
// typing in the search box would collapse a form mid-edit.
@mainForm_(v.Main)
@sideForm_(v.Side)
@catalogList(v)
</div>
<details class="card">
@@ -559,21 +557,21 @@ templ catalogPage(v catalogView) {
}
// catalogList carries the id Datastar patches, so typing in the search box
// swaps the lists without touching the forms above them.
//
// Two levels of heading, because there are two: Pääruuat and Lisukkeet are
// the halves of the catalog, and the categories are subdivisions of the
// first. They were previously styled the same, which made a category look
// like a peer of the entire side-dish list.
// swaps the lists without reloading the forms above them.
templ catalogList(v catalogView) {
<div id="ruokalista">
<section class="section">
@sectionTitle("Pääruuat", v.Mains)
if v.Mains == 0 {
@emptyNote(v.Search)
<h3 class="sechead">Pääruuat</h3>
<p class="muted small">
if v.Search == "" {
Ei vielä pääruokia.
} else {
Ei osumia.
}
// Grouped by category, alphabetical inside. The catalog is a list
// you manage, so a predictable position beats a useful one.
</p>
}
// Grouped by category, alphabetical inside. The catalog is a list you
// manage, so a predictable position beats a useful one.
for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3>
for _, d := range g.Dishes {
@@ -589,11 +587,15 @@ templ catalogList(v catalogView) {
</div>
}
}
</section>
<section class="section">
@sectionTitle("Lisukkeet", len(v.Sides))
<h3 class="sechead">Lisukkeet</h3>
if len(v.Sides) == 0 {
@emptyNote(v.Search)
<p class="muted small">
if v.Search == "" {
Ei vielä lisukkeita.
} else {
Ei osumia.
}
</p>
}
for _, s := range v.Sides {
<div class="row">
@@ -601,27 +603,9 @@ templ catalogList(v catalogView) {
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div>
}
</section>
</div>
}
templ sectionTitle(label string, n int) {
<h2 class="sectiontitle">
{ label }
<span class="count">{ strconv.Itoa(n) }</span>
</h2>
}
templ emptyNote(search string) {
<p class="muted small">
if search == "" {
Ei vielä mitään.
} else {
Ei osumia haulle { search }.
}
</p>
}
// rowActions is a pencil and a bin, until the bin is tapped: then the row
// asks. An icon is a smaller target to hit by accident than a word, and the
// dish disappears from every picker the moment it goes.
@@ -667,18 +651,15 @@ templ iconTrash() {
</svg>
}
// Collapsed by default so the page opens on the catalog rather than on two
// screens of empty form. Forced open when editing or after a rejected
// submission, since the form is then the thing that needs attention.
templ mainForm_(f mainForm) {
<details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }>
<summary>
<section class="card" id="paaruoka">
<h3>
if f.ID == 0 {
Lisää pääruoka
} else {
Muokkaa pääruokaa
}
</summary>
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
@@ -712,7 +693,7 @@ templ mainForm_(f mainForm) {
if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a>
}
</details>
</section>
}
templ categoryChip(value, label string, f mainForm) {
@@ -728,14 +709,14 @@ templ categoryChip(value, label string, f mainForm) {
}
templ sideForm_(f sideForm) {
<details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }>
<summary>
<section class="card" id="lisuke">
<h3>
if f.ID == 0 {
Lisää lisuke
} else {
Muokkaa lisuketta
}
</summary>
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
@@ -752,7 +733,7 @@ templ sideForm_(f sideForm) {
if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a>
}
</details>
</section>
}
templ importForm() {