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