From d15f741ab1d92cd5c7b3235d14cc5c1638b0bddd Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 18:44:09 +0300 Subject: [PATCH] Add dish CRUD to the UI, including inline from Kirjaa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog could only be filled by importing JSON, which is a poor way to add the one dish you are about to eat. Ruoat now covers PRD §7.3 in full: add and edit mains with their categories and has_sides, add and edit sides, and delete either. Deletes are soft, so a log entry keeps resolving the dish it used and the freed name can be reused. Validation messages are Finnish and the rejected form comes back filled in rather than blank. Bulk import moves into a details element, since it is now the occasional path rather than the only one. Kirjaa gets the same ability without the detour: a search that finds nothing offers to add what was typed, and saving creates the dish and continues straight to the sides step. An empty catalog shows the same card instead of dead-ending on a link to another tab, and the search box is no longer hidden behind the empty state. The importer's own insert is gone; it and the UI both go through createMain and createSide, so duplicate detection lives in one place and reason() can match on errNameTaken instead of poking at driver strings. --- README.md | 7 +- cmd/foodster/bundle.go | 41 +----- cmd/foodster/catalog_test.go | 150 +++++++++++++++++++++ cmd/foodster/handlers.go | 255 ++++++++++++++++++++++++++++++++--- cmd/foodster/main.go | 4 + cmd/foodster/static/app.css | 71 ++++++++++ cmd/foodster/store.go | 109 +++++++++++++++ cmd/foodster/views.templ | 218 +++++++++++++++++++++++++++--- scripts/smoke.sh | 54 ++++++++ 9 files changed, 836 insertions(+), 73 deletions(-) create mode 100644 cmd/foodster/catalog_test.go diff --git a/README.md b/README.md index d8cf241..ae8354d 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,14 @@ Working: 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** — import a bundle of dishes by paste or file upload. +- **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. Still to build: -- Adding, editing and deleting dishes **in the UI** (PRD §7.3). Right now the - catalog can only be filled by importing a bundle. - Light / dark theme switch, saved per device (PRD §7.4). +- A proper app header and a `favicon.svg`. - Stage 2: the seven-meal suggester, which starts once there is history to weight against. diff --git a/cmd/foodster/bundle.go b/cmd/foodster/bundle.go index 0e9888d..dd36cd1 100644 --- a/cmd/foodster/bundle.go +++ b/cmd/foodster/bundle.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "encoding/json" + "errors" "fmt" "io" "os" @@ -102,7 +103,7 @@ func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) { hasSides = *m.HasSides } - if err := insertMain(db, name, m.Categories, hasSides); err != nil { + if _, err := createMain(db, name, m.Categories, hasSides); err != nil { report.skip("%s: %s", name, reason(err)) continue } @@ -115,7 +116,7 @@ func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) { report.skip("lisuke ilman nimeä") continue } - if _, err := db.Exec(`INSERT INTO side_dishes (name) VALUES (?)`, name); err != nil { + if err := createSide(db, name); err != nil { report.skip("%s: %s", name, reason(err)) continue } @@ -125,45 +126,15 @@ func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) { return report, nil } -// reason turns a driver error into something worth showing a person. The -// only failure a normal import hits is a name already in the catalog. -// -// ponytail: string match rather than unwrapping a driver-specific error type, -// so this keeps working if the driver is ever swapped. +// reason turns a store error into something worth showing a person. The only +// failure a normal import hits is a name already in the catalog. func reason(err error) string { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if errors.Is(err, errNameTaken) { return "jo listalla" } return err.Error() } -func insertMain(db *sql.DB, name string, categories []string, hasSides bool) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - res, err := tx.Exec( - `INSERT INTO main_dishes (name, has_sides) VALUES (?, ?)`, name, hasSides) - if err != nil { - return err - } - id, err := res.LastInsertId() - if err != nil { - return err - } - for _, c := range categories { - if _, err := tx.Exec( - `INSERT OR IGNORE INTO main_dish_categories (main_dish_id, category) VALUES (?, ?)`, - id, c, - ); err != nil { - return err - } - } - return tx.Commit() -} - // runImport loads a bundle file, prints the report, and is what `-import` // calls. Handy for reseeding a scratch database between test runs. func runImport(db *sql.DB, path string) error { diff --git a/cmd/foodster/catalog_test.go b/cmd/foodster/catalog_test.go new file mode 100644 index 0000000..4f55a36 --- /dev/null +++ b/cmd/foodster/catalog_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "errors" + "testing" +) + +func TestCreateMain(t *testing.T) { + h := seeded(t) + + id, err := createMain(h.db, "Uunikala", []string{"fish"}, true) + if err != nil { + t.Fatalf("createMain: %v", err) + } + + dish, err := dishByID(h.db, id) + if err != nil { + t.Fatalf("dishByID: %v", err) + } + if dish.Name != "Uunikala" || !dish.HasSides { + t.Errorf("got %+v", dish) + } + if len(dish.Categories) != 1 || dish.Categories[0] != "fish" { + t.Errorf("categories = %v, want [fish]", dish.Categories) + } +} + +func TestCreateMainRejectsDuplicateName(t *testing.T) { + h := seeded(t) + + // Seeded bundle already has Lohikeitto; casing must not matter. + _, err := createMain(h.db, "lohikeitto", []string{"fish"}, true) + if !errors.Is(err, errNameTaken) { + t.Errorf("err = %v, want errNameTaken", err) + } +} + +func TestUpdateMainReplacesCategories(t *testing.T) { + h := seeded(t) + id := h.mainNamed(t, "Tortillat") + + if err := updateMain(h.db, id, "Tortillat", []string{"chicken"}, false); err != nil { + t.Fatalf("updateMain: %v", err) + } + + dish, err := dishByID(h.db, id) + if err != nil { + t.Fatalf("dishByID: %v", err) + } + if len(dish.Categories) != 1 || dish.Categories[0] != "chicken" { + t.Errorf("categories = %v, want [chicken]", dish.Categories) + } + if dish.HasSides { + t.Error("has_sides should have been cleared") + } + if got := dish.CategoryKey(); got != "kana" { + t.Errorf("CategoryKey = %q, want kana", got) + } +} + +func TestUpdateMainRejectsAnotherDishesName(t *testing.T) { + h := seeded(t) + + err := updateMain(h.db, h.mainNamed(t, "Tortillat"), "Lohikeitto", []string{"fish"}, true) + if !errors.Is(err, errNameTaken) { + t.Errorf("err = %v, want errNameTaken", err) + } +} + +func TestSideCRUD(t *testing.T) { + h := seeded(t) + + if err := createSide(h.db, "Lohkoperunat"); err != nil { + t.Fatalf("createSide: %v", err) + } + if err := createSide(h.db, "lohkoperunat"); !errors.Is(err, errNameTaken) { + t.Errorf("duplicate side err = %v, want errNameTaken", err) + } + + id := h.sideNamed(t, "Lohkoperunat") + if err := updateSide(h.db, id, "Lohkoperunat uunista"); err != nil { + t.Fatalf("updateSide: %v", err) + } + side, err := sideByID(h.db, id) + if err != nil { + t.Fatalf("sideByID: %v", err) + } + if side.Name != "Lohkoperunat uunista" { + t.Errorf("name = %q", side.Name) + } +} + +func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) { + h := seeded(t) + id := h.mainNamed(t, "Lohikeitto") + date := day(t, "2026-09-05") + + if err := saveEntry(h.db, date, id, nil); err != nil { + t.Fatalf("saveEntry: %v", err) + } + if err := softDeleteMain(h.db, id); err != nil { + t.Fatalf("softDeleteMain: %v", err) + } + + // Gone from the pickers... + dishes, err := listDishes(h.db, "") + if err != nil { + t.Fatalf("listDishes: %v", err) + } + for _, d := range dishes { + if d.ID == id { + t.Fatal("soft-deleted dish still appears in the catalog") + } + } + if _, err := dishByID(h.db, id); err == nil { + t.Error("dishByID returned a soft-deleted dish") + } + + // ...but the log entry still resolves its name (PRD §6). + entry, err := entryFor(h.db, date) + if err != nil || entry == nil { + t.Fatalf("entryFor: %v, %v", entry, err) + } + if entry.Main.Name != "Lohikeitto" { + t.Errorf("historical name = %q, want Lohikeitto", entry.Main.Name) + } + + // And the freed name can be reused. + if _, err := createMain(h.db, "Lohikeitto", []string{"fish"}, true); err != nil { + t.Errorf("name still blocked after soft delete: %v", err) + } +} + +func TestSoftDeleteSideHidesItFromPickers(t *testing.T) { + h := seeded(t) + id := h.sideNamed(t, "Riisi") + + if err := softDeleteSide(h.db, id); err != nil { + t.Fatalf("softDeleteSide: %v", err) + } + sides, err := listSides(h.db) + if err != nil { + t.Fatalf("listSides: %v", err) + } + for _, s := range sides { + if s.ID == id { + t.Fatal("soft-deleted side still appears") + } + } +} diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index d9af2be..f18f6e6 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "errors" "io" "log" "net/http" @@ -54,6 +55,7 @@ type logView struct { Checked map[int64]bool // sides ticked in that step Dishes []Dish Sides []Side + New mainForm // inline "add the dish you were looking for" } func (a *app) index(w http.ResponseWriter, r *http.Request) { @@ -91,6 +93,9 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { if v.Dishes, err = listDishes(a.db, v.Search); err != nil { log.Printf("list dishes: %v", err) } + // 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} } if v.Chosen != nil && v.Chosen.HasSides { if v.Sides, err = listSides(a.db); err != nil { @@ -101,6 +106,71 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { render(w, r, logPage(v)) } +// quickAdd creates a dish from the Kirjaa screen and goes straight on to +// logging it. Hunting for something that is not in the catalog yet should not +// mean a detour through Ruoat and a lost train of thought. +func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) { + date := a.date(r) + + form := mainForm{ + Name: normalizeName(r.FormValue("nimi")), + Categories: map[string]bool{}, + HasSides: r.FormValue("lisukkeita") != "", + } + var categories []string + for _, c := range r.Form["kategoria"] { + if validCategories[c] { + categories = append(categories, c) + form.Categories[c] = true + } + } + + switch { + case form.Name == "": + form.Err = "Anna nimi." + case len(categories) == 0: + form.Err = "Valitse vähintään yksi kategoria." + } + + if form.Err == "" { + id, err := createMain(a.db, form.Name, categories, form.HasSides) + switch { + case errors.Is(err, errNameTaken): + form.Err = "Nimi on jo listalla." + case err != nil: + log.Printf("quick add: %v", err) + form.Err = "Tallennus epäonnistui." + default: + a.redirectToPick(w, r, date, id) + return + } + } + + // Rejected: back to the board with the form filled in and the search + // still narrowed, so the add card stays on screen. + v := logView{ + Date: date, + Today: today(a.loc), + Search: form.Name, + Checked: map[int64]bool{}, + New: form, + } + var err error + if v.Dishes, err = listDishes(a.db, v.Search); err != nil { + log.Printf("list dishes: %v", err) + } + render(w, r, logPage(v)) +} + +func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.Time, id int64) { + target := dayURL("/", date, today(a.loc)) + sep := "?" + if strings.Contains(target, "?") { + sep = "&" + } + http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther) +} + // save records the meal and redirects, so a refresh cannot double-post. func (a *app) save(w http.ResponseWriter, r *http.Request) { date := a.date(r) @@ -152,20 +222,175 @@ func (a *app) history(w http.ResponseWriter, r *http.Request) { render(w, r, historyPage(rows)) } -func (a *app) catalog(w http.ResponseWriter, r *http.Request) { - a.renderCatalog(w, r, nil) +// mainForm and sideForm carry what the user typed, so a rejected submission +// comes back filled in rather than blank. +type mainForm struct { + ID int64 + Name string + Categories map[string]bool + HasSides bool + Err string } -func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, report *ImportReport) { - var mains, sides int - err := a.db.QueryRow(` - SELECT (SELECT count(*) FROM main_dishes WHERE deleted_at IS NULL), - (SELECT count(*) FROM side_dishes WHERE deleted_at IS NULL)`, - ).Scan(&mains, &sides) - if err != nil { - log.Printf("catalog counts: %v", err) +type sideForm struct { + ID int64 + Name string + Err string +} + +type catalogView struct { + Mains []Dish + Sides []Side + Main mainForm + Side sideForm + Report *ImportReport +} + +func (a *app) catalog(w http.ResponseWriter, r *http.Request) { + v := catalogView{ + Main: mainForm{Categories: map[string]bool{}, HasSides: true}, } - render(w, r, catalogPage(mains, sides, report)) + + // ?muokkaa= loads a dish into its form; the same form adds and edits. + if raw := r.URL.Query().Get("muokkaa"); raw != "" { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + if dish, err := dishByID(a.db, id); err == nil { + v.Main = mainForm{ + ID: dish.ID, + Name: dish.Name, + Categories: map[string]bool{}, + HasSides: dish.HasSides, + } + for _, c := range dish.Categories { + v.Main.Categories[c] = true + } + } + } + } + if raw := r.URL.Query().Get("muokkaa-lisuke"); raw != "" { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + if side, err := sideByID(a.db, id); err == nil { + v.Side = sideForm{ID: side.ID, Name: side.Name} + } + } + } + + a.renderCatalog(w, r, v) +} + +func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { + if v.Main.Categories == nil { + v.Main.Categories = map[string]bool{} + } + + var err error + if v.Mains, err = listDishes(a.db, ""); err != nil { + log.Printf("list mains: %v", err) + } + if v.Sides, err = listSides(a.db); err != nil { + log.Printf("list sides: %v", err) + } + render(w, r, catalogPage(v)) +} + +// saveMain adds or updates a main dish. A rejected form is re-rendered with +// the values still in it; a good one redirects, so refresh cannot re-submit. +func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { + form := mainForm{ + Name: normalizeName(r.FormValue("nimi")), + Categories: map[string]bool{}, + HasSides: r.FormValue("lisukkeita") != "", + } + if raw := r.FormValue("id"); raw != "" { + form.ID, _ = strconv.ParseInt(raw, 10, 64) + } + + var categories []string + for _, c := range r.Form["kategoria"] { + if validCategories[c] { + categories = append(categories, c) + form.Categories[c] = true + } + } + + switch { + case form.Name == "": + form.Err = "Anna nimi." + case len(categories) == 0: + form.Err = "Valitse vähintään yksi kategoria." + } + if form.Err == "" { + var err error + if form.ID == 0 { + _, err = createMain(a.db, form.Name, categories, form.HasSides) + } else { + err = updateMain(a.db, form.ID, form.Name, categories, form.HasSides) + } + switch { + case errors.Is(err, errNameTaken): + form.Err = "Nimi on jo listalla." + case err != nil: + log.Printf("save main: %v", err) + form.Err = "Tallennus epäonnistui." + default: + http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + return + } + } + a.renderCatalog(w, r, catalogView{Main: form}) +} + +func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { + form := sideForm{Name: normalizeName(r.FormValue("nimi"))} + if raw := r.FormValue("id"); raw != "" { + form.ID, _ = strconv.ParseInt(raw, 10, 64) + } + + if form.Name == "" { + form.Err = "Anna nimi." + } else { + var err error + if form.ID == 0 { + err = createSide(a.db, form.Name) + } else { + err = updateSide(a.db, form.ID, form.Name) + } + switch { + case errors.Is(err, errNameTaken): + form.Err = "Nimi on jo listalla." + case err != nil: + log.Printf("save side: %v", err) + form.Err = "Tallennus epäonnistui." + default: + http.Redirect(w, r, "/ruoat", http.StatusSeeOther) + return + } + } + a.renderCatalog(w, r, catalogView{Side: form}) +} + +// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6). +// +// ponytail: no confirmation step. The row survives and history still reads +// correctly; add a confirm or an undo list if anyone actually mis-taps. +func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) + if err != nil { + http.Error(w, "tuntematon ruoka", http.StatusBadRequest) + return + } + + if r.FormValue("tyyppi") == "lisuke" { + err = softDeleteSide(a.db, id) + } else { + err = softDeleteMain(a.db, id) + } + if err != nil { + log.Printf("delete dish: %v", err) + http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/ruoat", http.StatusSeeOther) } // importDishes takes a bundle either pasted into the textarea or uploaded as a @@ -178,7 +403,7 @@ func (a *app) importDishes(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxUpload) if err := r.ParseMultipartForm(maxUpload); err != nil { - a.renderCatalog(w, r, failed("Tiedosto on liian suuri tai vioittunut.")) + a.renderCatalog(w, r, catalogView{Report: failed("Tiedosto on liian suuri tai vioittunut.")}) return } @@ -189,16 +414,16 @@ func (a *app) importDishes(w http.ResponseWriter, r *http.Request) { } else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" { src = strings.NewReader(pasted) } else { - a.renderCatalog(w, r, failed("Ei tuotavaa: liitä JSON tai valitse tiedosto.")) + a.renderCatalog(w, r, catalogView{Report: failed("Ei tuotavaa: liitä JSON tai valitse tiedosto.")}) return } report, err := importBundle(a.db, src) if err != nil { - a.renderCatalog(w, r, failed("JSON ei kelpaa: "+err.Error())) + a.renderCatalog(w, r, catalogView{Report: failed("JSON ei kelpaa: " + err.Error())}) return } - a.renderCatalog(w, r, report) + a.renderCatalog(w, r, catalogView{Report: report}) } // failed builds a report for a whole-request failure, so the view only ever diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index c6cbb1c..934f14e 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -146,9 +146,13 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler { mux.Handle("GET /static/", http.FileServerFS(staticFS)) mux.HandleFunc("GET /{$}", a.index) 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) // /healthz stays outside auth so a monitor or reverse proxy can reach it. diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css index a5ac067..dc34bd1 100644 --- a/cmd/foodster/static/app.css +++ b/cmd/foodster/static/app.css @@ -346,6 +346,77 @@ button, input, select { font: inherit; } cursor: pointer; } +/* Catalog rows */ +.sechead { + margin: 26px 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--muted); +} +.row { + display: flex; + align-items: center; + gap: 11px; + min-height: var(--tap); + padding: 11px 0; + border-bottom: 1px solid var(--line); +} +.rowtext { flex: 1; min-width: 0; } +.rowtext .nm { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; } +.rowtext .sd { font-size: 12.5px; color: var(--muted); } +.rowactions { display: flex; align-items: center; gap: 4px; flex: none; } +.rowactions a, .rowactions button { + min-height: 40px; + padding: 0 10px; + display: flex; + align-items: center; + background: none; + border: 0; + border-radius: 8px; + color: var(--muted); + font-size: 13px; + font-weight: 600; + text-decoration: none; + cursor: pointer; +} +.rowactions a:hover { color: var(--accent); } +.rowactions .del { color: var(--liha); } + +.field input[type="text"] { + width: 100%; + min-height: var(--tap); + background: var(--sunk); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 10px; + padding: 0 12px; + font-size: 16px; +} +.chip.wide { width: 100%; margin-bottom: 14px; border-radius: 10px; gap: 8px; } +.chip .dot { margin-right: 6px; } +.formerr { + margin: 0 0 12px; + padding: 10px 12px; + border-left: 3px solid var(--liha); + background: var(--sunk); + border-radius: 0 8px 8px 0; + font-size: 14px; +} +details.card > summary { + cursor: pointer; + font-weight: 600; + min-height: 24px; +} +details.card[open] > summary { margin-bottom: 10px; } +details.card > section.card { + margin: 0; + padding: 0; + border: 0; + background: none; +} + .report { border-left: 4px solid var(--accent); } .report.bad { border-left-color: var(--liha); } .report .tally { margin: 0; font-weight: 700; } diff --git a/cmd/foodster/store.go b/cmd/foodster/store.go index e0698ed..872b61c 100644 --- a/cmd/foodster/store.go +++ b/cmd/foodster/store.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "errors" "strings" "time" ) @@ -232,6 +233,114 @@ func deleteEntry(db *sql.DB, date time.Time) error { return err } +// errNameTaken is returned when a name collides with a live dish. The +// comparison is case-insensitive and ignores soft-deleted rows (PRD §7.3). +var errNameTaken = errors.New("nimi on jo listalla") + +func taken(err error) error { + // ponytail: string match rather than a driver-specific error type, so + // this survives swapping the driver. + if err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") { + return errNameTaken + } + return err +} + +// createMain inserts a main dish and its categories in one transaction. +func createMain(db *sql.DB, name string, categories []string, hasSides bool) (int64, error) { + tx, err := db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + res, err := tx.Exec( + `INSERT INTO main_dishes (name, has_sides) VALUES (?, ?)`, name, hasSides) + if err != nil { + return 0, taken(err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, err + } + if err := setCategories(tx, id, categories); err != nil { + return 0, err + } + return id, tx.Commit() +} + +// updateMain rewrites a main dish, replacing its category set wholesale. +func updateMain(db *sql.DB, id int64, name string, categories []string, hasSides bool) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec( + `UPDATE main_dishes SET name = ?, has_sides = ? WHERE id = ? AND deleted_at IS NULL`, + name, hasSides, id, + ); err != nil { + return taken(err) + } + if _, err := tx.Exec( + `DELETE FROM main_dish_categories WHERE main_dish_id = ?`, id); err != nil { + return err + } + if err := setCategories(tx, id, categories); err != nil { + return err + } + return tx.Commit() +} + +func setCategories(tx *sql.Tx, mainID int64, categories []string) error { + for _, c := range categories { + if _, err := tx.Exec( + `INSERT OR IGNORE INTO main_dish_categories (main_dish_id, category) VALUES (?, ?)`, + mainID, c, + ); err != nil { + return err + } + } + return nil +} + +func createSide(db *sql.DB, name string) error { + _, err := db.Exec(`INSERT INTO side_dishes (name) VALUES (?)`, name) + return taken(err) +} + +func updateSide(db *sql.DB, id int64, name string) error { + _, err := db.Exec( + `UPDATE side_dishes SET name = ? WHERE id = ? AND deleted_at IS NULL`, name, id) + return taken(err) +} + +// Soft delete: the row stays so historical log entries keep resolving their +// names, but it disappears from the catalog and every picker (PRD §6). +func softDeleteMain(db *sql.DB, id int64) error { + _, err := db.Exec( + `UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = ?`, id) + return err +} + +func softDeleteSide(db *sql.DB, id int64) error { + _, err := db.Exec( + `UPDATE side_dishes SET deleted_at = datetime('now') WHERE id = ?`, id) + return err +} + +func sideByID(db *sql.DB, id int64) (*Side, error) { + var s Side + err := db.QueryRow( + `SELECT id, name FROM side_dishes WHERE id = ? AND deleted_at IS NULL`, id, + ).Scan(&s.ID, &s.Name) + if err != nil { + return nil, err + } + return &s, nil +} + // HistoryRow is one calendar day: either what was eaten or an unfilled gap. type HistoryRow struct { Date time.Time diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index bc1c36a..86e9fc2 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -3,6 +3,7 @@ package main import ( "fmt" "strconv" + "strings" "time" ) @@ -50,6 +51,15 @@ func pickSeparator(v logView) string { return "&" } +// categoryLabels lists a dish's categories in Finnish, for the catalog rows. +func categoryLabels(d Dish) string { + names := make([]string, 0, len(d.Categories)) + for _, c := range d.Categories { + names = append(names, categoryFI[c]) + } + return strings.Join(names, ", ") +} + // countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive // after every number except one. func countFI(n int, one, many string) string { @@ -134,28 +144,66 @@ templ dayButton(label string, target, selected, now time.Time) { } templ board(v logView) { - if len(v.Dishes) == 0 && v.Search == "" { -
-

