From 4ce189d1e445e675b80f0cf33f78064122967a0e Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 21:28:14 +0300 Subject: [PATCH] Merge the log and history into one page, group dishes by category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kirjaa and Historia were two views of the same thing: every history row was already a link back into the logger, and the logger had a day switcher. They are now one page — the day being logged on top, history underneath, each row loading its day into the logger above. Two tabs instead of three. That also closed a gap. On an already-logged day there was no way to swap to a different dish; "Muokkaa" only reopened the sides for the same one. It now opens the board, so changing a dish and choosing one for the first time are the same path. Dishes are grouped by category on both screens, with Sekalaiset collecting the ones covering more than one. That group is derived from the stored set rather than being a fifth category, so a single Tortillat still satisfies meat, chicken, fish and vegetarian at once when the §8.1 suggester arrives. The two screens sort differently on purpose. The log board keeps frequency then name inside each group, so favourites surface without wandering between categories as counts change. The catalog sorts by name, because there you are hunting a specific dish to edit rather than picking one to eat. groupDishes preserves the order it is handed; the caller decides which it wants. Ruoat is renamed Ruuat throughout, label and route both. --- PRD.md | 2 +- README.md | 27 +++++-- cmd/foodster/handlers.go | 79 ++++++++++++------- cmd/foodster/main.go | 11 ++- cmd/foodster/static/app.css | 10 +++ cmd/foodster/store.go | 48 ++++++++++++ cmd/foodster/views.templ | 147 ++++++++++++++++++------------------ scripts/smoke.sh | 36 ++++----- 8 files changed, 226 insertions(+), 134 deletions(-) diff --git a/PRD.md b/PRD.md index de48db2..0f42fb0 100644 --- a/PRD.md +++ b/PRD.md @@ -321,7 +321,7 @@ build and no asset bundler. lines of `database/sql`. There are no down-migrations: restoring the database file is the rollback for a single-household app. - **Bundle import**: the §7.3 mass import is a live feature of the running - app, on the Ruoat tab — paste JSON or upload a file, get a per-row report + app, on the Ruuat tab — paste JSON or upload a file, get a per-row report back. A plain multipart form rather than a Datastar round trip, since the response is a whole-page report and a form needs no client code. Uploads are capped at 1 MiB. The same importer is also reachable as diff --git a/README.md b/README.md index 1371830..41eaf01 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,29 @@ weeks of real history to weight against. Working: -- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized - by how often they are eaten. Edit or delete the day's entry. -- **Historia** — every day back to the first entry, with unlogged days shown - as explicit gaps. -- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle - by paste or file upload. Deletes are soft, so old log entries keep showing - the dish they used. +- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered + and sized by how often they are eaten, so the likely answer is the biggest + target. The history sits on the same page underneath: every day back to the + first entry, unlogged days shown as explicit gaps, and every row a link that + loads that day into the logger above it. +- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle + by paste or file upload. Grouped by category and alphabetical inside, since + this is a list you manage rather than one you pick from. Deletes are soft, + so old log entries keep showing the dish they used. - **Light / dark**, remembered per device, dark by default. The button shows the theme that is on — moon while dark, sun while light — not the one a click would bring. Still to build: + +- Live search as you type, and paging for the history and catalog lists once + years of entries make them long. Both via Datastar. +- Category icons instead of plain colour dots — colour and shape together, so + a red blob and a yellow blob are told apart by more than hue. +- Edit and delete as icons in the catalog rows, and a confirmation step before + a delete actually happens. +- A background for the header. Something subtle; the palette gets overhauled + later. - Stage 2: the seven-meal suggester, which starts once there is history to weight against. @@ -99,7 +110,7 @@ make up/down/logs compose ## Importing dishes -The **Ruoat** tab takes a bundle of mains and sides: paste the JSON or upload +The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload a file, and the app reports row by row what it did. ```json diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index f18f6e6..317d4d4 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -17,8 +17,10 @@ import ( // kilobytes; a megabyte is already absurd generosity. const maxUpload = 1 << 20 -// historyDays is how far back the Historia list walks. Long enough to see a -// couple of months, short enough to stay one scroll. +// historyDays is how far back the history under the logger walks. +// +// ponytail: a fixed window. After a year of daily entries this list is the +// thing that needs paging; load more on scroll when it actually hurts. const historyDays = 60 type app struct { @@ -45,17 +47,22 @@ func (a *app) date(r *http.Request) time.Time { return today(a.loc) } -// logView is everything the Kirjaa screen needs. +// logView is everything the log screen needs. Logging and history are one +// page: every history row was already a link back into the logger, and the +// day switcher made them two views of the same thing. type logView struct { - Date time.Time - Today time.Time - Search string - Entry *Entry // what is already logged for Date, if anything - Chosen *Dish // dish picked, so the sides step is showing - Checked map[int64]bool // sides ticked in that step - Dishes []Dish - Sides []Side - New mainForm // inline "add the dish you were looking for" + Date time.Time + Today time.Time + Search string + Entry *Entry // what is already logged for Date, if anything + Chosen *Dish // dish picked, so the sides step is showing + Checked map[int64]bool // sides ticked in that step + ShowBoard bool + Dishes []Dish // flat, only to know whether anything matched + Groups []DishGroup // what the board actually renders + Sides []Side + New mainForm // inline "add the dish you were looking for" + History []HistoryRow } func (a *app) index(w http.ResponseWriter, r *http.Request) { @@ -89,10 +96,18 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { } } - if v.Chosen == nil && v.Entry == nil { + // The board shows when there is nothing logged yet, or when the entry is + // being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here, + // so swapping the dish and picking one for the first time are one path. + changing := r.URL.Query().Get("muuta") != "" || v.Search != "" + if v.Chosen == nil && (v.Entry == nil || changing) { + v.ShowBoard = true if v.Dishes, err = listDishes(a.db, v.Search); err != nil { log.Printf("list dishes: %v", err) } + // listDishes already orders by frequency then name, so grouping keeps + // the favourites at the top of each category. + v.Groups = groupDishes(v.Dishes) // Seed the inline add form with whatever was searched for, so a miss // turns straight into "add it" without retyping. v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} @@ -103,6 +118,10 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { } } + if v.History, err = history(a.db, a.loc, historyDays); err != nil { + log.Printf("history: %v", err) + } + render(w, r, logPage(v)) } @@ -149,16 +168,21 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) { // 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, + Date: date, + Today: today(a.loc), + Search: form.Name, + Checked: map[int64]bool{}, + New: form, + ShowBoard: true, } var err error if v.Dishes, err = listDishes(a.db, v.Search); err != nil { log.Printf("list dishes: %v", err) } + v.Groups = groupDishes(v.Dishes) + if v.History, err = history(a.db, a.loc, historyDays); err != nil { + log.Printf("history: %v", err) + } render(w, r, logPage(v)) } @@ -214,14 +238,6 @@ func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Ti http.Redirect(w, r, target, http.StatusSeeOther) } -func (a *app) history(w http.ResponseWriter, r *http.Request) { - rows, err := history(a.db, a.loc, historyDays) - if err != nil { - log.Printf("history: %v", err) - } - render(w, r, historyPage(rows)) -} - // mainForm and sideForm carry what the user typed, so a rejected submission // comes back filled in rather than blank. type mainForm struct { @@ -239,11 +255,12 @@ type sideForm struct { } type catalogView struct { - Mains []Dish + Groups []DishGroup Sides []Side Main mainForm Side sideForm Report *ImportReport + Mains int // count, for the header } func (a *app) catalog(w http.ResponseWriter, r *http.Request) { @@ -283,10 +300,14 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie v.Main.Categories = map[string]bool{} } - var err error - if v.Mains, err = listDishes(a.db, ""); err != nil { + mains, err := listDishes(a.db, "") + if err != nil { log.Printf("list mains: %v", err) } + v.Mains = len(mains) + sortByName(mains) // the catalog is managed, so position should be predictable + v.Groups = groupDishes(mains) + if v.Sides, err = listSides(a.db); err != nil { log.Printf("list sides: %v", err) } diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 2b7a323..0fe374e 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -159,12 +159,11 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler { mux.HandleFunc("POST /kirjaa", a.save) mux.HandleFunc("POST /lisaa", a.quickAdd) mux.HandleFunc("POST /poista", a.delete) - mux.HandleFunc("GET /historia", a.history) - mux.HandleFunc("GET /ruoat", a.catalog) - mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain) - mux.HandleFunc("POST /ruoat/lisuke", a.saveSide) - mux.HandleFunc("POST /ruoat/poista", a.deleteDish) - mux.HandleFunc("POST /ruoat/tuonti", a.importDishes) + mux.HandleFunc("GET /ruuat", a.catalog) + mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain) + mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) + mux.HandleFunc("POST /ruuat/poista", a.deleteDish) + mux.HandleFunc("POST /ruuat/tuonti", a.importDishes) // /healthz stays outside auth so a monitor or reverse proxy can reach it. root := http.NewServeMux() diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css index bd34057..f563b63 100644 --- a/cmd/foodster/static/app.css +++ b/cmd/foodster/static/app.css @@ -307,6 +307,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; } text-transform: uppercase; color: var(--muted); } +/* History sits under the logger on the same page, so whole rows are links. */ +.history { margin-top: 8px; } .entry, .gapline { display: flex; gap: 12px; @@ -314,6 +316,14 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; } min-height: var(--tap); padding: 14px 0; border-bottom: 1px solid var(--line); + color: inherit; + text-decoration: none; +} +.gapline .act { + margin-left: auto; + padding: 0 4px; + color: var(--accent); + font-weight: 600; } .gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); } .entry time, .gapline time { diff --git a/cmd/foodster/store.go b/cmd/foodster/store.go index 872b61c..b1b72e8 100644 --- a/cmd/foodster/store.go +++ b/cmd/foodster/store.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "errors" + "slices" "strings" "time" ) @@ -341,6 +342,53 @@ func sideByID(db *sql.DB, id int64) (*Side, error) { return &s, nil } +// DishGroup is one category's worth of dishes for the catalog listing. +type DishGroup struct { + Key string + Label string + Dishes []Dish +} + +// groupOrder fixes the order the catalog lists categories in. Sekalaiset is a +// display grouping for dishes covering more than one category, not a fifth +// category: the stored set is what PRD §8.1 counts for coverage, and one +// Tortillat still satisfies meat, chicken, fish and vegetarian at once. +var groupOrder = []DishGroup{ + {Key: "liha", Label: "Liha"}, + {Key: "kana", Label: "Kana"}, + {Key: "kala", Label: "Kala"}, + {Key: "kasvis", Label: "Kasvis"}, + {Key: "sek", Label: "Sekalaiset"}, +} + +// groupDishes buckets dishes by category, keeping whatever order they arrived +// in. The caller decides that order: the log board hands over listDishes' +// frequency-then-name ordering, the catalog sorts by name first. +func groupDishes(dishes []Dish) []DishGroup { + byKey := make(map[string][]Dish, len(groupOrder)) + for _, d := range dishes { + key := d.CategoryKey() + byKey[key] = append(byKey[key], d) + } + + var groups []DishGroup + for _, g := range groupOrder { + in := byKey[g.Key] + if len(in) == 0 { + continue + } + groups = append(groups, DishGroup{Key: g.Key, Label: g.Label, Dishes: in}) + } + return groups +} + +// sortByName orders dishes alphabetically, case-insensitively. +func sortByName(dishes []Dish) { + slices.SortFunc(dishes, func(a, b Dish) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) +} + // HistoryRow is one calendar day: either what was eaten or an unfilled gap. type HistoryRow struct { Date time.Time diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index f771635..15b0c55 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -109,9 +109,6 @@ templ brandbar() { } -// themeSwitch marks the active theme rather than labelling itself with the one -// a click would produce. aria-pressed is set by theme.js on load, because only -// the device knows what was chosen. // themeSwitch shows the theme that is on right now — moon while dark, sun // while light — and clicking swaps it. Both icons are in the markup and CSS // picks one, so the server never has to know the device's choice. @@ -145,8 +142,7 @@ templ iconMoon() { templ tabbar(current string) { } @@ -170,15 +166,53 @@ templ logPage(v logView) { switch { case v.Chosen != nil: @sidesStep(v) - case v.Entry != nil: - @loggedCard(v) - default: + case v.ShowBoard: @board(v) + default: + @loggedCard(v) } + @historyList(v) } } +// historyList sits under the day being logged: the two were always one thing, +// since every row here is a link back into the logger above it. +templ historyList(v logView) { +
+

