Release: one log page, grouped dishes, live search #1

Merged
Kessinen merged 10 commits from dev into main 2026-09-05 19:28:54 +00:00
8 changed files with 226 additions and 134 deletions
Showing only changes of commit 4ce189d1e4 - Show all commits
+1 -1
View File
@@ -321,7 +321,7 @@ build and no asset bundler.
lines of `database/sql`. There are no down-migrations: restoring the
database file is the rollback for a single-household app.
- **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
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
+19 -8
View File
@@ -17,18 +17,29 @@ weeks of real history to weight against.
Working:
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized
by how often they are eaten. Edit or delete the day's entry.
- **Historia** — every day back to the first entry, with unlogged days shown
as explicit gaps.
- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle
by paste or file upload. Deletes are soft, so old log entries keep showing
the dish they used.
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered
and sized by how often they are eaten, so the likely answer is the biggest
target. The history sits on the same page underneath: every day back to the
first entry, unlogged days shown as explicit gaps, and every row a link that
loads that day into the logger above it.
- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
by paste or file upload. Grouped by category and alphabetical inside, since
this is a list you manage rather than one you pick from. Deletes are soft,
so old log entries keep showing the dish they used.
- **Light / dark**, remembered per device, dark by default. The button shows
the theme that is on — moon while dark, sun while light — not the one a
click would bring.
Still to build:
- Live search as you type, and paging for the history and catalog lists once
years of entries make them long. Both via Datastar.
- Category icons instead of plain colour dots — colour and shape together, so
a red blob and a yellow blob are told apart by more than hue.
- Edit and delete as icons in the catalog rows, and a confirmation step before
a delete actually happens.
- A background for the header. Something subtle; the palette gets overhauled
later.
- Stage 2: the seven-meal suggester, which starts once there is history to
weight against.
@@ -99,7 +110,7 @@ make up/down/logs compose
## 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.
```json
+37 -16
View File
@@ -17,8 +17,10 @@ import (
// kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20
// historyDays is how far back the Historia list walks. Long enough to see a
// couple of months, short enough to stay one scroll.
// historyDays is how far back the history under the logger walks.
//
// 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
type app struct {
@@ -45,7 +47,9 @@ func (a *app) date(r *http.Request) time.Time {
return today(a.loc)
}
// 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 {
Date time.Time
Today time.Time
@@ -53,9 +57,12 @@ type logView struct {
Entry *Entry // what is already logged for Date, if anything
Chosen *Dish // dish picked, so the sides step is showing
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
New mainForm // inline "add the dish you were looking for"
History []HistoryRow
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
@@ -89,10 +96,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 {
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
// turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
@@ -103,6 +118,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))
}
@@ -154,11 +173,16 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
Search: form.Name,
Checked: map[int64]bool{},
New: form,
ShowBoard: true,
}
var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
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))
}
@@ -214,14 +238,6 @@ func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Ti
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
// comes back filled in rather than blank.
type mainForm struct {
@@ -239,11 +255,12 @@ type sideForm struct {
}
type catalogView struct {
Mains []Dish
Groups []DishGroup
Sides []Side
Main mainForm
Side sideForm
Report *ImportReport
Mains int // count, for the header
}
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
@@ -283,10 +300,14 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
v.Main.Categories = map[string]bool{}
}
var err error
if v.Mains, err = listDishes(a.db, ""); err != nil {
mains, err := listDishes(a.db, "")
if err != nil {
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 {
log.Printf("list sides: %v", err)
}
+5 -6
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 /lisaa", a.quickAdd)
mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /historia", a.history)
mux.HandleFunc("GET /ruoat", a.catalog)
mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
root := http.NewServeMux()
+10
View File
@@ -307,6 +307,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-transform: uppercase;
color: var(--muted);
}
/* History sits under the logger on the same page, so whole rows are links. */
.history { margin-top: 8px; }
.entry, .gapline {
display: flex;
gap: 12px;
@@ -314,6 +316,14 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
min-height: var(--tap);
padding: 14px 0;
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); }
.entry time, .gapline time {
+48
View File
@@ -3,6 +3,7 @@ package main
import (
"database/sql"
"errors"
"slices"
"strings"
"time"
)
@@ -341,6 +342,53 @@ func sideByID(db *sql.DB, id int64) (*Side, error) {
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.
type HistoryRow struct {
Date time.Time
+71 -68
View File
@@ -109,9 +109,6 @@ templ brandbar() {
</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
// 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.
@@ -145,8 +142,7 @@ templ iconMoon() {
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)
@tab("/historia", "Historia", current)
@tab("/ruoat", "Ruoat", current)
@tab("/ruuat", "Ruuat", current)
</nav>
}
@@ -170,15 +166,53 @@ templ logPage(v logView) {
switch {
case v.Chosen != nil:
@sidesStep(v)
case v.Entry != nil:
@loggedCard(v)
default:
case v.ShowBoard:
@board(v)
default:
@loggedCard(v)
}
@historyList(v)
</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">
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
{ 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) {
<div class="dayseg">
@dayButton("Tänään", v.Today, v.Date, v.Today)
@@ -205,9 +239,13 @@ templ board(v logView) {
}
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
</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">
for _, d := range v.Dishes {
for _, d := range g.Dishes {
@dishPill(d, v)
}
</div>
@@ -225,7 +263,7 @@ templ quickAddCard(v logView) {
if v.Search == "" {
<h3>Lisää ensimmäinen ruoka</h3>
<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>
} else {
<h3>Ei osumia. Lisätäänkö?</h3>
@@ -323,7 +361,7 @@ templ loggedCard(v logView) {
<div class="pair">
<a
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>
<form method="post" action="/poista">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
@@ -334,50 +372,13 @@ templ loggedCard(v logView) {
}
// ---------------------------------------------------------------- Historia
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
// ---------------------------------------------------------------- Ruuat
templ catalogPage(v catalogView) {
@page("Ruoat — Foodster", "/ruoat") {
@page("Ruuat — Foodster", "/ruuat") {
<header class="appbar">
<h2>Ruoat</h2>
<h2>Ruuat</h2>
<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>
</header>
<main class="pad">
@@ -385,24 +386,26 @@ templ catalogPage(v catalogView) {
@importReport(v.Report)
}
@mainForm_(v.Main)
<h3 class="sechead">Pääruoat</h3>
if len(v.Mains) == 0 {
if v.Mains == 0 {
<h3 class="sechead">Pääruuat</h3>
<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">
<i class={ "dot", "d-" + d.CategoryKey() }></i>
<div class="rowtext">
<div class="nm">{ d.Name }</div>
<div class="sd">
{ categoryLabels(d) }
if !d.HasSides {
· ei lisukkeita
<div class="sd">Ei lisukkeita</div>
}
</div>
@rowActions("/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
}
}
@sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3>
@@ -412,7 +415,7 @@ templ catalogPage(v catalogView) {
for _, s := range v.Sides {
<div class="row">
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
@rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
@rowActions("/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div>
}
<details class="card">
@@ -426,7 +429,7 @@ templ catalogPage(v catalogView) {
templ rowActions(editURL string, id int64, kind string) {
<div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
<form method="post" action="/ruoat/poista">
<form method="post" action="/ruuat/poista">
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del" aria-label="Poista">Poista</button>
@@ -446,7 +449,7 @@ templ mainForm_(f mainForm) {
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/paaruoka">
<form method="post" action="/ruuat/paaruoka">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
@@ -474,7 +477,7 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a>
<a class="ghost" href="/ruuat">Peruuta</a>
}
</section>
}
@@ -503,7 +506,7 @@ templ sideForm_(f sideForm) {
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/lisuke">
<form method="post" action="/ruuat/lisuke">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
@@ -514,7 +517,7 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a>
<a class="ghost" href="/ruuat">Peruuta</a>
}
</section>
}
@@ -525,7 +528,7 @@ templ importForm() {
<p class="muted small">
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
</p>
<form method="post" action="/ruoat/tuonti" enctype="multipart/form-data">
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
<label class="field">
<span>JSON</span>
<textarea
+18 -18
View File
@@ -75,29 +75,29 @@ check "manifest has the right content type" \
"application/manifest+json"
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 "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
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"
check "pasted JSON imports" \
"$(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" \
"$(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" \
"$(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" \
"$(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 --------------------
@@ -122,8 +122,8 @@ check "saving redirects back to the day" \
check "the saved day shows what was eaten" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
check "history lists the entry" \
"$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu"
check "history is on the same page as the logger" \
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
check "deleting redirects back" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
@@ -157,37 +157,37 @@ check "the quick-added dish is on the board" \
check "adding a main redirects" \
"$(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 "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."
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."
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."
check "adding a side redirects" \
"$(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" \
"$(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)
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" \
"$(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"
if [ "$fail" -ne 0 ]; then
echo "smoke: FAILED"