Pääruoat
+ if len(v.Mains) == 0 { +Ei vielä pääruokia.
+ } + for _, d := range v.Mains { +Lisukkeet
+ if len(v.Sides) == 0 { +Ei vielä lisukkeita.
+ } + for _, s := range v.Sides { +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.
Ei osumia haulle { v.Search }.
+ + if len(v.Dishes) > 0 {+ Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruoat-välilehdeltä. +
+ } else { +{ v.New.Err }
+ } + +Ei vielä pääruokia.
+ } + for _, d := range v.Mains { +Ei vielä lisukkeita.
+ } + for _, s := range v.Sides { +{ f.Err }
+ } + + if f.ID != 0 { + Peruuta + } +{ f.Err }
+ } + + if f.ID != 0 { + Peruuta + } +