Aiemmin

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

Ei vielä merkintöjä.

+ } + for i, row := range v.History { + if !row.Date.Equal(v.Date) { + if i == 0 || v.History[i-1].Date.Month() != row.Date.Month() { +

{ monthFI(row.Date) }

+ } + if row.Entry != nil { + + +
+
+ + { row.Entry.Main.Name } +
+
{ row.Entry.SidesLabel() }
+
+ +
+ } else { + + + Ei merkintää + Merkitse + + } + } + } +
+} + templ daySwitch(v logView) {
@dayButton("Tänään", v.Today, v.Date, v.Today) @@ -205,9 +239,13 @@ templ board(v logView) { } - if len(v.Dishes) > 0 { + // Grouped by category, and inside each group the most-eaten first — so a + // dish keeps a predictable neighbourhood while favourites still surface + // at the top of it. + for _, g := range v.Groups { +

{ g.Label }

- for _, d := range v.Dishes { + for _, d := range g.Dishes { @dishPill(d, v) }
@@ -225,7 +263,7 @@ 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ä. + Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä.

} else {

Ei osumia. Lisätäänkö?

@@ -323,7 +361,7 @@ templ loggedCard(v logView) {
Muokkaa
@@ -334,50 +372,13 @@ templ loggedCard(v logView) { } // ---------------------------------------------------------------- Historia -templ historyPage(rows []HistoryRow) { - @page("Historia — Foodster", "/historia") { -
-

Historia

-
-
- if len(rows) == 0 { -

Ei vielä merkintöjä.

- } - for i, row := range rows { - if i == 0 || rows[i-1].Date.Month() != row.Date.Month() { -

{ monthFI(row.Date) }

- } - if row.Entry != nil { -
- -
-
- - { row.Entry.Main.Name } -
-
{ row.Entry.SidesLabel() }
-
- -
- } else { -
- - Ei merkintää - -

Ruoat

+

Ruuat

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

@@ -385,24 +386,26 @@ templ catalogPage(v catalogView) { @importReport(v.Report) } @mainForm_(v.Main) -

Pääruoat

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

Pääruuat

Ei vielä pääruokia.

} - for _, d := range v.Mains { -