Build the Kirjaa log flow and Historia list
Stage 1's point is collecting eating history, so the logging path is the one that has to be frictionless: pick a dish, tick sides, done. Kirjaa: - dishes ordered and sized by how often they have been eaten, so the likely answer is the biggest target on the screen - picking a dish opens the sides step; a dish with has_sides false says so instead of offering an empty list - saving redirects, so a refresh cannot double-post - a day already logged shows the entry with edit and delete. Editing reopens the sides step with the existing sides ticked, which makes editing and creating the same screen - day switcher and a server-side search over the catalog Historia walks back day by day to the oldest entry, so a day nobody wrote down appears as an explicit gap rather than quietly missing. No JavaScript is involved: every interaction is a link or a form, and the checked-chip styling is :has(input:checked). Datastar stays loaded but unused until an interaction genuinely needs to avoid a page load. Also fix a Makefile ordering bug: lint did not depend on generate, so `go vet` could run against templ output that was being rewritten. check now runs its phases as sub-makes so `make -j` cannot interleave them. Records two features that are specified but not built: dish CRUD in the UI (§7.3, only import exists today) and a per-device light/dark switch (§7.4). The switch must show the state that is active, not the one clicking produces.
This commit is contained in:
@@ -43,7 +43,12 @@ test: generate ## Run unit tests
|
|||||||
smoke: generate ## End-to-end check: auth, static assets, bundle import
|
smoke: generate ## End-to-end check: auth, static assets, bundle import
|
||||||
./scripts/smoke.sh
|
./scripts/smoke.sh
|
||||||
|
|
||||||
check: lint test smoke ## Everything that must pass before a commit
|
# Sub-makes rather than prerequisites: these must run in order even under
|
||||||
|
# `make -j`, and a parallel run vets generated code that is being rewritten.
|
||||||
|
check: ## Everything that must pass before a commit
|
||||||
|
@$(MAKE) --no-print-directory lint
|
||||||
|
@$(MAKE) --no-print-directory test
|
||||||
|
@$(MAKE) --no-print-directory smoke
|
||||||
@echo "check: all passed"
|
@echo "check: all passed"
|
||||||
|
|
||||||
vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
|
vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
|
||||||
@@ -51,7 +56,7 @@ vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
|
|||||||
"https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js"
|
"https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js"
|
||||||
@head -1 cmd/foodster/static/datastar.js
|
@head -1 cmd/foodster/static/datastar.js
|
||||||
|
|
||||||
lint: ## go vet, gofmt check, golangci-lint when installed
|
lint: generate ## go vet, gofmt check, golangci-lint when installed
|
||||||
go vet ./...
|
go vet ./...
|
||||||
@bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \
|
@bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \
|
||||||
if [ -n "$$bad" ]; then echo "gofmt needed:"; echo "$$bad"; exit 1; fi
|
if [ -n "$$bad" ]; then echo "gofmt needed:"; echo "$$bad"; exit 1; fi
|
||||||
|
|||||||
@@ -200,6 +200,22 @@ regenerates or swaps.
|
|||||||
- Settings page for the cooldown window (default **14 days**, configurable).
|
- Settings page for the cooldown window (default **14 days**, configurable).
|
||||||
*(Stage 2 — only relevant once the suggester exists.)*
|
*(Stage 2 — only relevant once the suggester exists.)*
|
||||||
|
|
||||||
|
### 7.4 Appearance (stage 1)
|
||||||
|
|
||||||
|
- A light / dark theme switch, remembered **per device**. The instance is
|
||||||
|
shared and has no accounts, so this is a device preference in
|
||||||
|
`localStorage`, never a server-side setting — the phone in the kitchen and
|
||||||
|
the laptop must be able to disagree.
|
||||||
|
- Three states: light, dark, and follow the system. System is the default.
|
||||||
|
- The control **shows the state that is currently active**, not the state
|
||||||
|
clicking it would produce. A segmented control with the active option
|
||||||
|
marked, not a single button labelled with its opposite. A lightswitch does
|
||||||
|
not read "off" while the lights are on.
|
||||||
|
- Implementation is `color-scheme` on the root element: `light dark` for the
|
||||||
|
system-following default, `only light` or `only dark` when overridden.
|
||||||
|
Because the palette is built from `light-dark()` custom properties, no
|
||||||
|
other CSS changes.
|
||||||
|
|
||||||
## 8. Suggestion algorithm (stage 2)
|
## 8. Suggestion algorithm (stage 2)
|
||||||
|
|
||||||
Produce seven mains, then attach sides per meal.
|
Produce seven mains, then attach sides per meal.
|
||||||
|
|||||||
@@ -15,7 +15,21 @@ See [PRD.md](PRD.md) for the full specification.
|
|||||||
log come first, because the suggester is worthless until there are a few
|
log come first, because the suggester is worthless until there are a few
|
||||||
weeks of real history to weight against.
|
weeks of real history to weight against.
|
||||||
|
|
||||||
Stage 2 — the seven-meal suggester — starts once that history exists.
|
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** — import a bundle of dishes by paste or file upload.
|
||||||
|
|
||||||
|
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).
|
||||||
|
- Stage 2: the seven-meal suggester, which starts once there is history to
|
||||||
|
weight against.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|||||||
+119
-2
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ import (
|
|||||||
// kilobytes; a megabyte is already absurd generosity.
|
// kilobytes; a megabyte is already absurd generosity.
|
||||||
const maxUpload = 1 << 20
|
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.
|
||||||
|
const historyDays = 60
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
loc *time.Location
|
loc *time.Location
|
||||||
@@ -27,12 +32,124 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// date reads the ?pvm= parameter, falling back to today. An unparseable value
|
||||||
|
// is treated as today rather than an error: a mangled URL should not be a
|
||||||
|
// dead end.
|
||||||
|
func (a *app) date(r *http.Request) time.Time {
|
||||||
|
if raw := r.FormValue("pvm"); raw != "" {
|
||||||
|
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return today(a.loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logView is everything the Kirjaa screen needs.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||||
render(w, r, indexPage(today(a.loc)))
|
date := a.date(r)
|
||||||
|
v := logView{
|
||||||
|
Date: date,
|
||||||
|
Today: today(a.loc),
|
||||||
|
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
|
||||||
|
Checked: map[int64]bool{},
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := entryFor(a.db, date)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
|
||||||
|
}
|
||||||
|
v.Entry = entry
|
||||||
|
|
||||||
|
// ?ruoka= opens the sides step for that dish. When it is the dish already
|
||||||
|
// logged, the existing sides come back ticked, which makes editing an
|
||||||
|
// entry the same screen as creating one.
|
||||||
|
if raw := r.URL.Query().Get("ruoka"); raw != "" {
|
||||||
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
if dish, err := dishByID(a.db, id); err == nil {
|
||||||
|
v.Chosen = dish
|
||||||
|
if entry != nil && entry.Main.ID == id {
|
||||||
|
for _, s := range entry.Sides {
|
||||||
|
v.Checked[s.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.Chosen == nil && v.Entry == nil {
|
||||||
|
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||||
|
log.Printf("list dishes: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.Chosen != nil && v.Chosen.HasSides {
|
||||||
|
if v.Sides, err = listSides(a.db); err != nil {
|
||||||
|
log.Printf("list sides: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, r, logPage(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
mainID, err := strconv.ParseInt(r.FormValue("ruoka"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "tuntematon ruoka", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sideIDs []int64
|
||||||
|
for _, raw := range r.Form["lisuke"] {
|
||||||
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
sideIDs = append(sideIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveEntry(a.db, date, mainID, sideIDs); err != nil {
|
||||||
|
log.Printf("save entry: %v", err)
|
||||||
|
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.redirectToDay(w, r, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
date := a.date(r)
|
||||||
|
if err := deleteEntry(a.db, date); err != nil {
|
||||||
|
log.Printf("delete entry: %v", err)
|
||||||
|
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.redirectToDay(w, r, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
||||||
|
target := "/"
|
||||||
|
if !date.Equal(today(a.loc)) {
|
||||||
|
target += "?pvm=" + date.Format(dateLayout)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) history(w http.ResponseWriter, r *http.Request) {
|
func (a *app) history(w http.ResponseWriter, r *http.Request) {
|
||||||
render(w, r, historyPage())
|
rows, err := history(a.db, a.loc, historyDays)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("history: %v", err)
|
||||||
|
}
|
||||||
|
render(w, r, historyPage(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
mux.HandleFunc("GET /{$}", a.index)
|
mux.HandleFunc("GET /{$}", a.index)
|
||||||
|
mux.HandleFunc("POST /kirjaa", a.save)
|
||||||
|
mux.HandleFunc("POST /poista", a.delete)
|
||||||
mux.HandleFunc("GET /historia", a.history)
|
mux.HandleFunc("GET /historia", a.history)
|
||||||
mux.HandleFunc("GET /ruoat", a.catalog)
|
mux.HandleFunc("GET /ruoat", a.catalog)
|
||||||
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
||||||
|
|||||||
@@ -75,6 +75,228 @@ button, input, select { font: inherit; }
|
|||||||
}
|
}
|
||||||
.tabbar a[aria-current] { color: var(--accent); }
|
.tabbar a[aria-current] { color: var(--accent); }
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.d-liha { background: var(--liha); }
|
||||||
|
.d-kana { background: var(--kana); }
|
||||||
|
.d-kala { background: var(--kala); }
|
||||||
|
.d-kasvis { background: var(--kasvis); }
|
||||||
|
.d-sek {
|
||||||
|
background: conic-gradient(var(--liha) 0 25%, var(--kana) 25% 50%,
|
||||||
|
var(--kala) 50% 75%, var(--kasvis) 75% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day switcher */
|
||||||
|
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
|
||||||
|
.dayseg .seg {
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dayseg .seg[aria-current] {
|
||||||
|
background: var(--ink);
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
.daypick { display: flex; gap: 6px; flex: 1; min-width: 190px; }
|
||||||
|
.daypick input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.daypick button {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tap board */
|
||||||
|
.searchrow { margin-bottom: 14px; }
|
||||||
|
.filter {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--tap);
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
.board { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: var(--tap);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 11px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.022em;
|
||||||
|
}
|
||||||
|
.pill .n {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
|
||||||
|
.pill.lg { font-size: 18px; padding: 12px 16px; }
|
||||||
|
.pill.md { font-size: 15.5px; padding: 11px 14px; }
|
||||||
|
.pill.sm { font-size: 14px; padding: 10px 13px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Sides step */
|
||||||
|
.q { margin: 14px 0 10px; font-size: 13.5px; color: var(--muted); }
|
||||||
|
.chips { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
|
.chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: var(--sunk);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 14.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.chip:has(input:checked) {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--onacc);
|
||||||
|
}
|
||||||
|
.chip:has(input:focus-visible) { outline: 2.5px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Logged card */
|
||||||
|
.logged .k {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.logged .nm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 3px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
.logged .sd { margin: 0 0 16px; font-size: 14.5px; color: var(--muted); }
|
||||||
|
.pair { display: flex; gap: 8px; }
|
||||||
|
.pair form { flex: 1; display: flex; }
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: var(--tap);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--sunk);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn.del { color: var(--liha); }
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--tap);
|
||||||
|
margin-top: 4px;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: var(--tap);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.empty { text-align: center; }
|
||||||
|
.empty p { margin: 0 0 8px; }
|
||||||
|
.empty .primary { margin-top: 12px; line-height: var(--tap); text-decoration: none; }
|
||||||
|
|
||||||
|
/* Historia */
|
||||||
|
.monthrule {
|
||||||
|
margin: 22px 0 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.entry, .gapline {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--tap);
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
|
||||||
|
.entry time, .gapline time {
|
||||||
|
flex: none;
|
||||||
|
min-width: 54px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.entry .nm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
.entry .sd { margin-top: 1px; font-size: 12.5px; color: var(--muted); }
|
||||||
|
.entry .chev {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: none;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.gapline a {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dateLayout is how calendar days are stored and passed around in URLs.
|
||||||
|
const dateLayout = "2006-01-02"
|
||||||
|
|
||||||
|
// categoryFI maps the stored English category onto the Finnish key used for
|
||||||
|
// CSS custom properties and labels.
|
||||||
|
var categoryFI = map[string]string{
|
||||||
|
"meat": "liha",
|
||||||
|
"chicken": "kana",
|
||||||
|
"fish": "kala",
|
||||||
|
"vegetarian": "kasvis",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dish struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
HasSides bool
|
||||||
|
Categories []string
|
||||||
|
TimesEaten int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryKey is the class suffix for the colour dot. A dish covering several
|
||||||
|
// categories (tortillas, build-your-own pizza) gets the mixed marker.
|
||||||
|
func (d Dish) CategoryKey() string {
|
||||||
|
if len(d.Categories) == 1 {
|
||||||
|
return categoryFI[d.Categories[0]]
|
||||||
|
}
|
||||||
|
return "sek"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size buckets the dish by how often it has been eaten. The board draws
|
||||||
|
// favourites as bigger targets, so the likely answer is the easiest to hit.
|
||||||
|
func (d Dish) Size() string {
|
||||||
|
switch {
|
||||||
|
case d.TimesEaten >= 10:
|
||||||
|
return "xl"
|
||||||
|
case d.TimesEaten >= 6:
|
||||||
|
return "lg"
|
||||||
|
case d.TimesEaten >= 3:
|
||||||
|
return "md"
|
||||||
|
default:
|
||||||
|
return "sm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Side struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Date time.Time
|
||||||
|
Main Dish
|
||||||
|
Sides []Side
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Entry) SidesLabel() string {
|
||||||
|
if len(e.Sides) == 0 {
|
||||||
|
return "Ei lisukkeita"
|
||||||
|
}
|
||||||
|
names := make([]string, len(e.Sides))
|
||||||
|
for i, s := range e.Sides {
|
||||||
|
names[i] = s.Name
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// listDishes returns live mains ordered by how often they have been eaten.
|
||||||
|
// An empty search matches everything.
|
||||||
|
func listDishes(db *sql.DB, search string) ([]Dish, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT m.id, m.name, m.has_sides,
|
||||||
|
coalesce((SELECT group_concat(c.category)
|
||||||
|
FROM main_dish_categories c
|
||||||
|
WHERE c.main_dish_id = m.id), ''),
|
||||||
|
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
|
||||||
|
FROM main_dishes m
|
||||||
|
WHERE m.deleted_at IS NULL
|
||||||
|
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%')
|
||||||
|
ORDER BY 5 DESC, m.name`, search, search)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var dishes []Dish
|
||||||
|
for rows.Next() {
|
||||||
|
var d Dish
|
||||||
|
var cats string
|
||||||
|
if err := rows.Scan(&d.ID, &d.Name, &d.HasSides, &cats, &d.TimesEaten); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cats != "" {
|
||||||
|
d.Categories = strings.Split(cats, ",")
|
||||||
|
}
|
||||||
|
dishes = append(dishes, d)
|
||||||
|
}
|
||||||
|
return dishes, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dishByID(db *sql.DB, id int64) (*Dish, error) {
|
||||||
|
var d Dish
|
||||||
|
var cats string
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT m.id, m.name, m.has_sides,
|
||||||
|
coalesce((SELECT group_concat(c.category)
|
||||||
|
FROM main_dish_categories c
|
||||||
|
WHERE c.main_dish_id = m.id), ''),
|
||||||
|
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
|
||||||
|
FROM main_dishes m
|
||||||
|
WHERE m.id = ? AND m.deleted_at IS NULL`, id,
|
||||||
|
).Scan(&d.ID, &d.Name, &d.HasSides, &cats, &d.TimesEaten)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cats != "" {
|
||||||
|
d.Categories = strings.Split(cats, ",")
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSides(db *sql.DB) ([]Side, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sides []Side
|
||||||
|
for rows.Next() {
|
||||||
|
var s Side
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sides = append(sides, s)
|
||||||
|
}
|
||||||
|
return sides, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryFor returns the meal logged on a date, or nil when nothing is. Dishes
|
||||||
|
// are resolved even if they have since been soft-deleted, so history keeps
|
||||||
|
// displaying its names (PRD §6).
|
||||||
|
func entryFor(db *sql.DB, date time.Time) (*Entry, error) {
|
||||||
|
e := &Entry{Date: date}
|
||||||
|
var logID int64
|
||||||
|
var cats string
|
||||||
|
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT l.id, m.id, m.name, m.has_sides,
|
||||||
|
coalesce((SELECT group_concat(c.category)
|
||||||
|
FROM main_dish_categories c
|
||||||
|
WHERE c.main_dish_id = m.id), '')
|
||||||
|
FROM meal_log l
|
||||||
|
JOIN main_dishes m ON m.id = l.main_dish_id
|
||||||
|
WHERE l.date = ?`, date.Format(dateLayout),
|
||||||
|
).Scan(&logID, &e.Main.ID, &e.Main.Name, &e.Main.HasSides, &cats)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cats != "" {
|
||||||
|
e.Main.Categories = strings.Split(cats, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT s.id, s.name
|
||||||
|
FROM meal_log_sides ls
|
||||||
|
JOIN side_dishes s ON s.id = ls.side_dish_id
|
||||||
|
WHERE ls.meal_log_id = ?
|
||||||
|
ORDER BY s.name`, logID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var s Side
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Sides = append(e.Sides, s)
|
||||||
|
}
|
||||||
|
return e, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveEntry records what was eaten, replacing whatever was there. The unique
|
||||||
|
// constraint allows one entry per date, so editing is delete-then-insert; the
|
||||||
|
// old sides go with it through ON DELETE CASCADE.
|
||||||
|
func saveEntry(db *sql.DB, date time.Time, mainID int64, sideIDs []int64) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
day := date.Format(dateLayout)
|
||||||
|
if _, err := tx.Exec(`DELETE FROM meal_log WHERE date = ?`, day); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO meal_log (date, main_dish_id) VALUES (?, ?)`, day, mainID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range sideIDs {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT OR IGNORE INTO meal_log_sides (meal_log_id, side_dish_id) VALUES (?, ?)`,
|
||||||
|
logID, id,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(db *sql.DB, date time.Time) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM meal_log WHERE date = ?`, date.Format(dateLayout))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryRow is one calendar day: either what was eaten or an unfilled gap.
|
||||||
|
type HistoryRow struct {
|
||||||
|
Date time.Time
|
||||||
|
Entry *Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// history walks back day by day from today, so a day nobody wrote down shows
|
||||||
|
// up as an explicit gap rather than silently missing. It stops at the first
|
||||||
|
// entry ever recorded — before that there is no history to be missing.
|
||||||
|
func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) {
|
||||||
|
var first string
|
||||||
|
err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first)
|
||||||
|
if err == sql.ErrNoRows || first == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
firstDate, err := time.ParseInLocation(dateLayout, first, loc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := today(loc)
|
||||||
|
oldest := now.AddDate(0, 0, -days)
|
||||||
|
if firstDate.After(oldest) {
|
||||||
|
oldest = firstDate
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []HistoryRow
|
||||||
|
for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
|
||||||
|
entry, err := entryFor(db, d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows = append(rows, HistoryRow{Date: d, Entry: entry})
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dbHandle wraps a scratch database with lookups the tests need, so a test
|
||||||
|
// reads as "log Lohikeitto" rather than juggling row ids.
|
||||||
|
type dbHandle struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func (h *dbHandle) mainNamed(t *testing.T, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
if err := h.db.QueryRow(
|
||||||
|
`SELECT id FROM main_dishes WHERE name = ?`, name).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("main dish %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *dbHandle) sideNamed(t *testing.T, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
if err := h.db.QueryRow(
|
||||||
|
`SELECT id FROM side_dishes WHERE name = ?`, name).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("side dish %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(s, sub string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
|
||||||
|
}
|
||||||
|
|
||||||
|
// seeded opens a scratch database populated from the committed bundle.
|
||||||
|
func seeded(t *testing.T) *dbHandle {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := openDB(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("openDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
f, err := os.Open("../../seeds/testi.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open seed: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := importBundle(db, f); err != nil {
|
||||||
|
t.Fatalf("import seed: %v", err)
|
||||||
|
}
|
||||||
|
return &dbHandle{db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func day(t *testing.T, s string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
d, err := time.ParseInLocation(dateLayout, s, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bad date %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndReadEntry(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
date := day(t, "2026-09-05")
|
||||||
|
|
||||||
|
if e, err := entryFor(h.db, date); err != nil || e != nil {
|
||||||
|
t.Fatalf("empty day: entry = %v, err = %v; want nil, nil", e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
main := h.mainNamed(t, "Lohikeitto")
|
||||||
|
side := h.sideNamed(t, "Ruisleipä")
|
||||||
|
|
||||||
|
if err := saveEntry(h.db, date, main, []int64{side}); err != nil {
|
||||||
|
t.Fatalf("saveEntry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := entryFor(h.db, date)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
t.Fatalf("entryFor: %v, %v", e, err)
|
||||||
|
}
|
||||||
|
if e.Main.Name != "Lohikeitto" {
|
||||||
|
t.Errorf("main = %q, want Lohikeitto", e.Main.Name)
|
||||||
|
}
|
||||||
|
if e.SidesLabel() != "Ruisleipä" {
|
||||||
|
t.Errorf("sides = %q, want Ruisleipä", e.SidesLabel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveEntryReplacesTheDay(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
date := day(t, "2026-09-05")
|
||||||
|
|
||||||
|
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"),
|
||||||
|
[]int64{h.sideNamed(t, "Ruisleipä")}); err != nil {
|
||||||
|
t.Fatalf("first save: %v", err)
|
||||||
|
}
|
||||||
|
// PRD §6 allows one entry per date, so a second save is an edit.
|
||||||
|
if err := saveEntry(h.db, date, h.mainNamed(t, "Lihapullat"),
|
||||||
|
[]int64{h.sideNamed(t, "Perunamuusi"), h.sideNamed(t, "Vihersalaatti")}); err != nil {
|
||||||
|
t.Fatalf("second save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows int
|
||||||
|
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log WHERE date = ?`,
|
||||||
|
date.Format(dateLayout)).Scan(&rows); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
t.Errorf("%d rows for one date, want 1", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := entryFor(h.db, date)
|
||||||
|
if e.Main.Name != "Lihapullat" {
|
||||||
|
t.Errorf("main = %q, want Lihapullat", e.Main.Name)
|
||||||
|
}
|
||||||
|
if len(e.Sides) != 2 {
|
||||||
|
t.Errorf("%d sides, want 2 (%s)", len(e.Sides), e.SidesLabel())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The replaced entry's sides must go with it, not linger orphaned.
|
||||||
|
var orphans int
|
||||||
|
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log_sides ls
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM meal_log l WHERE l.id = ls.meal_log_id)`,
|
||||||
|
).Scan(&orphans); err != nil {
|
||||||
|
t.Fatalf("orphan check: %v", err)
|
||||||
|
}
|
||||||
|
if orphans != 0 {
|
||||||
|
t.Errorf("%d orphaned sides after replace", orphans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteEntry(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
date := day(t, "2026-09-05")
|
||||||
|
|
||||||
|
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
if err := deleteEntry(h.db, date); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if e, _ := entryFor(h.db, date); e != nil {
|
||||||
|
t.Errorf("entry survived delete: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDishesOrdersByTimesEaten(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
lihapullat := h.mainNamed(t, "Lihapullat")
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
d := day(t, fmt.Sprintf("2026-09-%02d", i))
|
||||||
|
if err := saveEntry(h.db, d, lihapullat, nil); err != nil {
|
||||||
|
t.Fatalf("save %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dishes, err := listDishes(h.db, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listDishes: %v", err)
|
||||||
|
}
|
||||||
|
if len(dishes) == 0 {
|
||||||
|
t.Fatal("no dishes")
|
||||||
|
}
|
||||||
|
if dishes[0].Name != "Lihapullat" {
|
||||||
|
t.Errorf("first dish = %q, want the most-eaten Lihapullat", dishes[0].Name)
|
||||||
|
}
|
||||||
|
if dishes[0].TimesEaten != 3 {
|
||||||
|
t.Errorf("timesEaten = %d, want 3", dishes[0].TimesEaten)
|
||||||
|
}
|
||||||
|
if got := dishes[0].Size(); got != "md" {
|
||||||
|
t.Errorf("size for 3 = %q, want md", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDishesSearchIsCaseInsensitive(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
dishes, err := listDishes(h.db, "KEITTO")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listDishes: %v", err)
|
||||||
|
}
|
||||||
|
if len(dishes) == 0 {
|
||||||
|
t.Fatal("no matches for KEITTO, want the soups")
|
||||||
|
}
|
||||||
|
for _, d := range dishes {
|
||||||
|
if !containsFold(d.Name, "keitto") {
|
||||||
|
t.Errorf("%q does not match the search", d.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiCategoryDishUsesMixedMarker(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
tortillat, err := dishByID(h.db, h.mainNamed(t, "Tortillat"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dishByID: %v", err)
|
||||||
|
}
|
||||||
|
if len(tortillat.Categories) != 4 {
|
||||||
|
t.Fatalf("%d categories, want 4", len(tortillat.Categories))
|
||||||
|
}
|
||||||
|
if got := tortillat.CategoryKey(); got != "sek" {
|
||||||
|
t.Errorf("CategoryKey = %q, want sek", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
lohi, err := dishByID(h.db, h.mainNamed(t, "Lohikeitto"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dishByID: %v", err)
|
||||||
|
}
|
||||||
|
if got := lohi.CategoryKey(); got != "kala" {
|
||||||
|
t.Errorf("CategoryKey = %q, want kala", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
loc := time.UTC
|
||||||
|
now := today(loc)
|
||||||
|
|
||||||
|
// Log today and three days ago, leaving two gaps between them.
|
||||||
|
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
|
||||||
|
t.Fatalf("save today: %v", err)
|
||||||
|
}
|
||||||
|
if err := saveEntry(h.db, now.AddDate(0, 0, -3), h.mainNamed(t, "Lihapullat"), nil); err != nil {
|
||||||
|
t.Fatalf("save -3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := history(h.db, loc, 60)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
// Walks back to the oldest entry only: today, -1, -2, -3.
|
||||||
|
if len(rows) != 4 {
|
||||||
|
t.Fatalf("%d rows, want 4", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Entry == nil || rows[0].Entry.Main.Name != "Lohikeitto" {
|
||||||
|
t.Errorf("first row should be today's Lohikeitto, got %+v", rows[0].Entry)
|
||||||
|
}
|
||||||
|
if rows[1].Entry != nil || rows[2].Entry != nil {
|
||||||
|
t.Error("the two unlogged days should be gaps")
|
||||||
|
}
|
||||||
|
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
|
||||||
|
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
rows, err := history(h.db, time.UTC, 60)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Errorf("%d rows for an empty log, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
-7
@@ -2,15 +2,19 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Finnish weekday names. Go formats dates in English only, and this app has
|
// Finnish weekday and month names. Go formats dates in English only, and this
|
||||||
// exactly one locale (PRD §5).
|
// app has exactly one locale (PRD §5).
|
||||||
var (
|
var (
|
||||||
weekdaysFI = [...]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko",
|
weekdaysFI = [...]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko",
|
||||||
"torstai", "perjantai", "lauantai"}
|
"torstai", "perjantai", "lauantai"}
|
||||||
weekdayAbbrFI = [...]string{"su", "ma", "ti", "ke", "to", "pe", "la"}
|
weekdayAbbrFI = [...]string{"su", "ma", "ti", "ke", "to", "pe", "la"}
|
||||||
|
monthsFI = [...]string{"", "tammikuu", "helmikuu", "maaliskuu", "huhtikuu",
|
||||||
|
"toukokuu", "kesäkuu", "heinäkuu", "elokuu", "syyskuu", "lokakuu",
|
||||||
|
"marraskuu", "joulukuu"}
|
||||||
)
|
)
|
||||||
|
|
||||||
func longDateFI(t time.Time) string {
|
func longDateFI(t time.Time) string {
|
||||||
@@ -21,6 +25,31 @@ func dayLabelFI(t time.Time) string {
|
|||||||
return weekdayAbbrFI[t.Weekday()] + " " + t.Format("2.1.")
|
return weekdayAbbrFI[t.Weekday()] + " " + t.Format("2.1.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func monthFI(t time.Time) string {
|
||||||
|
return monthsFI[int(t.Month())]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isoDate(t time.Time) string {
|
||||||
|
return t.Format(dateLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayURL links to a specific day, leaving today as the bare path.
|
||||||
|
func dayURL(base string, d, now time.Time) string {
|
||||||
|
if d.Equal(now) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "?pvm=" + isoDate(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
|
||||||
|
// any day but today.
|
||||||
|
func pickSeparator(v logView) string {
|
||||||
|
if v.Date.Equal(v.Today) {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return "&"
|
||||||
|
}
|
||||||
|
|
||||||
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
|
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
|
||||||
// after every number except one.
|
// after every number except one.
|
||||||
func countFI(n int, one, many string) string {
|
func countFI(n int, one, many string) string {
|
||||||
@@ -64,29 +93,182 @@ templ tab(href, label, current string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ indexPage(now time.Time) {
|
// ---------------------------------------------------------------- Kirjaa
|
||||||
|
templ logPage(v logView) {
|
||||||
@page("Foodster", "/") {
|
@page("Foodster", "/") {
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<h2>Mitä syötiin?</h2>
|
<h2>Mitä syötiin?</h2>
|
||||||
<p class="meta">{ longDateFI(now) }</p>
|
<p class="meta">{ longDateFI(v.Date) }</p>
|
||||||
|
@daySwitch(v)
|
||||||
</header>
|
</header>
|
||||||
<main class="pad">
|
<main class="pad">
|
||||||
<p class="muted">Ruokien kirjaus tulee tähän.</p>
|
switch {
|
||||||
|
case v.Chosen != nil:
|
||||||
|
@sidesStep(v)
|
||||||
|
case v.Entry != nil:
|
||||||
|
@loggedCard(v)
|
||||||
|
default:
|
||||||
|
@board(v)
|
||||||
|
}
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ historyPage() {
|
templ daySwitch(v logView) {
|
||||||
|
<div class="dayseg">
|
||||||
|
@dayButton("Tänään", v.Today, v.Date, v.Today)
|
||||||
|
@dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
|
||||||
|
<form method="get" action="/" class="daypick">
|
||||||
|
<input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/>
|
||||||
|
<button type="submit">Näytä</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ dayButton(label string, target, selected, now time.Time) {
|
||||||
|
if target.Equal(selected) {
|
||||||
|
<a class="seg" aria-current="true" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
|
||||||
|
} else {
|
||||||
|
<a class="seg" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"/>
|
||||||
|
</form>
|
||||||
|
if len(v.Dishes) == 0 {
|
||||||
|
<p class="muted">Ei osumia haulle { v.Search }.</p>
|
||||||
|
}
|
||||||
|
<div class="board">
|
||||||
|
for _, d := range v.Dishes {
|
||||||
|
@dishPill(d, v)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ dishPill(d Dish, v logView) {
|
||||||
|
<a
|
||||||
|
class={ "pill", d.Size() }
|
||||||
|
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
|
||||||
|
>
|
||||||
|
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
||||||
|
{ d.Name }
|
||||||
|
if d.TimesEaten > 0 {
|
||||||
|
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ sidesStep(v logView) {
|
||||||
|
<section class="card">
|
||||||
|
<h3>
|
||||||
|
<i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i>
|
||||||
|
{ v.Chosen.Name }
|
||||||
|
</h3>
|
||||||
|
<form method="post" action="/kirjaa">
|
||||||
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
|
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
|
||||||
|
if v.Chosen.HasSides && len(v.Sides) > 0 {
|
||||||
|
<p class="q">Lisukkeita?</p>
|
||||||
|
<div class="chips">
|
||||||
|
for _, s := range v.Sides {
|
||||||
|
<label class="chip">
|
||||||
|
if v.Checked[s.ID] {
|
||||||
|
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) } checked/>
|
||||||
|
} else {
|
||||||
|
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) }/>
|
||||||
|
}
|
||||||
|
<span>{ s.Name }</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<p class="q">Tarjoillaan sellaisenaan.</p>
|
||||||
|
}
|
||||||
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
|
</form>
|
||||||
|
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ loggedCard(v logView) {
|
||||||
|
<section class="card logged">
|
||||||
|
<p class="k">
|
||||||
|
if v.Date.Equal(v.Today) {
|
||||||
|
Tänään kirjattu
|
||||||
|
} else {
|
||||||
|
{ dayLabelFI(v.Date) } kirjattu
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="nm">
|
||||||
|
<i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i>
|
||||||
|
{ v.Entry.Main.Name }
|
||||||
|
</p>
|
||||||
|
<p class="sd">{ v.Entry.SidesLabel() }</p>
|
||||||
|
<div class="pair">
|
||||||
|
<a
|
||||||
|
class="btn"
|
||||||
|
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
|
||||||
|
>Muokkaa</a>
|
||||||
|
<form method="post" action="/poista">
|
||||||
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
|
<button class="btn del" type="submit">Poista</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Historia
|
||||||
|
templ historyPage(rows []HistoryRow) {
|
||||||
@page("Historia — Foodster", "/historia") {
|
@page("Historia — Foodster", "/historia") {
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<h2>Historia</h2>
|
<h2>Historia</h2>
|
||||||
</header>
|
</header>
|
||||||
<main class="pad">
|
<main class="pad">
|
||||||
<p class="muted">Merkinnät tulevat tähän.</p>
|
if len(rows) == 0 {
|
||||||
|
<p class="muted">Ei vielä merkintöjä.</p>
|
||||||
|
}
|
||||||
|
for i, row := range rows {
|
||||||
|
if i == 0 || rows[i-1].Date.Month() != row.Date.Month() {
|
||||||
|
<p class="monthrule">{ monthFI(row.Date) }</p>
|
||||||
|
}
|
||||||
|
if row.Entry != nil {
|
||||||
|
<div class="entry">
|
||||||
|
<time>{ dayLabelFI(row.Date) }</time>
|
||||||
|
<div>
|
||||||
|
<div class="nm">
|
||||||
|
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
|
||||||
|
{ row.Entry.Main.Name }
|
||||||
|
</div>
|
||||||
|
<div class="sd">{ row.Entry.SidesLabel() }</div>
|
||||||
|
</div>
|
||||||
|
<a class="chev" href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) } aria-label="Muokkaa">›</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="gapline">
|
||||||
|
<time>{ dayLabelFI(row.Date) }</time>
|
||||||
|
<span>Ei merkintää</span>
|
||||||
|
<a href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) }>Merkitse</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Ruoat
|
||||||
templ catalogPage(mains, sides int, report *ImportReport) {
|
templ catalogPage(mains, sides int, report *ImportReport) {
|
||||||
@page("Ruoat — Foodster", "/ruoat") {
|
@page("Ruoat — Foodster", "/ruoat") {
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
|
|||||||
@@ -79,6 +79,42 @@ check "empty submit is explained" \
|
|||||||
check "malformed JSON is explained" \
|
check "malformed JSON is explained" \
|
||||||
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa"
|
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa"
|
||||||
|
|
||||||
|
# ---- the log flow, against the dishes imported above --------------------
|
||||||
|
|
||||||
|
board=$(curl -s -u ":$pass" "http://$addr/")
|
||||||
|
check "board lists imported dishes" "$board" "Lihapullat"
|
||||||
|
|
||||||
|
# Pull a real dish id out of the board rather than assuming one.
|
||||||
|
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
|
||||||
|
if [ -z "$ruoka" ]; then
|
||||||
|
echo " FAIL could not find a dish link on the board"
|
||||||
|
fail=1
|
||||||
|
ruoka=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check "picking a dish opens the sides step" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/?ruoka=$ruoka")" "Tallenna"
|
||||||
|
|
||||||
|
check "saving redirects back to the day" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||||
|
-d "pvm=2026-09-05&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
|
||||||
|
|
||||||
|
check "the saved day shows what was eaten" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
||||||
|
|
||||||
|
check "history lists the entry" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu"
|
||||||
|
|
||||||
|
check "deleting redirects back" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||||
|
-d "pvm=2026-09-05" "http://$addr/poista")" "303"
|
||||||
|
|
||||||
|
check "the day is empty again" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "Etsi"
|
||||||
|
|
||||||
|
check "search filters the board" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
||||||
|
|
||||||
if [ "$fail" -ne 0 ]; then
|
if [ "$fail" -ne 0 ]; then
|
||||||
echo "smoke: FAILED"
|
echo "smoke: FAILED"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user