6 Commits
Author SHA1 Message Date
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
11 changed files with 724 additions and 192 deletions
+8
View File
@@ -81,6 +81,14 @@ fix: ## Format Go and templ sources, tidy go.mod
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } @test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_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); \ @day=$$(date +%Y%m%d); \
tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \ tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \
echo "==> $$tag"; \ echo "==> $$tag"; \
+5 -2
View File
@@ -321,7 +321,7 @@ build and no asset bundler.
lines of `database/sql`. There are no down-migrations: restoring the lines of `database/sql`. There are no down-migrations: restoring the
database file is the rollback for a single-household app. database file is the rollback for a single-household app.
- **Bundle import**: the §7.3 mass import is a live feature of the running - **Bundle import**: the §7.3 mass import is a live feature of the running
app, on the Ruoat tab — paste JSON or upload a file, get a per-row report app, on the Ruuat tab — paste JSON or upload a file, get a per-row report
back. A plain multipart form rather than a Datastar round trip, since the back. A plain multipart form rather than a Datastar round trip, since the
response is a whole-page report and a form needs no client code. Uploads response is a whole-page report and a form needs no client code. Uploads
are capped at 1 MiB. The same importer is also reachable as are capped at 1 MiB. The same importer is also reachable as
@@ -360,7 +360,10 @@ on the server and run with Docker Compose.
- **Branches**: `main` carries released versions only, so its history is the - **Branches**: `main` carries released versions only, so its history is the
deployment history and every release tag points into it. Development happens deployment history and every release tag points into it. Development happens
on `dev` and merges into `main` when a release is cut. 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.
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that - **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, 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 creates the new tag, and bakes the version into the binary through
+38 -12
View File
@@ -17,18 +17,29 @@ weeks of real history to weight against.
Working: Working:
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized - **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered
by how often they are eaten. Edit or delete the day's entry. and sized by how often they are eaten, so the likely answer is the biggest
- **Historia** — every day back to the first entry, with unlogged days shown target. The history sits on the same page underneath: every day back to the
as explicit gaps. first entry, unlogged days shown as explicit gaps, and every row a link that
- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle loads that day into the logger above it.
by paste or file upload. Deletes are soft, so old log entries keep showing - **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
the dish they used. 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.
- **Light / dark**, remembered per device, dark by default. The button shows - **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 the theme that is on — moon while dark, sun while light — not the one a
click would bring. click would bring.
Still to build: 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 - Stage 2: the seven-meal suggester, which starts once there is history to
weight against. weight against.
@@ -50,16 +61,31 @@ One static Go binary. No Node.js, no bundler, no separate database server.
## Branches ## Branches
`main` holds released versions only. Every release tag points at a commit on `main` holds released versions only. Every release tag points at a commit on
`main`, so its history is the deployment history. `main`, so its history is the deployment history. **Nothing is committed to
`main` directly** — it moves only by fast-forwarding `dev` into it.
All development happens on `dev`. Merge into `main` when cutting a release, All development happens on `dev`. `main` is protected on the remote: it takes
then build and push the image from there. no direct pushes, so a release arrives through a pull request.
```sh ```sh
git switch dev # where the work happens git switch dev # where the work happens
git switch main && git merge dev && make release # ... commits ...
make check # lint, unit tests, smoke
git push origin dev
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
``` ```
`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 ## Quick start
```sh ```sh
@@ -84,7 +110,7 @@ make up/down/logs compose
## Importing dishes ## Importing dishes
The **Ruoat** tab takes a bundle of mains and sides: paste the JSON or upload The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload
a file, and the app reports row by row what it did. a file, and the app reports row by row what it did.
```json ```json
+64 -17
View File
@@ -17,8 +17,10 @@ import (
// kilobytes; a megabyte is already absurd generosity. // kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20 const maxUpload = 1 << 20
// historyDays is how far back the Historia list walks. Long enough to see a // historyDays is how far back the history under the logger walks.
// couple of months, short enough to stay one scroll. //
// ponytail: a fixed window. After a year of daily entries this list is the
// thing that needs paging; load more on scroll when it actually hurts.
const historyDays = 60 const historyDays = 60
type app struct { type app struct {
@@ -36,16 +38,27 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
// date reads the ?pvm= parameter, falling back to today. An unparseable value // date reads the ?pvm= parameter, falling back to today. An unparseable value
// is treated as today rather than an error: a mangled URL should not be a // is treated as today rather than an error: a mangled URL should not be a
// dead end. // dead end.
//
// A future date is clamped to today. This 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.
// Every read and write goes through here, so the clamp covers them all.
func (a *app) date(r *http.Request) time.Time { func (a *app) date(r *http.Request) time.Time {
now := today(a.loc)
if raw := r.FormValue("pvm"); raw != "" { if raw := r.FormValue("pvm"); raw != "" {
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil { if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
if d.After(now) {
return now
}
return d return d
} }
} }
return today(a.loc) return now
} }
// logView is everything the Kirjaa screen needs. // logView is everything the log screen needs. Logging and history are one
// page: every history row was already a link back into the logger, and the
// day switcher made them two views of the same thing.
type logView struct { type logView struct {
Date time.Time Date time.Time
Today time.Time Today time.Time
@@ -53,9 +66,16 @@ type logView struct {
Entry *Entry // what is already logged for Date, if anything Entry *Entry // what is already logged for Date, if anything
Chosen *Dish // dish picked, so the sides step is showing Chosen *Dish // dish picked, so the sides step is showing
Checked map[int64]bool // sides ticked in that step Checked map[int64]bool // sides ticked in that step
Dishes []Dish ShowBoard bool
Dishes []Dish // flat, only to know whether anything matched
Groups []DishGroup // what the board actually renders
Sides []Side Sides []Side
New mainForm // inline "add the dish you were looking for" New mainForm // inline "add the dish you were looking for"
History []HistoryRow
// Deleting a logged meal drops the row outright, unlike a dish which is
// only soft-deleted, so it asks first.
Confirming bool
} }
func (a *app) index(w http.ResponseWriter, r *http.Request) { func (a *app) index(w http.ResponseWriter, r *http.Request) {
@@ -67,6 +87,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
Checked: map[int64]bool{}, Checked: map[int64]bool{},
} }
v.Confirming = r.URL.Query().Get("poista") != ""
entry, err := entryFor(a.db, date) entry, err := entryFor(a.db, date)
if err != nil { if err != nil {
log.Printf("entry for %s: %v", date.Format(dateLayout), err) log.Printf("entry for %s: %v", date.Format(dateLayout), err)
@@ -89,10 +111,18 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
} }
} }
if v.Chosen == nil && v.Entry == nil { // The board shows when there is nothing logged yet, or when the entry is
// being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here,
// so swapping the dish and picking one for the first time are one path.
changing := r.URL.Query().Get("muuta") != "" || v.Search != ""
if v.Chosen == nil && (v.Entry == nil || changing) {
v.ShowBoard = true
if v.Dishes, err = listDishes(a.db, v.Search); err != nil { if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err) log.Printf("list dishes: %v", err)
} }
// listDishes already orders by frequency then name, so grouping keeps
// the favourites at the top of each category.
v.Groups = groupDishes(v.Dishes)
// Seed the inline add form with whatever was searched for, so a miss // Seed the inline add form with whatever was searched for, so a miss
// turns straight into "add it" without retyping. // turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
@@ -103,6 +133,10 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
} }
} }
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
log.Printf("history: %v", err)
}
render(w, r, logPage(v)) render(w, r, logPage(v))
} }
@@ -154,11 +188,16 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
Search: form.Name, Search: form.Name,
Checked: map[int64]bool{}, Checked: map[int64]bool{},
New: form, New: form,
ShowBoard: true,
} }
var err error var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil { if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err) log.Printf("list dishes: %v", err)
} }
v.Groups = groupDishes(v.Dishes)
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
log.Printf("history: %v", err)
}
render(w, r, logPage(v)) render(w, r, logPage(v))
} }
@@ -214,14 +253,6 @@ func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Ti
http.Redirect(w, r, target, http.StatusSeeOther) http.Redirect(w, r, target, http.StatusSeeOther)
} }
func (a *app) history(w http.ResponseWriter, r *http.Request) {
rows, err := history(a.db, a.loc, historyDays)
if err != nil {
log.Printf("history: %v", err)
}
render(w, r, historyPage(rows))
}
// mainForm and sideForm carry what the user typed, so a rejected submission // mainForm and sideForm carry what the user typed, so a rejected submission
// comes back filled in rather than blank. // comes back filled in rather than blank.
type mainForm struct { type mainForm struct {
@@ -239,11 +270,17 @@ type sideForm struct {
} }
type catalogView struct { type catalogView struct {
Mains []Dish Groups []DishGroup
Sides []Side Sides []Side
Main mainForm Main mainForm
Side sideForm Side sideForm
Report *ImportReport Report *ImportReport
Mains int // count, for the header
// The row awaiting a delete confirmation, if any. A trash icon is easy to
// hit by accident, so the row asks before anything happens.
DeleteID int64
DeleteKind string
} }
func (a *app) catalog(w http.ResponseWriter, r *http.Request) { func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
@@ -274,6 +311,12 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
if raw := r.URL.Query().Get("poista"); raw != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
v.DeleteID = id
v.DeleteKind = r.URL.Query().Get("tyyppi")
}
}
a.renderCatalog(w, r, v) a.renderCatalog(w, r, v)
} }
@@ -283,10 +326,14 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
v.Main.Categories = map[string]bool{} v.Main.Categories = map[string]bool{}
} }
var err error mains, err := listDishes(a.db, "")
if v.Mains, err = listDishes(a.db, ""); err != nil { if err != nil {
log.Printf("list mains: %v", err) log.Printf("list mains: %v", err)
} }
v.Mains = len(mains)
sortByName(mains) // the catalog is managed, so position should be predictable
v.Groups = groupDishes(mains)
if v.Sides, err = listSides(a.db); err != nil { if v.Sides, err = listSides(a.db); err != nil {
log.Printf("list sides: %v", err) log.Printf("list sides: %v", err)
} }
+15 -10
View File
@@ -159,12 +159,11 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("POST /kirjaa", a.save) mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd) mux.HandleFunc("POST /lisaa", a.quickAdd)
mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /historia", a.history) mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("GET /ruoat", a.catalog) mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain) mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruoat/lisuke", a.saveSide) mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
mux.HandleFunc("POST /ruoat/poista", a.deleteDish) mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it. // /healthz stays outside auth so a monitor or reverse proxy can reach it.
root := http.NewServeMux() root := http.NewServeMux()
@@ -213,11 +212,17 @@ func challenge(w http.ResponseWriter) {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
} }
// today is the current calendar day in the configured location. Every date in // today is the current calendar day in the configured location, truncated to
// this app goes through here rather than time.Local, which would be UTC // midnight. Every date in this app goes through here rather than time.Local,
// whenever TZ is unset and quietly shift evening entries to the day before. // which would be UTC whenever TZ is unset and quietly shift evening entries to
// the day before.
//
// The truncation matters: dates parsed from ?pvm= are midnight, so a today
// carrying a time of day would never compare equal to one of them, and the UI
// would stop recognising today as today the moment the date was explicit.
func today(loc *time.Location) time.Time { func today(loc *time.Location) time.Time {
return time.Now().In(loc) now := time.Now().In(loc)
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
} }
// normalizeName collapses whitespace and capitalises the first letter for // normalizeName collapses whitespace and capitalises the first letter for
+45
View File
@@ -30,6 +30,51 @@ func TestNormalizeName(t *testing.T) {
} }
} }
func TestTodayIsMidnight(t *testing.T) {
now := today(time.UTC)
if h, m, s := now.Clock(); h != 0 || m != 0 || s != 0 {
t.Errorf("today() = %s, want midnight", now)
}
// A date parsed from a URL must compare equal to it, or the UI stops
// recognising today as today whenever the date is spelled out.
parsed, err := time.ParseInLocation(dateLayout, now.Format(dateLayout), time.UTC)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !parsed.Equal(now) {
t.Errorf("parsed %s != today %s", parsed, now)
}
}
func TestDateRejectsTheFuture(t *testing.T) {
a := &app{loc: time.UTC}
now := today(time.UTC)
cases := []struct {
name string
pvm string
want time.Time
}{
{"no parameter", "", now},
{"today", now.Format(dateLayout), now},
{"yesterday", now.AddDate(0, 0, -1).Format(dateLayout), now.AddDate(0, 0, -1)},
// Nothing was eaten tomorrow, and a stray entry dated next year would
// sit at the top of the history forever.
{"tomorrow", now.AddDate(0, 0, 1).Format(dateLayout), now},
{"next year", now.AddDate(1, 0, 0).Format(dateLayout), now},
{"nonsense", "eilen", now},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?pvm="+c.pvm, nil)
if got := a.date(r); !got.Equal(c.want) {
t.Errorf("date = %s, want %s", got.Format(dateLayout), c.want.Format(dateLayout))
}
})
}
}
func TestMigrateCreatesSchema(t *testing.T) { func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db") db, err := openDB(t.TempDir() + "/test.db")
if err != nil { if err != nil {
+45 -20
View File
@@ -20,6 +20,11 @@
--kala: light-dark(#25688F, #63AAD8); --kala: light-dark(#25688F, #63AAD8);
--kasvis: light-dark(#457A3C, #82BE7A); --kasvis: light-dark(#457A3C, #82BE7A);
/* Its own name rather than reusing --card, so the header can be recoloured
later without dragging every card with it. Keep the theme-color meta tags
in views.templ in step: those need literal hex. */
--header: light-dark(#FFFFFF, #1C1E22);
--tap: 48px; /* minimum touch target */ --tap: 48px; /* minimum touch target */
} }
@@ -43,11 +48,15 @@ button, input, select { font: inherit; }
:focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; } :focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
/* The header is the brand and the theme toggle, and nothing else. The page
title below it is content, so it stays on the page background. */
.brandbar { .brandbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 16px; padding: 10px 16px;
background: var(--header);
border-bottom: 1px solid var(--line);
} }
.brand { .brand {
display: flex; display: flex;
@@ -84,9 +93,7 @@ html[data-theme="dark"] .themetoggle .i-sun { display: none; }
html[data-theme="light"] .themetoggle .i-moon { display: none; } html[data-theme="light"] .themetoggle .i-moon { display: none; }
.appbar { .appbar {
background: var(--paper); padding: 16px 16px 4px;
border-bottom: 1px solid var(--line);
padding: 2px 16px 12px;
} }
.appbar h2 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; } .appbar h2 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
.appbar .meta { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); } .appbar .meta { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
@@ -116,21 +123,17 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
} }
.tabbar a[aria-current] { color: var(--accent); } .tabbar a[aria-current] { color: var(--accent); }
.dot { /* Category marks carry colour and shape together, so they are readable
width: 9px; without having learned which hue means what. */
height: 9px; .cat { flex: none; display: block; }
border-radius: 2px; .cat svg { display: block; width: 16px; height: 16px; }
flex: none; .pill.xl .cat svg { width: 19px; height: 19px; }
display: inline-block; .logged .cat svg { width: 22px; height: 22px; }
}
.d-liha { background: var(--liha); } .c-liha { color: var(--liha); }
.d-kana { background: var(--kana); } .c-kana { color: var(--kana); }
.d-kala { background: var(--kala); } .c-kala { color: var(--kala); }
.d-kasvis { background: var(--kasvis); } .c-kasvis { color: var(--kasvis); }
.d-sek {
background: conic-gradient(var(--liha) 0 25%, var(--kana) 25% 50%,
var(--kala) 50% 75%, var(--kasvis) 75% 100%);
}
/* Day switcher */ /* Day switcher */
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; } .dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
@@ -307,6 +310,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
/* History sits under the logger on the same page, so whole rows are links. */
.history { margin-top: 8px; }
.entry, .gapline { .entry, .gapline {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -314,6 +319,14 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
min-height: var(--tap); min-height: var(--tap);
padding: 14px 0; padding: 14px 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.gapline .act {
margin-left: auto;
padding: 0 4px;
color: var(--accent);
font-weight: 600;
} }
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); } .gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
.entry time, .gapline time { .entry time, .gapline time {
@@ -417,12 +430,14 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
.rowtext { flex: 1; min-width: 0; } .rowtext { flex: 1; min-width: 0; }
.rowtext .nm { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; } .rowtext .nm { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; }
.rowtext .sd { font-size: 12.5px; color: var(--muted); } .rowtext .sd { font-size: 12.5px; color: var(--muted); }
.rowactions { display: flex; align-items: center; gap: 4px; flex: none; } .rowactions { display: flex; align-items: center; gap: 2px; flex: none; }
.rowactions a, .rowactions button { .rowactions a, .rowactions button {
min-width: 40px;
min-height: 40px; min-height: 40px;
padding: 0 10px; padding: 0 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
background: none; background: none;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
@@ -432,9 +447,19 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
.rowactions svg { display: block; }
.rowactions a:hover { color: var(--accent); } .rowactions a:hover { color: var(--accent); }
.rowactions .del { color: var(--liha); } .rowactions .del { color: var(--liha); }
/* The row asks before a delete happens; an icon is easy to hit by accident. */
.rowactions.confirming {
gap: 4px;
font-size: 13px;
color: var(--muted);
}
.rowactions.confirming > span { padding-right: 2px; }
.rowactions.confirming .del { color: var(--liha); font-weight: 700; }
.field input[type="text"] { .field input[type="text"] {
width: 100%; width: 100%;
min-height: var(--tap); min-height: var(--tap);
+48
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"slices"
"strings" "strings"
"time" "time"
) )
@@ -341,6 +342,53 @@ func sideByID(db *sql.DB, id int64) (*Side, error) {
return &s, nil return &s, nil
} }
// DishGroup is one category's worth of dishes for the catalog listing.
type DishGroup struct {
Key string
Label string
Dishes []Dish
}
// groupOrder fixes the order the catalog lists categories in. Sekalaiset is a
// display grouping for dishes covering more than one category, not a fifth
// category: the stored set is what PRD §8.1 counts for coverage, and one
// Tortillat still satisfies meat, chicken, fish and vegetarian at once.
var groupOrder = []DishGroup{
{Key: "liha", Label: "Liha"},
{Key: "kana", Label: "Kana"},
{Key: "kala", Label: "Kala"},
{Key: "kasvis", Label: "Kasvis"},
{Key: "sek", Label: "Sekalaiset"},
}
// groupDishes buckets dishes by category, keeping whatever order they arrived
// in. The caller decides that order: the log board hands over listDishes'
// frequency-then-name ordering, the catalog sorts by name first.
func groupDishes(dishes []Dish) []DishGroup {
byKey := make(map[string][]Dish, len(groupOrder))
for _, d := range dishes {
key := d.CategoryKey()
byKey[key] = append(byKey[key], d)
}
var groups []DishGroup
for _, g := range groupOrder {
in := byKey[g.Key]
if len(in) == 0 {
continue
}
groups = append(groups, DishGroup{Key: g.Key, Label: g.Label, Dishes: in})
}
return groups
}
// sortByName orders dishes alphabetically, case-insensitively.
func sortByName(dishes []Dish) {
slices.SortFunc(dishes, func(a, b Dish) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
}
// HistoryRow is one calendar day: either what was eaten or an unfilled gap. // HistoryRow is one calendar day: either what was eaten or an unfilled gap.
type HistoryRow struct { type HistoryRow struct {
Date time.Time Date time.Time
+218 -85
View File
@@ -78,8 +78,10 @@ templ page(title, current string) {
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="color-scheme" content="light dark"/> <meta name="color-scheme" content="light dark"/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ECEDE8"/> // Matches --header in app.css so the browser chrome continues the
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121316"/> // header rather than butting against it.
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ title }</title> <title>{ title }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
@@ -109,9 +111,6 @@ templ brandbar() {
</header> </header>
} }
// themeSwitch marks the active theme rather than labelling itself with the one
// a click would produce. aria-pressed is set by theme.js on load, because only
// the device knows what was chosen.
// themeSwitch shows the theme that is on right now — moon while dark, sun // themeSwitch shows the theme that is on right now — moon while dark, sun
// while light — and clicking swaps it. Both icons are in the markup and CSS // while light — and clicking swaps it. Both icons are in the markup and CSS
// picks one, so the server never has to know the device's choice. // picks one, so the server never has to know the device's choice.
@@ -142,11 +141,92 @@ templ iconMoon() {
</svg> </svg>
} }
// categoryIcon draws a dish's category as colour *and* shape. Colour on its
// own was not telling: a red blob and a yellow blob only differ once you have
// learned the legend.
templ categoryIcon(key string) {
switch key {
case "liha":
<span class="cat c-liha">
@glyphLiha()
</span>
case "kana":
<span class="cat c-kana">
@glyphKana()
</span>
case "kala":
<span class="cat c-kala">
@glyphKala()
</span>
case "kasvis":
<span class="cat c-kasvis">
@glyphKasvis()
</span>
default:
<span class="cat">
@glyphSekalaiset()
</span>
}
}
// A steak, its bone knocked out with fill-rule so the hole is transparent on
// whatever background the icon lands on.
templ glyphLiha() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fill-rule="evenodd"
d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"
></path>
</svg>
}
// A drumstick. The bone is what keeps it apart from the steak at small sizes,
// where both are otherwise warm blobs.
templ glyphKana() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" d="M7.8 8.2l3.1-3.1"></path>
<circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"></circle>
<circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"></circle>
<circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"></circle>
</svg>
}
templ glyphKala() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fill-rule="evenodd"
d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"
></path>
</svg>
}
// No midrib: stroking one in the card colour only works on a card, and these
// also sit on the page background in the history list.
templ glyphKasvis() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"
></path>
</svg>
}
// Quartered, one wedge per category: the mark already means "all of them".
templ glyphSekalaiset() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"></path>
<path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"></path>
<path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"></path>
<path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"></path>
</svg>
}
templ tabbar(current string) { templ tabbar(current string) {
<nav class="tabbar"> <nav class="tabbar">
@tab("/", "Kirjaa", current) @tab("/", "Kirjaa", current)
@tab("/historia", "Historia", current) @tab("/ruuat", "Ruuat", current)
@tab("/ruoat", "Ruoat", current)
</nav> </nav>
} }
@@ -170,21 +250,61 @@ templ logPage(v logView) {
switch { switch {
case v.Chosen != nil: case v.Chosen != nil:
@sidesStep(v) @sidesStep(v)
case v.Entry != nil: case v.ShowBoard:
@loggedCard(v)
default:
@board(v) @board(v)
default:
@loggedCard(v)
} }
@historyList(v)
</main> </main>
} }
} }
// historyList sits under the day being logged: the two were always one thing,
// since every row here is a link back into the logger above it.
templ historyList(v logView) {
<section class="history">
<h3 class="sechead">Aiemmin</h3>
if len(v.History) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p>
}
for i, row := range v.History {
if !row.Date.Equal(v.Date) {
if i == 0 || v.History[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<a class="entry" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
@categoryIcon(row.Entry.Main.CategoryKey())
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</div>
<span class="chev"></span>
</a>
} else {
<a class="gapline" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<span class="act">Merkitse</span>
</a>
}
}
}
</section>
}
templ daySwitch(v logView) { templ daySwitch(v logView) {
<div class="dayseg"> <div class="dayseg">
@dayButton("Tänään", v.Today, v.Date, v.Today) @dayButton("Tänään", v.Today, v.Date, v.Today)
@dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today) @dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
<form method="get" action="/" class="daypick"> <form method="get" action="/" class="daypick">
<input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/> // max stops the picker offering days that have not happened yet;
// the server clamps anyway, this just avoids the dead end.
<input type="date" name="pvm" value={ isoDate(v.Date) } max={ isoDate(v.Today) } aria-label="Muu päivä"/>
<button type="submit">Näytä</button> <button type="submit">Näytä</button>
</form> </form>
</div> </div>
@@ -205,9 +325,13 @@ templ board(v logView) {
} }
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/> <input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
</form> </form>
if len(v.Dishes) > 0 { // Grouped by category, and inside each group the most-eaten first — so a
// dish keeps a predictable neighbourhood while favourites still surface
// at the top of it.
for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3>
<div class="board"> <div class="board">
for _, d := range v.Dishes { for _, d := range g.Dishes {
@dishPill(d, v) @dishPill(d, v)
} }
</div> </div>
@@ -225,7 +349,7 @@ templ quickAddCard(v logView) {
if v.Search == "" { if v.Search == "" {
<h3>Lisää ensimmäinen ruoka</h3> <h3>Lisää ensimmäinen ruoka</h3>
<p class="muted small"> <p class="muted small">
Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruoat-välilehdeltä. Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä.
</p> </p>
} else { } else {
<h3>Ei osumia. Lisätäänkö?</h3> <h3>Ei osumia. Lisätäänkö?</h3>
@@ -266,7 +390,7 @@ templ dishPill(d Dish, v logView) {
class={ "pill", d.Size() } class={ "pill", d.Size() }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) } href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
> >
<i class={ "dot", "d-" + d.CategoryKey() }></i> @categoryIcon(d.CategoryKey())
{ d.Name } { d.Name }
if d.TimesEaten > 0 { if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span> <span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
@@ -277,7 +401,7 @@ templ dishPill(d Dish, v logView) {
templ sidesStep(v logView) { templ sidesStep(v logView) {
<section class="card"> <section class="card">
<h3> <h3>
<i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i> @categoryIcon(v.Chosen.CategoryKey())
{ v.Chosen.Name } { v.Chosen.Name }
</h3> </h3>
<form method="post" action="/kirjaa"> <form method="post" action="/kirjaa">
@@ -316,68 +440,41 @@ templ loggedCard(v logView) {
} }
</p> </p>
<p class="nm"> <p class="nm">
<i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i> @categoryIcon(v.Entry.Main.CategoryKey())
{ v.Entry.Main.Name } { v.Entry.Main.Name }
</p> </p>
<p class="sd">{ v.Entry.SidesLabel() }</p> <p class="sd">{ v.Entry.SidesLabel() }</p>
if v.Confirming {
<p class="q">Poistetaanko merkintä?</p>
<div class="pair">
<form method="post" action="/poista">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button>
</form>
<a class="btn" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
</div>
} else {
<div class="pair"> <div class="pair">
<a <a
class="btn" class="btn"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) } href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "muuta=1") }
>Muokkaa</a> >Muokkaa</a>
<form method="post" action="/poista"> <a
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> class="btn del"
<button class="btn del" type="submit">Poista</button> href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "poista=1") }
</form> >Poista</a>
</div> </div>
}
</section> </section>
} }
// ---------------------------------------------------------------- Historia // ---------------------------------------------------------------- Ruuat
templ historyPage(rows []HistoryRow) {
@page("Historia — Foodster", "/historia") {
<header class="appbar">
<h2>Historia</h2>
</header>
<main class="pad">
if len(rows) == 0 {
<p class="muted">Ei vielä merkintöjä.</p>
}
for i, row := range rows {
if i == 0 || rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<div class="entry">
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</div>
<a class="chev" href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) } aria-label="Muokkaa"></a>
</div>
} else {
<div class="gapline">
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<a href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) }>Merkitse</a>
</div>
}
}
</main>
}
}
// ---------------------------------------------------------------- Ruoat
templ catalogPage(v catalogView) { templ catalogPage(v catalogView) {
@page("Ruoat — Foodster", "/ruoat") { @page("Ruuat — Foodster", "/ruuat") {
<header class="appbar"> <header class="appbar">
<h2>Ruoat</h2> <h2>Ruuat</h2>
<p class="meta"> <p class="meta">
{ countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") } { countFI(v.Mains, "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
</p> </p>
</header> </header>
<main class="pad"> <main class="pad">
@@ -385,24 +482,26 @@ templ catalogPage(v catalogView) {
@importReport(v.Report) @importReport(v.Report)
} }
@mainForm_(v.Main) @mainForm_(v.Main)
<h3 class="sechead">Pääruoat</h3> if v.Mains == 0 {
if len(v.Mains) == 0 { <h3 class="sechead">Pääruuat</h3>
<p class="muted small">Ei vielä pääruokia.</p> <p class="muted small">Ei vielä pääruokia.</p>
} }
for _, d := range v.Mains { // 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 {
<div class="row"> <div class="row">
<i class={ "dot", "d-" + d.CategoryKey() }></i> @categoryIcon(d.CategoryKey())
<div class="rowtext"> <div class="rowtext">
<div class="nm">{ d.Name }</div> <div class="nm">{ d.Name }</div>
<div class="sd">
{ categoryLabels(d) }
if !d.HasSides { if !d.HasSides {
· ei lisukkeita <div class="sd">Ei lisukkeita</div>
} }
</div> </div>
@rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div> </div>
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa") }
</div>
} }
@sideForm_(v.Side) @sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3> <h3 class="sechead">Lisukkeet</h3>
@@ -412,7 +511,7 @@ templ catalogPage(v catalogView) {
for _, s := range v.Sides { for _, s := range v.Sides {
<div class="row"> <div class="row">
<div class="rowtext"><div class="nm">{ s.Name }</div></div> <div class="rowtext"><div class="nm">{ s.Name }</div></div>
@rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") @rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div> </div>
} }
<details class="card"> <details class="card">
@@ -423,15 +522,49 @@ templ catalogPage(v catalogView) {
} }
} }
templ rowActions(editURL string, id int64, kind string) { // rowActions is a pencil and a bin, until the bin is tapped: then the row
<div class="rowactions"> // asks. An icon is a smaller target to hit by accident than a word, and the
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a> // dish disappears from every picker the moment it goes.
<form method="post" action="/ruoat/poista"> templ rowActions(v catalogView, editURL string, id int64, kind string) {
if v.DeleteID == id && v.DeleteKind == kind {
<div class="rowactions confirming">
<span>Poista?</span>
<form method="post" action="/ruuat/poista">
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/> <input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del" aria-label="Poista">Poista</button> <button type="submit" class="del">Kyllä</button>
</form> </form>
<a href="/ruuat">Peruuta</a>
</div> </div>
} else {
<div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa" title="Muokkaa">
@iconPencil()
</a>
<a
class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
aria-label="Poista"
title="Poista"
>
@iconTrash()
</a>
</div>
}
}
templ iconPencil() {
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M11.1 1.6a1.7 1.7 0 0 1 2.4 0l0.9 0.9a1.7 1.7 0 0 1 0 2.4l-0.8 0.8-3.3-3.3z"></path>
<path d="M9.4 3.3l3.3 3.3-6.6 6.6-4 0.7 0.7-4z"></path>
</svg>
}
templ iconTrash() {
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M6.2 1.3h3.6a1 1 0 0 1 1 1v0.6h3.1v1.7H2.1V2.9h3.1v-.6a1 1 0 0 1 1-1z"></path>
<path d="M3.3 6.2h9.4l-0.7 7.3a1.5 1.5 0 0 1-1.5 1.3H5.5a1.5 1.5 0 0 1-1.5-1.3z"></path>
</svg>
} }
templ mainForm_(f mainForm) { templ mainForm_(f mainForm) {
@@ -446,7 +579,7 @@ templ mainForm_(f mainForm) {
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruoat/paaruoka"> <form method="post" action="/ruuat/paaruoka">
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -474,7 +607,7 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a> <a class="ghost" href="/ruuat">Peruuta</a>
} }
</section> </section>
} }
@@ -486,7 +619,7 @@ templ categoryChip(value, label string, f mainForm) {
} else { } else {
<input type="checkbox" name="kategoria" value={ value }/> <input type="checkbox" name="kategoria" value={ value }/>
} }
<i class={ "dot", "d-" + categoryFI[value] }></i> @categoryIcon(categoryFI[value])
<span>{ label }</span> <span>{ label }</span>
</label> </label>
} }
@@ -503,7 +636,7 @@ templ sideForm_(f sideForm) {
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruoat/lisuke"> <form method="post" action="/ruuat/lisuke">
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -514,7 +647,7 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a> <a class="ghost" href="/ruuat">Peruuta</a>
} }
</section> </section>
} }
@@ -525,7 +658,7 @@ templ importForm() {
<p class="muted small"> <p class="muted small">
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan. Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
</p> </p>
<form method="post" action="/ruoat/tuonti" enctype="multipart/form-data"> <form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
<label class="field"> <label class="field">
<span>JSON</span> <span>JSON</span>
<textarea <textarea
+145
View File
@@ -0,0 +1,145 @@
<!doctype html>
<html lang="fi" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foodster — kategoriakuvakkeet</title>
<style>
:root{
color-scheme: light dark;
--paper: light-dark(#ECEDE8, #121316);
--card: light-dark(#FFFFFF, #1C1E22);
--sunk: light-dark(#E3E4DE, #17181B);
--ink: light-dark(#14161A, #E9EAE5);
--muted: light-dark(#6B6F6A, #8B8F89);
--line: light-dark(#D5D6D0, #2C2F34);
--liha: light-dark(#AF4230, #DE7561);
--kana: light-dark(#B57E10, #DFA83B);
--kala: light-dark(#25688F, #63AAD8);
--kasvis:light-dark(#457A3C, #82BE7A);
}
html[data-theme="light"]{color-scheme:only light;}
html[data-theme="dark"] {color-scheme:only dark;}
*{box-sizing:border-box;}
body{margin:0;background:var(--paper);color:var(--ink);
font-family:system-ui,sans-serif;font-size:16px;line-height:1.45;}
.wrap{max-width:900px;margin:0 auto;padding:22px 20px 60px;}
h1{font-size:19px;letter-spacing:-.02em;margin:0 0 4px;}
.lede{margin:0 0 18px;color:var(--muted);font-size:14px;}
button.t{font:inherit;font-size:13px;background:var(--card);border:1px solid var(--line);
color:var(--muted);padding:7px 12px;border-radius:7px;cursor:pointer;margin-bottom:20px;}
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:16px 18px;margin-bottom:14px;}
.panel h2{font-size:14px;margin:0 0 12px;letter-spacing:-.01em;}
table{border-collapse:collapse;width:100%;}
th{font-size:10px;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);
text-align:left;font-weight:600;padding:0 10px 8px 0;}
td{padding:7px 10px 7px 0;vertical-align:middle;}
td.name{font-size:13px;color:var(--muted);}
.i-liha { color: var(--liha); }
.i-kana { color: var(--kana); }
.i-kala { color: var(--kala); }
.i-kasvis{ color: var(--kasvis); }
svg{display:block;}
/* in context */
.board{display:flex;flex-wrap:wrap;gap:8px;}
.pill{display:flex;align-items:center;gap:9px;background:var(--card);
border:1px solid var(--line);border-radius:11px;color:var(--ink);
font-weight:600;letter-spacing:-.022em;padding:11px 15px;font-size:16px;}
.pill.big{font-size:21px;padding:14px 18px;}
.n{font-family:ui-monospace,monospace;font-weight:400;font-size:10px;color:var(--muted);}
.row{display:flex;align-items:center;gap:11px;padding:11px 0;
border-bottom:1px solid var(--line);}
.row .nm{font-size:16px;font-weight:600;}
.dotrow{display:flex;align-items:center;gap:11px;padding:11px 0;
border-bottom:1px solid var(--line);}
.dot{width:9px;height:9px;border-radius:2px;flex:none;}
.d-liha{background:var(--liha);}.d-kana{background:var(--kana);}
.d-kala{background:var(--kala);}.d-kasvis{background:var(--kasvis);}
.d-sek{background:conic-gradient(var(--liha) 0 25%,var(--kana) 25% 50%,
var(--kala) 50% 75%,var(--kasvis) 75% 100%);}
</style>
</head>
<body>
<div class="wrap">
<h1>Kategoriakuvakkeet</h1>
<p class="lede">Colour and shape together. The 14&nbsp;px column and the pill row are the sizes that actually ship.</p>
<button class="t" id="t">Teema: tumma</button>
<div class="panel">
<h2>Every size</h2>
<table>
<tr><th>Kategoria</th><th>12</th><th>14</th><th>18</th><th>26</th></tr>
<tr>
<td class="name">liha</td>
<td class="i-liha"><svg width="12" height="12" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
<td class="i-liha"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
<td class="i-liha"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
<td class="i-liha"><svg width="26" height="26" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></td>
</tr>
<tr>
<td class="name">kana</td>
<td class="i-kana"><svg width="12" height="12" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
<td class="i-kana"><svg width="14" height="14" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
<td class="i-kana"><svg width="18" height="18" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
<td class="i-kana"><svg width="26" height="26" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></td>
</tr>
<tr>
<td class="name">kala</td>
<td class="i-kala"><svg width="12" height="12" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
<td class="i-kala"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
<td class="i-kala"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
<td class="i-kala"><svg width="26" height="26" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></td>
</tr>
<tr>
<td class="name">kasvis</td>
<td class="i-kasvis"><svg width="12" height="12" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
<td class="i-kasvis"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
<td class="i-kasvis"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
<td class="i-kasvis"><svg width="26" height="26" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></td>
</tr>
<tr>
<td class="name">sekalaiset</td>
<td><svg width="12" height="12" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
<td><svg width="14" height="14" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
<td><svg width="18" height="18" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
<td><svg width="26" height="26" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg></td>
</tr>
</table>
</div>
<div class="panel">
<h2>On the board, at 16&nbsp;px</h2>
<div class="board">
<span class="pill big"><span class="i-liha"><svg width="18" height="18" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"/></svg></span>Lihapullat <span class="n">12</span></span>
<span class="pill"><span class="i-kana"><svg width="16" height="16" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M7.8 8.2l3.1-3.1"/></g><circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"/><circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"/><circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"/></svg></span>Kanacurry <span class="n">8</span></span>
<span class="pill"><span class="i-kala"><svg width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/></svg></span>Lohikeitto <span class="n">9</span></span>
<span class="pill"><span class="i-kasvis"><svg width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"/><path fill="none" stroke="var(--card)" stroke-width="1.1" stroke-linecap="round" d="M12.4 3.4L5.1 10.7"/></svg></span>Hernekeitto <span class="n">4</span></span>
<span class="pill"><svg width="16" height="16" viewBox="0 0 16 16"><path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"/><path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"/><path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"/><path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"/></svg>Tortillat <span class="n">4</span></span>
</div>
</div>
<div class="panel">
<h2>What it replaces</h2>
<div class="dotrow"><i class="dot d-liha"></i><span>Lihapullat</span></div>
<div class="dotrow"><i class="dot d-kana"></i><span>Kanacurry</span></div>
<div class="dotrow"><i class="dot d-kala"></i><span>Lohikeitto</span></div>
<div class="dotrow"><i class="dot d-kasvis"></i><span>Hernekeitto</span></div>
<div class="dotrow"><i class="dot d-sek"></i><span>Tortillat</span></div>
</div>
</div>
<script>
const h=document.documentElement,b=document.getElementById('t'),m=['dark','light'];
b.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%2];h.dataset.theme=n;b.textContent='Teema: '+(n==='dark'?'tumma':'vaalea');};
</script>
</body>
</html>
+67 -20
View File
@@ -43,6 +43,16 @@ check() {
fi fi
} }
# refute <name> <haystack> <needle>
refute() {
if printf '%s' "$2" | grep -qF -- "$3"; then
echo " FAIL $1 (should not contain: $3)"
fail=1
else
echo " ok $1"
fi
}
echo "smoke: http://$addr" echo "smoke: http://$addr"
check "unauthenticated request is refused" \ check "unauthenticated request is refused" \
@@ -75,29 +85,29 @@ check "manifest has the right content type" \
"application/manifest+json" "application/manifest+json"
check "catalog starts empty" \ check "catalog starts empty" \
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "0 pääruokaa" "$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa"
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti") out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0" check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta" check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
check "re-import refuses duplicates" \ check "re-import refuses duplicates" \
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")" \ "$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
"jo listalla" "jo listalla"
check "pasted JSON imports" \ check "pasted JSON imports" \
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \ "$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
"http://$addr/ruoat/tuonti")" "Lisätty 1" "http://$addr/ruuat/tuonti")" "Lisätty 1"
check "unknown category is reported" \ check "unknown category is reported" \
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \ "$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
"http://$addr/ruoat/tuonti")" "tuntematon kategoria" "http://$addr/ruuat/tuonti")" "tuntematon kategoria"
check "empty submit is explained" \ check "empty submit is explained" \
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruoat/tuonti")" "Ei tuotavaa" "$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
check "malformed JSON is explained" \ check "malformed JSON is explained" \
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa" "$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
# ---- the log flow, against the dishes imported above -------------------- # ---- the log flow, against the dishes imported above --------------------
@@ -122,8 +132,13 @@ check "saving redirects back to the day" \
check "the saved day shows what was eaten" \ 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=2026-09-05")" "kirjattu"
check "history lists the entry" \ check "history is on the same page as the logger" \
"$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu" "$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
# Deleting a logged meal drops the row outright, so it asks first.
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
refute "and does not delete while asking" "$saved" "Ei merkintää"
check "deleting redirects back" \ check "deleting redirects back" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
@@ -135,6 +150,21 @@ check "the day is empty again" \
check "search filters the board" \ check "search filters the board" \
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto" "$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
future=$(date -d '+30 days' +%Y-%m-%d)
check "a future date falls back to today" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
check "saving a future date is clamped too" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
-d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/"
check "tomorrow was not written to the log" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
# Clean up the entry that clamped onto today.
curl -s -o /dev/null -u ":$pass" -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista"
# ---- adding a dish without leaving Kirjaa -------------------------------- # ---- adding a dish without leaving Kirjaa --------------------------------
miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste") miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste")
@@ -157,37 +187,54 @@ check "the quick-added dish is on the board" \
check "adding a main redirects" \ check "adding a main redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruoat/paaruoka")" "303" -d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303"
catalog=$(curl -s -u ":$pass" "http://$addr/ruoat") catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
check "the new main is listed, sentence-cased" "$catalog" "Uunikala" check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
check "a duplicate name is refused" \ check "a duplicate name is refused" \
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruoat/paaruoka")" \ "$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Nimi on jo listalla." "Nimi on jo listalla."
check "a main with no category is refused" \ check "a main with no category is refused" \
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruoat/paaruoka")" \ "$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
"Valitse vähintään yksi kategoria." "Valitse vähintään yksi kategoria."
check "a nameless dish is refused" \ check "a nameless dish is refused" \
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruoat/paaruoka")" \ "$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Anna nimi." "Anna nimi."
check "adding a side redirects" \ check "adding a side redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d 'nimi=lohkoperunat' "http://$addr/ruoat/lisuke")" "303" -d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303"
check "the new side is listed" \ check "the new side is listed" \
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "Lohkoperunat" "$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
uusi=$(printf '%s' "$catalog" | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2) # The id of Uunikala specifically: the catalog is grouped and alphabetical, so
# the first id on the page belongs to some other dish entirely.
uusi=$(printf '%s' "$catalog" | grep -o 'Uunikala.*' | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$uusi" ]; then
echo " FAIL could not find Uunikala's id in the catalog"
fail=1
uusi=0
fi
check "the edit form is prefilled" \ check "the edit form is prefilled" \
"$(curl -s -u ":$pass" "http://$addr/ruoat?muokkaa=$uusi")" "Muokkaa pääruokaa" "$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
check "deleting a main redirects" \ # A bin icon is easy to hit by accident, so the row asks before anything goes.
check "the bin asks before deleting" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
check "the dish is still there while it asks" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
check "confirming the delete redirects" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruoat/poista")" "303" -d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303"
refute "the dish is gone once confirmed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
if [ "$fail" -ne 0 ]; then if [ "$fail" -ne 0 ]; then
echo "smoke: FAILED" echo "smoke: FAILED"