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) {
+ Ei vielä merkintöjä. { monthFI(row.Date) }Aiemmin
+ if len(v.History) == 0 {
+
- 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 {