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 { -