Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174652778b | ||
|
|
e9754488db |
@@ -4,6 +4,9 @@
|
|||||||
# Build output
|
# Build output
|
||||||
/foodster
|
/foodster
|
||||||
|
|
||||||
|
# The tag `make image` last built, handed to `make push`.
|
||||||
|
/.release-tag
|
||||||
|
|
||||||
# Generated by `templ generate` during the container build.
|
# Generated by `templ generate` during the container build.
|
||||||
*_templ.go
|
*_templ.go
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ BIN := foodster
|
|||||||
PKG := ./cmd/foodster
|
PKG := ./cmd/foodster
|
||||||
STATIC := cmd/foodster/static
|
STATIC := cmd/foodster/static
|
||||||
|
|
||||||
|
# What `make image` last built. push reads it rather than re-deriving the tag:
|
||||||
|
# sorting tags by date is ambiguous when two point at the same commit, and
|
||||||
|
# re-deriving is what let a parallel make push the wrong one.
|
||||||
|
TAGFILE := .release-tag
|
||||||
|
|
||||||
# Vendored Datastar client. Bump, run `make vendor`, commit the result.
|
# Vendored Datastar client. Bump, run `make vendor`, commit the result.
|
||||||
DATASTAR_VERSION ?= v1.0.3
|
DATASTAR_VERSION ?= v1.0.3
|
||||||
SEED ?= seeds/testi.json
|
SEED ?= seeds/testi.json
|
||||||
@@ -94,16 +99,41 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
|
|||||||
echo "==> $$tag"; \
|
echo "==> $$tag"; \
|
||||||
git tag "$$tag"; \
|
git tag "$$tag"; \
|
||||||
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
|
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
|
||||||
-t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" .
|
-t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . ; \
|
||||||
|
echo "$$tag" > $(TAGFILE)
|
||||||
|
|
||||||
push: ## Push the newest tag and :latest
|
# Pushing reported success while uploading the previous release once, because
|
||||||
|
# nothing compared what was built against what arrived. So afterwards, ask the
|
||||||
|
# registry what it actually serves for each tag and fail if it is not the
|
||||||
|
# image we just built.
|
||||||
|
push: ## Push the newest tag and :latest, then verify the registry
|
||||||
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
|
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
|
||||||
@tag=$$(git tag -l 'v*' --sort=-creatordate | head -n1); \
|
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
|
||||||
test -n "$$tag" || { echo "no tags yet - run make image"; exit 1; }; \
|
tag=$$(cat $(TAGFILE)); \
|
||||||
|
built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
|
||||||
|
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \
|
||||||
podman push "$(FOODSTER_REPO):$$tag"; \
|
podman push "$(FOODSTER_REPO):$$tag"; \
|
||||||
podman push "$(FOODSTER_REPO):latest"
|
podman push "$(FOODSTER_REPO):latest"; \
|
||||||
|
echo "==> verifying $$tag"; \
|
||||||
|
for ref in "$$tag" latest; do \
|
||||||
|
podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \
|
||||||
|
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \
|
||||||
|
served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \
|
||||||
|
if [ "$$served" != "$$built" ]; then \
|
||||||
|
echo " FAIL $$ref serves $$served"; \
|
||||||
|
echo " expected $$built"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo " ok $$ref"; \
|
||||||
|
done
|
||||||
|
|
||||||
release: image push ## Build, tag and push in one go
|
# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on
|
||||||
|
# at least one machine here — these run concurrently, so push resolves the
|
||||||
|
# newest tag and uploads :latest before image has finished building and
|
||||||
|
# tagging. That silently ships the previous release a second time.
|
||||||
|
release: ## Build, tag and push in one go
|
||||||
|
@$(MAKE) --no-print-directory image
|
||||||
|
@$(MAKE) --no-print-directory push
|
||||||
|
|
||||||
up: ## Start the stack
|
up: ## Start the stack
|
||||||
@mkdir -p data # or the engine creates it root-owned and the app cannot write
|
@mkdir -p data # or the engine creates it root-owned and the app cannot write
|
||||||
|
|||||||
@@ -113,6 +113,23 @@ in English.
|
|||||||
Side dishes live in their own table and have no category. The pool is
|
Side dishes live in their own table and have no category. The pool is
|
||||||
expected to stay small.
|
expected to stay small.
|
||||||
|
|
||||||
|
### Tähteet — leftovers (stage 1)
|
||||||
|
|
||||||
|
A single built-in entry, flagged `special` on the main dish table. It is
|
||||||
|
**not food**: it exists so a day can be recorded as "we ate what was already
|
||||||
|
there" without inventing a meal that was never cooked.
|
||||||
|
|
||||||
|
- No category, which is why it cannot be an ordinary main: those must have
|
||||||
|
at least one.
|
||||||
|
- Created by a migration. The household does not add, edit or delete it, and
|
||||||
|
it never appears in the Ruuat catalog.
|
||||||
|
- Loggable exactly like any other entry, and shown on the log board apart
|
||||||
|
from the categories.
|
||||||
|
- **The stage 2 suggester must never propose it.** It is excluded from the
|
||||||
|
eligible pool outright, so cooldown, category coverage (§8.1) and
|
||||||
|
frequency weighting (§8.2) all skip it — despite it being among the
|
||||||
|
most-logged entries.
|
||||||
|
|
||||||
### Meal log entry ("what was actually eaten") (stage 1)
|
### Meal log entry ("what was actually eaten") (stage 1)
|
||||||
- `id`
|
- `id`
|
||||||
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field
|
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field
|
||||||
|
|||||||
@@ -131,6 +131,55 @@ func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTahteetIsLoggableButNotFood(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
// The migration creates it; nobody adds it.
|
||||||
|
special, err := listSpecial(h.db, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listSpecial: %v", err)
|
||||||
|
}
|
||||||
|
if len(special) != 1 || special[0].Name != "Tähteet" {
|
||||||
|
t.Fatalf("special = %+v, want exactly Tähteet", special)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It must not turn up among the dishes: not on the board's categories,
|
||||||
|
// not in the catalog, and not in whatever the suggester later draws from.
|
||||||
|
dishes, err := listDishes(h.db, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listDishes: %v", err)
|
||||||
|
}
|
||||||
|
for _, d := range dishes {
|
||||||
|
if d.Name == "Tähteet" {
|
||||||
|
t.Fatal("Tähteet appears among the dishes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It carries no category at all, which is why it cannot be an ordinary
|
||||||
|
// dish: those are required to have one.
|
||||||
|
if len(special[0].Categories) != 0 {
|
||||||
|
t.Errorf("categories = %v, want none", special[0].Categories)
|
||||||
|
}
|
||||||
|
// And it gets its own mark: no categories is not the same as several, so
|
||||||
|
// it must not fall through to the mixed Sekalaiset one.
|
||||||
|
if got := special[0].CategoryKey(); got != "tahteet" {
|
||||||
|
t.Errorf("CategoryKey = %q, want tahteet", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging it has to work exactly like logging a real meal.
|
||||||
|
date := day(t, "2026-09-05")
|
||||||
|
if err := saveEntry(h.db, date, special[0].ID, nil); err != nil {
|
||||||
|
t.Fatalf("saveEntry: %v", err)
|
||||||
|
}
|
||||||
|
entry, err := entryFor(h.db, date)
|
||||||
|
if err != nil || entry == nil {
|
||||||
|
t.Fatalf("entryFor: %v, %v", entry, err)
|
||||||
|
}
|
||||||
|
if entry.Main.Name != "Tähteet" {
|
||||||
|
t.Errorf("logged %q, want Tähteet", entry.Main.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
|
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
|
||||||
h := seeded(t)
|
h := seeded(t)
|
||||||
id := h.sideNamed(t, "Riisi")
|
id := h.sideNamed(t, "Riisi")
|
||||||
@@ -138,7 +187,7 @@ func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
|
|||||||
if err := softDeleteSide(h.db, id); err != nil {
|
if err := softDeleteSide(h.db, id); err != nil {
|
||||||
t.Fatalf("softDeleteSide: %v", err)
|
t.Fatalf("softDeleteSide: %v", err)
|
||||||
}
|
}
|
||||||
sides, err := listSides(h.db)
|
sides, err := listSides(h.db, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("listSides: %v", err)
|
t.Fatalf("listSides: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+159
-31
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -17,11 +18,27 @@ 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 history under the logger walks.
|
const (
|
||||||
//
|
// historyDays is one window of the history under the logger, and the step
|
||||||
// ponytail: a fixed window. After a year of daily entries this list is the
|
// that "show more" grows it by. Older days arrive a window at a time
|
||||||
// thing that needs paging; load more on scroll when it actually hurts.
|
// rather than all at once.
|
||||||
const historyDays = 60
|
historyDays = 30
|
||||||
|
|
||||||
|
// maxHistoryDays caps what a hand-edited URL can ask for, so ?paivat=
|
||||||
|
// cannot be turned into a request to render a decade of rows.
|
||||||
|
maxHistoryDays = 366 * 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyWindow reads ?paivat=, the number of days of history to show.
|
||||||
|
func historyWindow(r *http.Request) int {
|
||||||
|
days := historyDays
|
||||||
|
if raw := r.URL.Query().Get("paivat"); raw != "" {
|
||||||
|
if n, err := strconv.Atoi(raw); err == nil && n > days {
|
||||||
|
days = min(n, maxHistoryDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -60,18 +77,21 @@ func (a *app) date(r *http.Request) time.Time {
|
|||||||
// page: every history row was already a link back into the logger, and the
|
// page: every history row was already a link back into the logger, and the
|
||||||
// day switcher made them two views of the same thing.
|
// day switcher made them two views of the same thing.
|
||||||
type logView struct {
|
type logView struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
Today time.Time
|
Today time.Time
|
||||||
Search string
|
Search string
|
||||||
Entry *Entry // what is already logged for Date, if anything
|
Entry *Entry // what is already logged for Date, if anything
|
||||||
Chosen *Dish // dish picked, so the sides step is showing
|
Chosen *Dish // dish picked, so the sides step is showing
|
||||||
Checked map[int64]bool // sides ticked in that step
|
Checked map[int64]bool // sides ticked in that step
|
||||||
ShowBoard bool
|
ShowBoard bool
|
||||||
Dishes []Dish // flat, only to know whether anything matched
|
Dishes []Dish // flat, only to know whether anything matched
|
||||||
Groups []DishGroup // what the board actually renders
|
Groups []DishGroup // what the board actually renders
|
||||||
Sides []Side
|
Special []Dish // Tähteet and the like: loggable, but not food
|
||||||
New mainForm // inline "add the dish you were looking for"
|
Sides []Side
|
||||||
History []HistoryRow
|
New mainForm // inline "add the dish you were looking for"
|
||||||
|
History HistoryPage
|
||||||
|
HistoryDays int // size of the window currently shown
|
||||||
|
HistoryMore int // the window size the "show more" link asks for
|
||||||
|
|
||||||
// Deleting a logged meal drops the row outright, unlike a dish which is
|
// Deleting a logged meal drops the row outright, unlike a dish which is
|
||||||
// only soft-deleted, so it asks first.
|
// only soft-deleted, so it asks first.
|
||||||
@@ -123,21 +143,126 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
// listDishes already orders by frequency then name, so grouping keeps
|
// listDishes already orders by frequency then name, so grouping keeps
|
||||||
// the favourites at the top of each category.
|
// the favourites at the top of each category.
|
||||||
v.Groups = groupDishes(v.Dishes)
|
v.Groups = groupDishes(v.Dishes)
|
||||||
|
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
||||||
|
log.Printf("list special: %v", err)
|
||||||
|
}
|
||||||
// Seed the inline add form with whatever was searched for, so a miss
|
// Seed the inline add form with whatever was searched for, so a miss
|
||||||
// turns straight into "add it" without retyping.
|
// turns straight into "add it" without retyping.
|
||||||
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
||||||
}
|
}
|
||||||
if v.Chosen != nil && v.Chosen.HasSides {
|
if v.Chosen != nil && v.Chosen.HasSides {
|
||||||
if v.Sides, err = listSides(a.db); err != nil {
|
if v.Sides, err = listSides(a.db, ""); err != nil {
|
||||||
log.Printf("list sides: %v", err)
|
log.Printf("list sides: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
|
a.loadDays(r, &v)
|
||||||
|
render(w, r, logPage(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDays fills the day list. The selected day expands inside it rather than
|
||||||
|
// in a panel above it, so choosing a day from the list does not reorder the
|
||||||
|
// list underneath the tap.
|
||||||
|
func (a *app) loadDays(r *http.Request, v *logView) {
|
||||||
|
v.HistoryDays = historyWindow(r)
|
||||||
|
|
||||||
|
// The window has to reach the selected day, or it would have nowhere to
|
||||||
|
// expand.
|
||||||
|
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
|
||||||
|
v.HistoryDays = min(reach, maxHistoryDays)
|
||||||
|
}
|
||||||
|
v.HistoryMore = v.HistoryDays + historyDays
|
||||||
|
|
||||||
|
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
|
||||||
|
if err != nil {
|
||||||
log.Printf("history: %v", err)
|
log.Printf("history: %v", err)
|
||||||
}
|
}
|
||||||
|
// Nothing logged ever: the selected day is still the one being worked on,
|
||||||
|
// so it needs a row of its own to open in.
|
||||||
|
if len(page.Rows) == 0 {
|
||||||
|
page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}}
|
||||||
|
}
|
||||||
|
v.History = page
|
||||||
|
}
|
||||||
|
|
||||||
render(w, r, logPage(v))
|
// searchSignals is what Datastar sends back: for a GET it JSON-encodes the
|
||||||
|
// signals into the `datastar` query parameter.
|
||||||
|
type searchSignals struct {
|
||||||
|
Haku string `json:"haku"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSignals decodes that parameter.
|
||||||
|
//
|
||||||
|
// ponytail: the Datastar SDK does this too, but pulling it in for one JSON
|
||||||
|
// decode dragged along four modules — an HTTP compression stack among them —
|
||||||
|
// for an SSE generator this app never uses. Absent or empty is not an error:
|
||||||
|
// the first request carries no signals.
|
||||||
|
func readSignals(r *http.Request, into any) error {
|
||||||
|
raw := r.URL.Query().Get("datastar")
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal([]byte(raw), into)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fragment renders a piece of a page for Datastar to patch in. A plain
|
||||||
|
// text/html response is enough — Datastar matches the returned element by its
|
||||||
|
// id and replaces it, so there is no SSE stream to manage.
|
||||||
|
func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := c.Render(r.Context(), w); err != nil {
|
||||||
|
log.Printf("fragment %s: %v", r.URL.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchBoard re-renders the dish board as the search box is typed into.
|
||||||
|
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var signals searchSignals
|
||||||
|
if err := readSignals(r, &signals); err != nil {
|
||||||
|
http.Error(w, "bad signals", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := logView{
|
||||||
|
Date: a.date(r),
|
||||||
|
Today: today(a.loc),
|
||||||
|
Search: strings.TrimSpace(signals.Haku),
|
||||||
|
}
|
||||||
|
dishes, err := listDishes(a.db, v.Search)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("search dishes: %v", err)
|
||||||
|
}
|
||||||
|
v.Dishes = dishes
|
||||||
|
v.Groups = groupDishes(dishes)
|
||||||
|
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
||||||
|
log.Printf("search special: %v", err)
|
||||||
|
}
|
||||||
|
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
||||||
|
|
||||||
|
fragment(w, r, boardList(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchCatalog re-renders the catalog lists as the search box is typed into.
|
||||||
|
func (a *app) searchCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var signals searchSignals
|
||||||
|
if err := readSignals(r, &signals); err != nil {
|
||||||
|
http.Error(w, "bad signals", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := catalogView{Search: strings.TrimSpace(signals.Haku)}
|
||||||
|
mains, err := listDishes(a.db, v.Search)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("search catalog: %v", err)
|
||||||
|
}
|
||||||
|
v.Mains = len(mains)
|
||||||
|
sortByName(mains)
|
||||||
|
v.Groups = groupDishes(mains)
|
||||||
|
if v.Sides, err = listSides(a.db, v.Search); err != nil {
|
||||||
|
log.Printf("search sides: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment(w, r, catalogList(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
// quickAdd creates a dish from the Kirjaa screen and goes straight on to
|
// quickAdd creates a dish from the Kirjaa screen and goes straight on to
|
||||||
@@ -195,9 +320,10 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("list dishes: %v", err)
|
log.Printf("list dishes: %v", err)
|
||||||
}
|
}
|
||||||
v.Groups = groupDishes(v.Dishes)
|
v.Groups = groupDishes(v.Dishes)
|
||||||
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
|
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
||||||
log.Printf("history: %v", err)
|
log.Printf("list special: %v", err)
|
||||||
}
|
}
|
||||||
|
a.loadDays(r, &v)
|
||||||
render(w, r, logPage(v))
|
render(w, r, logPage(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +333,9 @@ func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.T
|
|||||||
if strings.Contains(target, "?") {
|
if strings.Contains(target, "?") {
|
||||||
sep = "&"
|
sep = "&"
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
http.Redirect(w, r,
|
||||||
|
target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date),
|
||||||
|
http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// save records the meal and redirects, so a refresh cannot double-post.
|
// save records the meal and redirects, so a refresh cannot double-post.
|
||||||
@@ -245,12 +373,10 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.redirectToDay(w, r, date)
|
a.redirectToDay(w, r, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redirectToDay returns to the day in the list, anchor included, so saving or
|
||||||
|
// deleting leaves the viewport where the work was happening.
|
||||||
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
||||||
target := "/"
|
http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther)
|
||||||
if !date.Equal(today(a.loc)) {
|
|
||||||
target += "?pvm=" + date.Format(dateLayout)
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainForm and sideForm carry what the user typed, so a rejected submission
|
// mainForm and sideForm carry what the user typed, so a rejected submission
|
||||||
@@ -276,6 +402,7 @@ type catalogView struct {
|
|||||||
Side sideForm
|
Side sideForm
|
||||||
Report *ImportReport
|
Report *ImportReport
|
||||||
Mains int // count, for the header
|
Mains int // count, for the header
|
||||||
|
Search string
|
||||||
|
|
||||||
// The row awaiting a delete confirmation, if any. A trash icon is easy to
|
// The row awaiting a delete confirmation, if any. A trash icon is easy to
|
||||||
// hit by accident, so the row asks before anything happens.
|
// hit by accident, so the row asks before anything happens.
|
||||||
@@ -285,7 +412,8 @@ type catalogView struct {
|
|||||||
|
|
||||||
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
||||||
v := catalogView{
|
v := catalogView{
|
||||||
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
|
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
|
||||||
|
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ?muokkaa= loads a dish into its form; the same form adds and edits.
|
// ?muokkaa= loads a dish into its form; the same form adds and edits.
|
||||||
@@ -326,7 +454,7 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
|
|||||||
v.Main.Categories = map[string]bool{}
|
v.Main.Categories = map[string]bool{}
|
||||||
}
|
}
|
||||||
|
|
||||||
mains, err := listDishes(a.db, "")
|
mains, err := listDishes(a.db, v.Search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("list mains: %v", err)
|
log.Printf("list mains: %v", err)
|
||||||
}
|
}
|
||||||
@@ -334,7 +462,7 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
|
|||||||
sortByName(mains) // the catalog is managed, so position should be predictable
|
sortByName(mains) // the catalog is managed, so position should be predictable
|
||||||
v.Groups = groupDishes(mains)
|
v.Groups = groupDishes(mains)
|
||||||
|
|
||||||
if v.Sides, err = listSides(a.db); err != nil {
|
if v.Sides, err = listSides(a.db, v.Search); err != nil {
|
||||||
log.Printf("list sides: %v", err)
|
log.Printf("list sides: %v", err)
|
||||||
}
|
}
|
||||||
render(w, r, catalogPage(v))
|
render(w, r, catalogPage(v))
|
||||||
|
|||||||
@@ -158,8 +158,10 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
|||||||
mux.HandleFunc("GET /{$}", a.index)
|
mux.HandleFunc("GET /{$}", a.index)
|
||||||
mux.HandleFunc("POST /kirjaa", a.save)
|
mux.HandleFunc("POST /kirjaa", a.save)
|
||||||
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
||||||
|
mux.HandleFunc("GET /etsi", a.searchBoard)
|
||||||
mux.HandleFunc("POST /poista", a.delete)
|
mux.HandleFunc("POST /poista", a.delete)
|
||||||
mux.HandleFunc("GET /ruuat", a.catalog)
|
mux.HandleFunc("GET /ruuat", a.catalog)
|
||||||
|
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
|
||||||
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
|
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
|
||||||
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
|
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
|
||||||
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -75,6 +76,35 @@ func TestDateRejectsTheFuture(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadSignals(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"a signal", `/etsi?datastar=` + url.QueryEscape(`{"haku":"keitto"}`), "keitto", false},
|
||||||
|
{"other signals are ignored", `/etsi?datastar=` + url.QueryEscape(`{"haku":"kala","muu":1}`), "kala", false},
|
||||||
|
// The first request carries no signals at all; that is not a failure.
|
||||||
|
{"no parameter", "/etsi", "", false},
|
||||||
|
{"empty parameter", "/etsi?datastar=", "", false},
|
||||||
|
{"malformed json", "/etsi?datastar=%7Bnope", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
var got searchSignals
|
||||||
|
err := readSignals(httptest.NewRequest(http.MethodGet, c.query, nil), &got)
|
||||||
|
if (err != nil) != c.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
|
||||||
|
}
|
||||||
|
if got.Haku != c.want {
|
||||||
|
t.Errorf("haku = %q, want %q", got.Haku, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigrateCreatesSchema(t *testing.T) {
|
func TestMigrateCreatesSchema(t *testing.T) {
|
||||||
db, err := openDB(t.TempDir() + "/test.db")
|
db, err := openDB(t.TempDir() + "/test.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,14 +246,16 @@ func TestMealLogOneEntryPerDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil {
|
// Ids well clear of anything the migrations create.
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO main_dishes (id, name) VALUES (101, 'Lohikeitto'), (102, 'Lihapullat')`); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil {
|
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); err != nil {
|
||||||
t.Fatalf("first entry: %v", err)
|
t.Fatalf("first entry: %v", err)
|
||||||
}
|
}
|
||||||
// PRD §6: a second dinner for the same day must be refused.
|
// PRD §6: a second dinner for the same day must be refused.
|
||||||
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil {
|
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 102)`); err == nil {
|
||||||
t.Error("second entry for the same date was accepted, want a unique violation")
|
t.Error("second entry for the same date was accepted, want a unique violation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,18 +267,18 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil {
|
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (101, 'Kanacurry')`); err != nil {
|
||||||
t.Fatalf("first insert: %v", err)
|
t.Fatalf("first insert: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil {
|
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err == nil {
|
||||||
t.Error("case-variant duplicate was accepted, want a unique violation")
|
t.Error("case-variant duplicate was accepted, want a unique violation")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft-deleting the original frees the name again (PRD §7.3).
|
// Soft-deleting the original frees the name again (PRD §7.3).
|
||||||
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
|
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 101`); err != nil {
|
||||||
t.Fatalf("soft delete: %v", err)
|
t.Fatalf("soft delete: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil {
|
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err != nil {
|
||||||
t.Errorf("name still blocked after soft delete: %v", err)
|
t.Errorf("name still blocked after soft delete: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Tähteet: leftovers.
|
||||||
|
--
|
||||||
|
-- Not a dish. It exists so a day can be recorded as "we ate what was already
|
||||||
|
-- there" without inventing a meal that was never cooked. It has no category,
|
||||||
|
-- it is not something the household adds or edits, and the stage 2 suggester
|
||||||
|
-- must never propose it (PRD §8).
|
||||||
|
--
|
||||||
|
-- Modelled as a flagged row in main_dishes rather than a nullable
|
||||||
|
-- main_dish_id on meal_log: the log keeps one shape, and every foreign key
|
||||||
|
-- and join carries on working untouched.
|
||||||
|
|
||||||
|
ALTER TABLE main_dishes
|
||||||
|
ADD COLUMN special INTEGER NOT NULL DEFAULT 0 CHECK (special IN (0, 1));
|
||||||
|
|
||||||
|
-- OR IGNORE in case a household already typed a dish by this name: the unique
|
||||||
|
-- index on lower(name) would otherwise fail the migration. Their row stays as
|
||||||
|
-- an ordinary dish, which is wrong but harmless and fixable by hand.
|
||||||
|
INSERT OR IGNORE INTO main_dishes (name, has_sides, special)
|
||||||
|
VALUES ('Tähteet', 0, 1);
|
||||||
@@ -134,6 +134,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
.c-kana { color: var(--kana); }
|
.c-kana { color: var(--kana); }
|
||||||
.c-kala { color: var(--kala); }
|
.c-kala { color: var(--kala); }
|
||||||
.c-kasvis { color: var(--kasvis); }
|
.c-kasvis { color: var(--kasvis); }
|
||||||
|
/* Not a category, so not a category colour. */
|
||||||
|
.c-tahteet { color: var(--muted); }
|
||||||
|
|
||||||
/* Day switcher */
|
/* Day switcher */
|
||||||
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
|
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
|
||||||
@@ -208,6 +210,20 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
/* Not food: set apart from the categories, and deliberately quiet. */
|
||||||
|
.special {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
.pill.plain {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--sunk);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
|
.pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
|
||||||
.pill.lg { font-size: 18px; padding: 12px 16px; }
|
.pill.lg { font-size: 18px; padding: 12px 16px; }
|
||||||
.pill.md { font-size: 15.5px; padding: 11px 14px; }
|
.pill.md { font-size: 15.5px; padding: 11px 14px; }
|
||||||
@@ -310,8 +326,26 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
/* History sits under the logger on the same page, so whole rows are links. */
|
/* The day list is the page; rows are links and the selected one expands. */
|
||||||
.history { margin-top: 8px; }
|
.history { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Marked with a bar down the side, not rules above and below: the rows either
|
||||||
|
side already draw a bottom border, so a horizontal rule here doubled up.
|
||||||
|
scroll-margin keeps the anchor off the viewport edge. */
|
||||||
|
.open {
|
||||||
|
scroll-margin-top: 12px;
|
||||||
|
margin: 8px 0 18px;
|
||||||
|
padding: 10px 0 4px 13px;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
.openday {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
.entry, .gapline {
|
.entry, .gapline {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -328,6 +362,19 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: var(--tap);
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
|
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
|
||||||
.entry time, .gapline time {
|
.entry time, .gapline time {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -410,15 +457,52 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Catalog rows */
|
/* Catalog structure: Pääruuat and Lisukkeet are the two halves of the
|
||||||
|
catalog, the categories are subdivisions of the first. Two levels, so they
|
||||||
|
must not look alike. */
|
||||||
|
.section + .section { margin-top: 34px; }
|
||||||
|
.sectiontitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.sectiontitle .count {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--sunk);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sechead {
|
.sechead {
|
||||||
margin: 26px 0 6px;
|
margin: 20px 0 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.09em;
|
letter-spacing: 0.09em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed add/edit forms, so the page opens on the catalog. */
|
||||||
|
.addform > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.addform[open] > summary {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+68
-28
@@ -28,13 +28,24 @@ type Dish struct {
|
|||||||
TimesEaten int
|
TimesEaten int
|
||||||
}
|
}
|
||||||
|
|
||||||
// CategoryKey is the class suffix for the colour dot. A dish covering several
|
// Rows flagged `special` in the database — Tähteet — are loggable but are not
|
||||||
// categories (tortillas, build-your-own pizza) gets the mixed marker.
|
// food. They carry no category, never appear in the catalog, and PRD §8
|
||||||
|
// excludes them from the suggester: cooldown, coverage and weighting all skip
|
||||||
|
// them. dishByID deliberately does not filter on the flag, because logging one
|
||||||
|
// has to work like logging anything else.
|
||||||
|
|
||||||
|
// CategoryKey picks the mark for a dish. One covering several categories
|
||||||
|
// (tortillas, build-your-own pizza) gets the mixed one; carrying none at all
|
||||||
|
// means it is not food, and Tähteet is not a mixture of anything.
|
||||||
func (d Dish) CategoryKey() string {
|
func (d Dish) CategoryKey() string {
|
||||||
if len(d.Categories) == 1 {
|
switch len(d.Categories) {
|
||||||
|
case 0:
|
||||||
|
return "tahteet"
|
||||||
|
case 1:
|
||||||
return categoryFI[d.Categories[0]]
|
return categoryFI[d.Categories[0]]
|
||||||
|
default:
|
||||||
|
return "sek"
|
||||||
}
|
}
|
||||||
return "sek"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size buckets the dish by how often it has been eaten. The board draws
|
// Size buckets the dish by how often it has been eaten. The board draws
|
||||||
@@ -77,6 +88,17 @@ func (e Entry) SidesLabel() string {
|
|||||||
// listDishes returns live mains ordered by how often they have been eaten.
|
// listDishes returns live mains ordered by how often they have been eaten.
|
||||||
// An empty search matches everything.
|
// An empty search matches everything.
|
||||||
func listDishes(db *sql.DB, search string) ([]Dish, error) {
|
func listDishes(db *sql.DB, search string) ([]Dish, error) {
|
||||||
|
return queryDishes(db, search, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSpecial returns the entries that are not food — Tähteet and anything
|
||||||
|
// like it. They are loggable but never suggested, and never appear in the
|
||||||
|
// catalog, so they are fetched deliberately rather than by accident.
|
||||||
|
func listSpecial(db *sql.DB, search string) ([]Dish, error) {
|
||||||
|
return queryDishes(db, search, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryDishes(db *sql.DB, search string, special bool) ([]Dish, error) {
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT m.id, m.name, m.has_sides,
|
SELECT m.id, m.name, m.has_sides,
|
||||||
coalesce((SELECT group_concat(c.category)
|
coalesce((SELECT group_concat(c.category)
|
||||||
@@ -85,8 +107,9 @@ func listDishes(db *sql.DB, search string) ([]Dish, error) {
|
|||||||
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
|
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
|
||||||
FROM main_dishes m
|
FROM main_dishes m
|
||||||
WHERE m.deleted_at IS NULL
|
WHERE m.deleted_at IS NULL
|
||||||
|
AND m.special = ?
|
||||||
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%')
|
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%')
|
||||||
ORDER BY 5 DESC, m.name`, search, search)
|
ORDER BY 5 DESC, m.name`, special, search, search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -128,9 +151,12 @@ func dishByID(db *sql.DB, id int64) (*Dish, error) {
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listSides(db *sql.DB) ([]Side, error) {
|
func listSides(db *sql.DB, search string) ([]Side, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(`
|
||||||
`SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`)
|
SELECT id, name FROM side_dishes
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (? = '' OR lower(name) LIKE '%' || lower(?) || '%')
|
||||||
|
ORDER BY name`, search, search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -395,36 +421,50 @@ type HistoryRow struct {
|
|||||||
Entry *Entry
|
Entry *Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// history walks back day by day from today, so a day nobody wrote down shows
|
// HistoryPage is one window of history plus where to continue from. After a
|
||||||
// up as an explicit gap rather than silently missing. It stops at the first
|
// few years of daily entries the whole log is far too much to render at once.
|
||||||
// entry ever recorded — before that there is no history to be missing.
|
type HistoryPage struct {
|
||||||
func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) {
|
Rows []HistoryRow
|
||||||
var first string
|
More bool // older entries exist beyond this window
|
||||||
err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first)
|
Next time.Time // the day the next window starts at
|
||||||
if err == sql.ErrNoRows || first == "" {
|
}
|
||||||
return nil, nil
|
|
||||||
|
// history walks back day by day from a given day, 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, from time.Time, days int) (HistoryPage, error) {
|
||||||
|
var first sql.NullString
|
||||||
|
if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return HistoryPage{}, nil
|
||||||
|
}
|
||||||
|
return HistoryPage{}, err
|
||||||
}
|
}
|
||||||
if err != nil {
|
if !first.Valid || first.String == "" {
|
||||||
return nil, err
|
return HistoryPage{}, nil
|
||||||
}
|
}
|
||||||
firstDate, err := time.ParseInLocation(dateLayout, first, loc)
|
firstDate, err := time.ParseInLocation(dateLayout, first.String, loc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return HistoryPage{}, err
|
||||||
|
}
|
||||||
|
if from.Before(firstDate) {
|
||||||
|
return HistoryPage{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := today(loc)
|
oldest := from.AddDate(0, 0, -days+1)
|
||||||
oldest := now.AddDate(0, 0, -days)
|
page := HistoryPage{More: true}
|
||||||
if firstDate.After(oldest) {
|
if !firstDate.Before(oldest) {
|
||||||
oldest = firstDate
|
oldest = firstDate
|
||||||
|
page.More = false
|
||||||
}
|
}
|
||||||
|
page.Next = oldest.AddDate(0, 0, -1)
|
||||||
|
|
||||||
var rows []HistoryRow
|
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
|
||||||
for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
|
|
||||||
entry, err := entryFor(db, d)
|
entry, err := entryFor(db, d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return HistoryPage{}, err
|
||||||
}
|
}
|
||||||
rows = append(rows, HistoryRow{Date: d, Entry: entry})
|
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry})
|
||||||
}
|
}
|
||||||
return rows, nil
|
return page, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,10 +235,11 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
|
|||||||
t.Fatalf("save -3: %v", err)
|
t.Fatalf("save -3: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := history(h.db, loc, 60)
|
page, err := history(h.db, loc, now, 60)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("history: %v", err)
|
t.Fatalf("history: %v", err)
|
||||||
}
|
}
|
||||||
|
rows := page.Rows
|
||||||
// Walks back to the oldest entry only: today, -1, -2, -3.
|
// Walks back to the oldest entry only: today, -1, -2, -3.
|
||||||
if len(rows) != 4 {
|
if len(rows) != 4 {
|
||||||
t.Fatalf("%d rows, want 4", len(rows))
|
t.Fatalf("%d rows, want 4", len(rows))
|
||||||
@@ -252,16 +253,66 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
|
|||||||
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
|
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
|
||||||
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry)
|
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry)
|
||||||
}
|
}
|
||||||
|
if page.More {
|
||||||
|
t.Error("More is set although the window reached the oldest entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryPagesInWindows(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
loc := time.UTC
|
||||||
|
now := today(loc)
|
||||||
|
|
||||||
|
// Entries today and 9 days back, with a 5-day window over 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, -9), h.mainNamed(t, "Lihapullat"), nil); err != nil {
|
||||||
|
t.Fatalf("save -9: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := history(h.db, loc, now, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first window: %v", err)
|
||||||
|
}
|
||||||
|
if len(first.Rows) != 5 {
|
||||||
|
t.Errorf("%d rows in the first window, want 5", len(first.Rows))
|
||||||
|
}
|
||||||
|
if !first.More {
|
||||||
|
t.Error("More should be set: older entries exist")
|
||||||
|
}
|
||||||
|
if want := now.AddDate(0, 0, -5); !first.Next.Equal(want) {
|
||||||
|
t.Errorf("Next = %s, want %s", first.Next.Format(dateLayout), want.Format(dateLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The windows must meet exactly: no day repeated, none skipped.
|
||||||
|
second, err := history(h.db, loc, first.Next, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second window: %v", err)
|
||||||
|
}
|
||||||
|
if len(second.Rows) != 5 {
|
||||||
|
t.Errorf("%d rows in the second window, want 5", len(second.Rows))
|
||||||
|
}
|
||||||
|
if second.More {
|
||||||
|
t.Error("the second window reaches the oldest entry, so More should be clear")
|
||||||
|
}
|
||||||
|
last := second.Rows[len(second.Rows)-1]
|
||||||
|
if last.Entry == nil || last.Entry.Main.Name != "Lihapullat" {
|
||||||
|
t.Errorf("last row should be the oldest entry, got %+v", last.Entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
||||||
h := seeded(t)
|
h := seeded(t)
|
||||||
|
|
||||||
rows, err := history(h.db, time.UTC, 60)
|
page, err := history(h.db, time.UTC, today(time.UTC), 60)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("history: %v", err)
|
t.Fatalf("history: %v", err)
|
||||||
}
|
}
|
||||||
if len(rows) != 0 {
|
if len(page.Rows) != 0 {
|
||||||
t.Errorf("%d rows for an empty log, want 0", len(rows))
|
t.Errorf("%d rows for an empty log, want 0", len(page.Rows))
|
||||||
|
}
|
||||||
|
if page.More {
|
||||||
|
t.Error("More is set although there is no history at all")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-84
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,6 +43,18 @@ func dayURL(base string, d, now time.Time) string {
|
|||||||
return base + "?pvm=" + isoDate(d)
|
return base + "?pvm=" + isoDate(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dayAnchor names the element a day expands into.
|
||||||
|
func dayAnchor(d time.Time) string {
|
||||||
|
return "paiva-" + isoDate(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayLink opens a day and lands the viewport on it. Without the fragment the
|
||||||
|
// browser would jump to the top of a page whose selected day might be far
|
||||||
|
// down the list.
|
||||||
|
func dayLink(d, now time.Time) string {
|
||||||
|
return dayURL("/", d, now) + "#" + dayAnchor(d)
|
||||||
|
}
|
||||||
|
|
||||||
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
|
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
|
||||||
// any day but today.
|
// any day but today.
|
||||||
func pickSeparator(v logView) string {
|
func pickSeparator(v logView) string {
|
||||||
@@ -51,6 +64,37 @@ func pickSeparator(v logView) string {
|
|||||||
return "&"
|
return "&"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stepURL is any link inside the open day. It keeps the anchor, so picking a
|
||||||
|
// dish or cancelling stays where the day is instead of throwing the viewport
|
||||||
|
// back to the top of the list.
|
||||||
|
func stepURL(v logView, param string) string {
|
||||||
|
url := dayURL("/", v.Date, v.Today)
|
||||||
|
if param != "" {
|
||||||
|
url += pickSeparator(v) + param
|
||||||
|
}
|
||||||
|
return url + "#" + dayAnchor(v.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsString renders a Go string as a JavaScript literal, for the data-signals
|
||||||
|
// attribute that seeds the search box.
|
||||||
|
func jsString(s string) string {
|
||||||
|
b, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return `""`
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchURL is where the live search posts back to. The day travels in the
|
||||||
|
// path so the board keeps rendering links for the right date; the search text
|
||||||
|
// travels as a Datastar signal.
|
||||||
|
func searchURL(v logView) string {
|
||||||
|
if v.Date.Equal(v.Today) {
|
||||||
|
return "/etsi"
|
||||||
|
}
|
||||||
|
return "/etsi?pvm=" + isoDate(v.Date)
|
||||||
|
}
|
||||||
|
|
||||||
// categoryLabels lists a dish's categories in Finnish, for the catalog rows.
|
// categoryLabels lists a dish's categories in Finnish, for the catalog rows.
|
||||||
func categoryLabels(d Dish) string {
|
func categoryLabels(d Dish) string {
|
||||||
names := make([]string, 0, len(d.Categories))
|
names := make([]string, 0, len(d.Categories))
|
||||||
@@ -162,6 +206,10 @@ templ categoryIcon(key string) {
|
|||||||
<span class="cat c-kasvis">
|
<span class="cat c-kasvis">
|
||||||
@glyphKasvis()
|
@glyphKasvis()
|
||||||
</span>
|
</span>
|
||||||
|
case "tahteet":
|
||||||
|
<span class="cat c-tahteet">
|
||||||
|
@glyphTahteet()
|
||||||
|
</span>
|
||||||
default:
|
default:
|
||||||
<span class="cat">
|
<span class="cat">
|
||||||
@glyphSekalaiset()
|
@glyphSekalaiset()
|
||||||
@@ -169,6 +217,15 @@ templ categoryIcon(key string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A lidded tub. Tähteet is not food and not a mixture of categories, so it
|
||||||
|
// gets neither a category colour nor the quartered mark.
|
||||||
|
templ glyphTahteet() {
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
|
||||||
|
<rect x="1.4" y="2.6" width="13.2" height="3" rx="1.3"></rect>
|
||||||
|
<path d="M2.8 6.8h10.4l-.9 6.6a1.6 1.6 0 0 1-1.6 1.4H5.3a1.6 1.6 0 0 1-1.6-1.4z"></path>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
// A steak, its bone knocked out with fill-rule so the hole is transparent on
|
// A steak, its bone knocked out with fill-rule so the hole is transparent on
|
||||||
// whatever background the icon lands on.
|
// whatever background the icon lands on.
|
||||||
templ glyphLiha() {
|
templ glyphLiha() {
|
||||||
@@ -247,52 +304,64 @@ templ logPage(v logView) {
|
|||||||
@daySwitch(v)
|
@daySwitch(v)
|
||||||
</header>
|
</header>
|
||||||
<main class="pad">
|
<main class="pad">
|
||||||
switch {
|
@dayList(v)
|
||||||
case v.Chosen != nil:
|
|
||||||
@sidesStep(v)
|
|
||||||
case v.ShowBoard:
|
|
||||||
@board(v)
|
|
||||||
default:
|
|
||||||
@loggedCard(v)
|
|
||||||
}
|
|
||||||
@historyList(v)
|
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// historyList sits under the day being logged: the two were always one thing,
|
// dayList is the whole page: every day back through the window, with the
|
||||||
// since every row here is a link back into the logger above it.
|
// selected one expanded where it sits. Opening a day used to swap in a panel
|
||||||
templ historyList(v logView) {
|
// above the list and drop that day out of it, so the rows below jumped up
|
||||||
|
// under the tap. Now nothing moves — the row grows.
|
||||||
|
templ dayList(v logView) {
|
||||||
<section class="history">
|
<section class="history">
|
||||||
<h3 class="sechead">Aiemmin</h3>
|
for i, row := range v.History.Rows {
|
||||||
if len(v.History) == 0 {
|
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
|
||||||
<p class="muted small">Ei vielä merkintöjä.</p>
|
<p class="monthrule">{ monthFI(row.Date) }</p>
|
||||||
}
|
|
||||||
for i, row := range v.History {
|
|
||||||
if !row.Date.Equal(v.Date) {
|
|
||||||
if i == 0 || v.History[i-1].Date.Month() != row.Date.Month() {
|
|
||||||
<p class="monthrule">{ monthFI(row.Date) }</p>
|
|
||||||
}
|
|
||||||
if row.Entry != nil {
|
|
||||||
<a class="entry" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
|
|
||||||
<time>{ dayLabelFI(row.Date) }</time>
|
|
||||||
<div>
|
|
||||||
<div class="nm">
|
|
||||||
@categoryIcon(row.Entry.Main.CategoryKey())
|
|
||||||
{ row.Entry.Main.Name }
|
|
||||||
</div>
|
|
||||||
<div class="sd">{ row.Entry.SidesLabel() }</div>
|
|
||||||
</div>
|
|
||||||
<span class="chev">›</span>
|
|
||||||
</a>
|
|
||||||
} else {
|
|
||||||
<a class="gapline" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
|
|
||||||
<time>{ dayLabelFI(row.Date) }</time>
|
|
||||||
<span>Ei merkintää</span>
|
|
||||||
<span class="act">Merkitse</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if row.Date.Equal(v.Date) {
|
||||||
|
<div class="open" id={ dayAnchor(row.Date) }>
|
||||||
|
<p class="openday">
|
||||||
|
if row.Date.Equal(v.Today) {
|
||||||
|
Tänään
|
||||||
|
} else {
|
||||||
|
{ longDateFI(row.Date) }
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
switch {
|
||||||
|
case v.Chosen != nil:
|
||||||
|
@sidesStep(v)
|
||||||
|
case v.ShowBoard:
|
||||||
|
@board(v)
|
||||||
|
default:
|
||||||
|
@loggedCard(v)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else if row.Entry != nil {
|
||||||
|
<a class="entry" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
|
||||||
|
<time>{ dayLabelFI(row.Date) }</time>
|
||||||
|
<div>
|
||||||
|
<div class="nm">
|
||||||
|
@categoryIcon(row.Entry.Main.CategoryKey())
|
||||||
|
{ row.Entry.Main.Name }
|
||||||
|
</div>
|
||||||
|
<div class="sd">{ row.Entry.SidesLabel() }</div>
|
||||||
|
</div>
|
||||||
|
<span class="chev">›</span>
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<a class="gapline" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
|
||||||
|
<time>{ dayLabelFI(row.Date) }</time>
|
||||||
|
<span>Ei merkintää</span>
|
||||||
|
<span class="act">Merkitse</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.History.More {
|
||||||
|
<a
|
||||||
|
class="more"
|
||||||
|
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) }
|
||||||
|
>Näytä lisää</a>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@@ -318,27 +387,72 @@ templ dayButton(label string, target, selected, now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The form still works on its own: submitting reloads the page with ?haku=.
|
||||||
|
// Datastar binds the same box to a signal and re-renders just the list as it
|
||||||
|
// is typed into, so the live version is an enhancement rather than a
|
||||||
|
// requirement.
|
||||||
templ board(v logView) {
|
templ board(v logView) {
|
||||||
<form method="get" action="/" class="searchrow">
|
<div data-signals:haku={ jsString(v.Search) }>
|
||||||
if !v.Date.Equal(v.Today) {
|
<form method="get" action="/" class="searchrow">
|
||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
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 tai lisää uusi" aria-label="Etsi"/>
|
|
||||||
</form>
|
|
||||||
// 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 {
|
|
||||||
<h3 class="sechead">{ g.Label }</h3>
|
|
||||||
<div class="board">
|
|
||||||
for _, d := range g.Dishes {
|
|
||||||
@dishPill(d, v)
|
|
||||||
}
|
}
|
||||||
</div>
|
<input
|
||||||
}
|
class="filter"
|
||||||
if len(v.Dishes) == 0 {
|
type="search"
|
||||||
@quickAddCard(v)
|
name="haku"
|
||||||
}
|
value={ v.Search }
|
||||||
|
placeholder="Etsi tai lisää uusi"
|
||||||
|
aria-label="Etsi"
|
||||||
|
data-bind:haku
|
||||||
|
data-on:input__debounce.250ms={ "@get('" + searchURL(v) + "')" }
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
@boardList(v)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// boardList is what Datastar patches: it carries the id, so a plain text/html
|
||||||
|
// response is matched to it and swapped in place.
|
||||||
|
templ boardList(v logView) {
|
||||||
|
<div id="lauta">
|
||||||
|
// 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 {
|
||||||
|
<h3 class="sechead">{ g.Label }</h3>
|
||||||
|
<div class="board">
|
||||||
|
for _, d := range g.Dishes {
|
||||||
|
@dishPill(d, v)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
// Tähteet is not food, so it sits apart from the categories rather
|
||||||
|
// than inside one. Fixed size: it will be among the most-logged
|
||||||
|
// entries, and it should not tower over the actual cooking.
|
||||||
|
if len(v.Special) > 0 {
|
||||||
|
<div class="special">
|
||||||
|
for _, d := range v.Special {
|
||||||
|
<a
|
||||||
|
class="pill plain"
|
||||||
|
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
|
||||||
|
>
|
||||||
|
@categoryIcon(d.CategoryKey())
|
||||||
|
{ d.Name }
|
||||||
|
if d.TimesEaten > 0 {
|
||||||
|
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
// Tähteet always matches an empty search, so the add card keys off the
|
||||||
|
// real dishes only: otherwise a fresh install would show leftovers and
|
||||||
|
// no way to add anything.
|
||||||
|
if len(v.Dishes) == 0 {
|
||||||
|
@quickAddCard(v)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// quickAddCard turns a search that found nothing into the thing to do next.
|
// quickAddCard turns a search that found nothing into the thing to do next.
|
||||||
@@ -388,7 +502,7 @@ templ quickAddCard(v logView) {
|
|||||||
templ dishPill(d Dish, v logView) {
|
templ dishPill(d Dish, v logView) {
|
||||||
<a
|
<a
|
||||||
class={ "pill", d.Size() }
|
class={ "pill", d.Size() }
|
||||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
|
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
|
||||||
>
|
>
|
||||||
@categoryIcon(d.CategoryKey())
|
@categoryIcon(d.CategoryKey())
|
||||||
{ d.Name }
|
{ d.Name }
|
||||||
@@ -426,7 +540,7 @@ templ sidesStep(v logView) {
|
|||||||
}
|
}
|
||||||
<button class="primary" type="submit">Tallenna</button>
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
|
<a class="ghost" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,17 +565,17 @@ templ loggedCard(v logView) {
|
|||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
<button class="btn del" type="submit">Kyllä, poista</button>
|
<button class="btn del" type="submit">Kyllä, poista</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
|
<a class="btn" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<a
|
<a
|
||||||
class="btn"
|
class="btn"
|
||||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "muuta=1") }
|
href={ templ.SafeURL(stepURL(v, "muuta=1")) }
|
||||||
>Muokkaa</a>
|
>Muokkaa</a>
|
||||||
<a
|
<a
|
||||||
class="btn del"
|
class="btn del"
|
||||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "poista=1") }
|
href={ templ.SafeURL(stepURL(v, "poista=1")) }
|
||||||
>Poista</a>
|
>Poista</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -481,10 +595,46 @@ templ catalogPage(v catalogView) {
|
|||||||
if v.Report != nil {
|
if v.Report != nil {
|
||||||
@importReport(v.Report)
|
@importReport(v.Report)
|
||||||
}
|
}
|
||||||
@mainForm_(v.Main)
|
<div data-signals:haku={ jsString(v.Search) }>
|
||||||
|
<form method="get" action="/ruuat" class="searchrow">
|
||||||
|
<input
|
||||||
|
class="filter"
|
||||||
|
type="search"
|
||||||
|
name="haku"
|
||||||
|
value={ v.Search }
|
||||||
|
placeholder="Etsi ruokaa"
|
||||||
|
aria-label="Etsi"
|
||||||
|
data-bind:haku
|
||||||
|
data-on:input__debounce.250ms="@get('/ruuat/etsi')"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
// The add and edit forms stay outside the patched fragment, or
|
||||||
|
// typing in the search box would collapse a form mid-edit.
|
||||||
|
@mainForm_(v.Main)
|
||||||
|
@sideForm_(v.Side)
|
||||||
|
@catalogList(v)
|
||||||
|
</div>
|
||||||
|
<details class="card">
|
||||||
|
<summary>Tuo ruokia tiedostosta</summary>
|
||||||
|
@importForm()
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogList carries the id Datastar patches, so typing in the search box
|
||||||
|
// swaps the lists without touching the forms above them.
|
||||||
|
//
|
||||||
|
// Two levels of heading, because there are two: Pääruuat and Lisukkeet are
|
||||||
|
// the halves of the catalog, and the categories are subdivisions of the
|
||||||
|
// first. They were previously styled the same, which made a category look
|
||||||
|
// like a peer of the entire side-dish list.
|
||||||
|
templ catalogList(v catalogView) {
|
||||||
|
<div id="ruokalista">
|
||||||
|
<section class="section">
|
||||||
|
@sectionTitle("Pääruuat", v.Mains)
|
||||||
if v.Mains == 0 {
|
if v.Mains == 0 {
|
||||||
<h3 class="sechead">Pääruuat</h3>
|
@emptyNote(v.Search)
|
||||||
<p class="muted small">Ei vielä pääruokia.</p>
|
|
||||||
}
|
}
|
||||||
// Grouped by category, alphabetical inside. The catalog is a list
|
// Grouped by category, alphabetical inside. The catalog is a list
|
||||||
// you manage, so a predictable position beats a useful one.
|
// you manage, so a predictable position beats a useful one.
|
||||||
@@ -503,10 +653,11 @@ templ catalogPage(v catalogView) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@sideForm_(v.Side)
|
</section>
|
||||||
<h3 class="sechead">Lisukkeet</h3>
|
<section class="section">
|
||||||
|
@sectionTitle("Lisukkeet", len(v.Sides))
|
||||||
if len(v.Sides) == 0 {
|
if len(v.Sides) == 0 {
|
||||||
<p class="muted small">Ei vielä lisukkeita.</p>
|
@emptyNote(v.Search)
|
||||||
}
|
}
|
||||||
for _, s := range v.Sides {
|
for _, s := range v.Sides {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -514,12 +665,25 @@ templ catalogPage(v catalogView) {
|
|||||||
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
|
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<details class="card">
|
</section>
|
||||||
<summary>Tuo ruokia tiedostosta</summary>
|
</div>
|
||||||
@importForm()
|
}
|
||||||
</details>
|
|
||||||
</main>
|
templ sectionTitle(label string, n int) {
|
||||||
}
|
<h2 class="sectiontitle">
|
||||||
|
{ label }
|
||||||
|
<span class="count">{ strconv.Itoa(n) }</span>
|
||||||
|
</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ emptyNote(search string) {
|
||||||
|
<p class="muted small">
|
||||||
|
if search == "" {
|
||||||
|
Ei vielä mitään.
|
||||||
|
} else {
|
||||||
|
Ei osumia haulle { search }.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
// rowActions is a pencil and a bin, until the bin is tapped: then the row
|
// rowActions is a pencil and a bin, until the bin is tapped: then the row
|
||||||
@@ -567,15 +731,18 @@ templ iconTrash() {
|
|||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsed by default so the page opens on the catalog rather than on two
|
||||||
|
// screens of empty form. Forced open when editing or after a rejected
|
||||||
|
// submission, since the form is then the thing that needs attention.
|
||||||
templ mainForm_(f mainForm) {
|
templ mainForm_(f mainForm) {
|
||||||
<section class="card" id="paaruoka">
|
<details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }>
|
||||||
<h3>
|
<summary>
|
||||||
if f.ID == 0 {
|
if f.ID == 0 {
|
||||||
Lisää pääruoka
|
Lisää pääruoka
|
||||||
} else {
|
} else {
|
||||||
Muokkaa pääruokaa
|
Muokkaa pääruokaa
|
||||||
}
|
}
|
||||||
</h3>
|
</summary>
|
||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
@@ -609,7 +776,7 @@ templ mainForm_(f mainForm) {
|
|||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<a class="ghost" href="/ruuat">Peruuta</a>
|
<a class="ghost" href="/ruuat">Peruuta</a>
|
||||||
}
|
}
|
||||||
</section>
|
</details>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ categoryChip(value, label string, f mainForm) {
|
templ categoryChip(value, label string, f mainForm) {
|
||||||
@@ -625,14 +792,14 @@ templ categoryChip(value, label string, f mainForm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ sideForm_(f sideForm) {
|
templ sideForm_(f sideForm) {
|
||||||
<section class="card" id="lisuke">
|
<details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }>
|
||||||
<h3>
|
<summary>
|
||||||
if f.ID == 0 {
|
if f.ID == 0 {
|
||||||
Lisää lisuke
|
Lisää lisuke
|
||||||
} else {
|
} else {
|
||||||
Muokkaa lisuketta
|
Muokkaa lisuketta
|
||||||
}
|
}
|
||||||
</h3>
|
</summary>
|
||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
@@ -649,7 +816,7 @@ templ sideForm_(f sideForm) {
|
|||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<a class="ghost" href="/ruuat">Peruuta</a>
|
<a class="ghost" href="/ruuat">Peruuta</a>
|
||||||
}
|
}
|
||||||
</section>
|
</details>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ importForm() {
|
templ importForm() {
|
||||||
|
|||||||
+37
-2
@@ -114,6 +114,11 @@ check "malformed JSON is explained" \
|
|||||||
board=$(curl -s -u ":$pass" "http://$addr/")
|
board=$(curl -s -u ":$pass" "http://$addr/")
|
||||||
check "board lists imported dishes" "$board" "Lihapullat"
|
check "board lists imported dishes" "$board" "Lihapullat"
|
||||||
|
|
||||||
|
# Tähteet is loggable but is not food: on the board, never in the catalog.
|
||||||
|
check "leftovers are on the board" "$board" "Tähteet"
|
||||||
|
refute "leftovers are not in the catalog" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet"
|
||||||
|
|
||||||
# Pull a real dish id out of the board rather than assuming one.
|
# 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)
|
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
|
||||||
if [ -z "$ruoka" ]; then
|
if [ -z "$ruoka" ]; then
|
||||||
@@ -132,8 +137,16 @@ check "saving redirects back to the day" \
|
|||||||
check "the saved day shows what was eaten" \
|
check "the saved day shows what was eaten" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
||||||
|
|
||||||
check "history is on the same page as the logger" \
|
# The selected day expands inside the list rather than in a panel above it,
|
||||||
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
|
# so the rows below do not shift when one is tapped.
|
||||||
|
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
|
||||||
|
check "the selected day expands in place" "$day" 'id="paiva-2026-09-05"'
|
||||||
|
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
||||||
|
check "links inside it keep the anchor" "$day" "#paiva-2026-09-05"
|
||||||
|
|
||||||
|
check "saving returns to the day, not the top" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||||
|
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "#paiva-2026-09-04"
|
||||||
|
|
||||||
# Deleting a logged meal drops the row outright, so it asks first.
|
# Deleting a logged meal drops the row outright, so it asks first.
|
||||||
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
|
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
|
||||||
@@ -150,6 +163,28 @@ check "the day is empty again" \
|
|||||||
check "search filters the board" \
|
check "search filters the board" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
||||||
|
|
||||||
|
# ---- live search: Datastar sends signals as JSON in ?datastar= -----------
|
||||||
|
|
||||||
|
live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")
|
||||||
|
check "live search returns the board fragment" "$live" 'id="lauta"'
|
||||||
|
check "live search applies the term" "$live" "keitto"
|
||||||
|
refute "live search excludes non-matches" "$live" "Lihapullat"
|
||||||
|
refute "the fragment is not a whole page" "$live" "<html"
|
||||||
|
|
||||||
|
check "live search is served as html for Datastar to patch" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" \
|
||||||
|
--get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \
|
||||||
|
"text/html"
|
||||||
|
|
||||||
|
cat_live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi")
|
||||||
|
check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"'
|
||||||
|
check "catalog live search matches sides too" "$cat_live" "Riisi"
|
||||||
|
refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat"
|
||||||
|
|
||||||
|
# The plain form still works without JavaScript.
|
||||||
|
check "catalog search works as a plain form too" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?haku=riisi")" "Riisi"
|
||||||
|
|
||||||
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
|
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
|
||||||
future=$(date -d '+30 days' +%Y-%m-%d)
|
future=$(date -d '+30 days' +%Y-%m-%d)
|
||||||
check "a future date falls back to today" \
|
check "a future date falls back to today" \
|
||||||
|
|||||||
+47
-10
@@ -1,19 +1,56 @@
|
|||||||
{
|
{
|
||||||
"mains": [
|
"mains": [
|
||||||
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
|
{"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false},
|
||||||
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
|
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
|
||||||
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
|
{"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false},
|
||||||
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
|
{"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true},
|
||||||
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
|
{"name": "Kebab", "categories": ["meat"], "has_sides": true},
|
||||||
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
|
{"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false},
|
||||||
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false}
|
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Lihapullat/pihvit", "categories": ["meat"], "has_sides": true},
|
||||||
|
{"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Makaronimössö", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Maksalaatikko", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Nachopelti", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Nakkikeitto", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Possunsuikalekastike", "categories": ["meat"], "has_sides": true},
|
||||||
|
{"name": "Possurisotto", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Pyttipannu", "categories": ["meat"], "has_sides": false},
|
||||||
|
{"name": "Uuniliha", "categories": ["meat"], "has_sides": true},
|
||||||
|
{"name": "Uunimakkara", "categories": ["meat"], "has_sides": true},
|
||||||
|
|
||||||
|
{"name": "Broilerin koipireidet", "categories": ["chicken"], "has_sides": true},
|
||||||
|
{"name": "Kanakastike", "categories": ["chicken"], "has_sides": true},
|
||||||
|
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
|
||||||
|
{"name": "Kanamakaronilaatikko", "categories": ["chicken"], "has_sides": false},
|
||||||
|
{"name": "Kanapasta", "categories": ["chicken"], "has_sides": false},
|
||||||
|
{"name": "Kanarisotto", "categories": ["chicken"], "has_sides": false},
|
||||||
|
|
||||||
|
{"name": "Kalakeitto", "categories": ["fish"], "has_sides": false},
|
||||||
|
{"name": "Lohicuscus-salaatti", "categories": ["fish"], "has_sides": false},
|
||||||
|
{"name": "Lohipyörykät", "categories": ["fish"], "has_sides": true},
|
||||||
|
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
|
||||||
|
{"name": "Uuniperunat (lohitäytteellä)", "categories": ["fish"], "has_sides": false},
|
||||||
|
|
||||||
|
{"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false},
|
||||||
|
{"name": "Italianpata (lihaton)", "categories": ["vegetarian"], "has_sides": true},
|
||||||
|
{"name": "Kasvispihvit", "categories": ["vegetarian"], "has_sides": true},
|
||||||
|
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false},
|
||||||
|
{"name": "Pinaattiletut", "categories": ["vegetarian"], "has_sides": false},
|
||||||
|
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
|
||||||
|
|
||||||
|
{"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
|
||||||
],
|
],
|
||||||
"sides": [
|
"sides": [
|
||||||
{"name": "Keitetyt perunat"},
|
{"name": "Keitetyt perunat"},
|
||||||
{"name": "Ranskalaiset"},
|
|
||||||
{"name": "Lohkoperunat"},
|
{"name": "Lohkoperunat"},
|
||||||
{"name": "Muussi"},
|
{"name": "Muusi"},
|
||||||
|
{"name": "Pasta"},
|
||||||
|
{"name": "Ranskalaiset"},
|
||||||
{"name": "Riisi"},
|
{"name": "Riisi"},
|
||||||
{"name": "Pasta"}
|
{"name": "Spagetti"},
|
||||||
|
{"name": "Tillikastike"},
|
||||||
|
{"name": "Wokkivihannekset"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user