Add dish CRUD to the UI, including inline from Kirjaa

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.
This commit is contained in:
Esa Kataja
2026-09-05 18:44:09 +03:00
parent d95b9f9f91
commit d15f741ab1
9 changed files with 836 additions and 73 deletions
+4 -3
View File
@@ -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.
+6 -35
View File
@@ -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 {
+150
View File
@@ -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")
}
}
}
+240 -15
View File
@@ -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
+4
View File
@@ -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.
+71
View File
@@ -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; }
+109
View File
@@ -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
+193 -15
View File
@@ -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 == "" {
<section class="card empty">
<p>Ruokalista on tyhjä.</p>
<p class="muted small">Tuo ruokia Ruoat-välilehdellä, niin ne ilmestyvät tähän.</p>
<a class="primary" href="/ruoat">Siirry ruokiin</a>
</section>
} else {
<form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
}
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi" aria-label="Etsi"/>
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
</form>
if len(v.Dishes) == 0 {
<p class="muted">Ei osumia haulle { v.Search }.</p>
}
if len(v.Dishes) > 0 {
<div class="board">
for _, d := range v.Dishes {
@dishPill(d, v)
}
</div>
}
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) {
<section class="card">
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ä.
</p>
} else {
<h3>Ei osumia. Lisätäänkö?</h3>
}
if v.New.Err != "" {
<p class="formerr">{ v.New.Err }</p>
}
<form method="post" action="/lisaa">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ v.New.Name } autocomplete="off" required/>
</label>
<div class="field">
<span>Kategoriat</span>
<div class="chips">
@categoryChip("meat", "liha", v.New)
@categoryChip("chicken", "kana", v.New)
@categoryChip("fish", "kala", v.New)
@categoryChip("vegetarian", "kasvis", v.New)
</div>
</div>
<label class="chip wide">
if v.New.HasSides {
<input type="checkbox" name="lisukkeita" value="1" checked/>
} else {
<input type="checkbox" name="lisukkeita" value="1"/>
}
<span>Tarjoillaan lisukkeiden kanssa</span>
</label>
<button class="primary" type="submit">Lisää ja kirjaa</button>
</form>
</section>
}
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") {
<header class="appbar">
<h2>Ruoat</h2>
<p class="meta">
{ countFI(mains, "pääruoka", "pääruokaa") }, { countFI(sides, "lisuke", "lisuketta") }
{ countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
</p>
</header>
<main class="pad">
if report != nil {
@importReport(report)
if v.Report != nil {
@importReport(v.Report)
}
@mainForm_(v.Main)
<h3 class="sechead">Pääruoat</h3>
if len(v.Mains) == 0 {
<p class="muted small">Ei vielä pääruokia.</p>
}
for _, d := range v.Mains {
<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>
</div>
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
}
@sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3>
if len(v.Sides) == 0 {
<p class="muted small">Ei vielä lisukkeita.</p>
}
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")
</div>
}
<details class="card">
<summary>Tuo ruokia tiedostosta</summary>
@importForm()
</details>
</main>
}
}
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">
<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>
</form>
</div>
}
templ mainForm_(f mainForm) {
<section class="card" id="paaruoka">
<h3>
if f.ID == 0 {
Lisää pääruoka
} else {
Muokkaa pääruokaa
}
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/paaruoka">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
</label>
<div class="field">
<span>Kategoriat</span>
<div class="chips">
@categoryChip("meat", "liha", f)
@categoryChip("chicken", "kana", f)
@categoryChip("fish", "kala", f)
@categoryChip("vegetarian", "kasvis", f)
</div>
</div>
<label class="chip wide">
if f.HasSides {
<input type="checkbox" name="lisukkeita" value="1" checked/>
} else {
<input type="checkbox" name="lisukkeita" value="1"/>
}
<span>Tarjoillaan lisukkeiden kanssa</span>
</label>
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a>
}
</section>
}
templ categoryChip(value, label string, f mainForm) {
<label class="chip">
if f.Categories[value] {
<input type="checkbox" name="kategoria" value={ value } checked/>
} else {
<input type="checkbox" name="kategoria" value={ value }/>
}
<i class={ "dot", "d-" + categoryFI[value] }></i>
<span>{ label }</span>
</label>
}
templ sideForm_(f sideForm) {
<section class="card" id="lisuke">
<h3>
if f.ID == 0 {
Lisää lisuke
} else {
Muokkaa lisuketta
}
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/lisuke">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
</label>
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruoat">Peruuta</a>
}
</section>
}
templ importForm() {
<section class="card">
<h3>Tuo ruokia</h3>
+54
View File
@@ -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