Ruokalista on tyhjä.

-

Tuo ruokia Ruoat-välilehdellä, niin ne ilmestyvät tähän.

- Siirry ruokiin -
- } else { -
- if !v.Date.Equal(v.Today) { - - } - -
- if len(v.Dishes) == 0 { -

Ei osumia haulle { v.Search }.

+
+ if !v.Date.Equal(v.Today) { + } + +
+ if len(v.Dishes) > 0 {
for _, d := range v.Dishes { @dishPill(d, v) }
} + if len(v.Dishes) == 0 { + @quickAddCard(v) + } +} + +// quickAddCard turns a search that found nothing into the thing to do next. +// Adding a dish here creates it and goes straight to logging it, so the user +// never leaves Kirjaa mid-thought. +templ quickAddCard(v logView) { +
+ if v.Search == "" { +

Lisää ensimmäinen ruoka

+

+ Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruoat-välilehdeltä. +

+ } else { +

Ei osumia. Lisätäänkö?

+ } + if v.New.Err != "" { +

{ v.New.Err }

+ } +
+ + +
+ Kategoriat +
+ @categoryChip("meat", "liha", v.New) + @categoryChip("chicken", "kana", v.New) + @categoryChip("fish", "kala", v.New) + @categoryChip("vegetarian", "kasvis", v.New) +
+
+ + +
+
} templ dishPill(d Dish, v logView) { @@ -269,23 +317,153 @@ templ historyPage(rows []HistoryRow) { } // ---------------------------------------------------------------- Ruoat -templ catalogPage(mains, sides int, report *ImportReport) { +templ catalogPage(v catalogView) { @page("Ruoat — Foodster", "/ruoat") {

Ruoat

- { countFI(mains, "pääruoka", "pääruokaa") }, { countFI(sides, "lisuke", "lisuketta") } + { countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }

- if report != nil { - @importReport(report) + if v.Report != nil { + @importReport(v.Report) } - @importForm() + @mainForm_(v.Main) +

Pääruoat

+ if len(v.Mains) == 0 { +

Ei vielä pääruokia.

+ } + for _, d := range v.Mains { +
+ +
+
{ d.Name }
+
+ { categoryLabels(d) } + if !d.HasSides { + · ei lisukkeita + } +
+
+ @rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa") +
+ } + @sideForm_(v.Side) +

Lisukkeet

+ if len(v.Sides) == 0 { +

Ei vielä lisukkeita.

+ } + for _, s := range v.Sides { +
+
{ s.Name }
+ @rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") +
+ } +
+ Tuo ruokia tiedostosta + @importForm() +
} } +templ rowActions(editURL string, id int64, kind string) { +
+ Muokkaa +
+ + + +
+
+} + +templ mainForm_(f mainForm) { +
+

+ if f.ID == 0 { + Lisää pääruoka + } else { + Muokkaa pääruokaa + } +

+ if f.Err != "" { +

{ f.Err }

+ } +
+ if f.ID != 0 { + + } + +
+ Kategoriat +
+ @categoryChip("meat", "liha", f) + @categoryChip("chicken", "kana", f) + @categoryChip("fish", "kala", f) + @categoryChip("vegetarian", "kasvis", f) +
+
+ + +
+ if f.ID != 0 { + Peruuta + } +
+} + +templ categoryChip(value, label string, f mainForm) { + +} + +templ sideForm_(f sideForm) { +
+

+ if f.ID == 0 { + Lisää lisuke + } else { + Muokkaa lisuketta + } +

+ if f.Err != "" { +

{ f.Err }

+ } +
+ if f.ID != 0 { + + } + + +
+ if f.ID != 0 { + Peruuta + } +
+} + templ importForm() {

Tuo ruokia

diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 319b0a7..47c4fc6 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -115,6 +115,60 @@ check "the day is empty again" \ check "search filters the board" \ "$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto" +# ---- adding a dish without leaving Kirjaa -------------------------------- + +miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste") +check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?" +check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"' + +check "quick add goes straight to the sides step" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \ + "ruoka=" + +check "quick add rejects a dish with no category" \ + "$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \ + "Valitse vähintään yksi kategoria." + +check "the quick-added dish is on the board" \ + "$(curl -s -u ":$pass" "http://$addr/")" "Poronkariste" + +# ---- catalog CRUD from the UI ------------------------------------------- + +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" + +catalog=$(curl -s -u ":$pass" "http://$addr/ruoat") +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")" \ + "Nimi on jo listalla." + +check "a main with no category is refused" \ + "$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruoat/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")" \ + "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" + +check "the new side is listed" \ + "$(curl -s -u ":$pass" "http://$addr/ruoat")" "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" + +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" + if [ "$fail" -ne 0 ]; then echo "smoke: FAILED" exit 1