7 Commits
Author SHA1 Message Date
Esa Kataja aa6541addd Replace the category dots with icons
A coloured dot means nothing until the legend has been learned, which is the
same objection that got the checkboxes made visible. The marks now carry
colour and shape together: a steak, a drumstick, a fish, a leaf, and a
quartered circle for the dishes covering several categories at once.

The steak's bone is a real hole rather than a shape painted in the background
colour, and the leaf lost the midrib it had in the mockup for the same
reason: these sit on cards in the board and on the page background in the
history list, so anything relying on knowing the backdrop breaks in one of
them.

One component, three sizes driven from CSS: 16px normally, 19 in the large
pills, 22 on the logged card. It covers the board, the sides step, the logged
card, the history rows, the catalog rows and the category checkboxes, so no
dot markup or CSS is left.
2026-09-05 21:46:14 +03:00
Esa Kataja 3564d74c39 Give the header a surface
The brand row and theme toggle sit on their own background with a hairline
under it, so the header reads as an object rather than text floating on the
page. The page title and day switcher stay on the page background: they are
content, not chrome.

The colour is its own --header variable rather than a reuse of --card, so the
header can be recoloured without dragging every card along when the palette
gets overhauled. The theme-color meta tags match it, so on a phone the browser
chrome continues the header instead of butting a different shade against it;
those need literal hex, so each file points at the other.
2026-09-05 21:37:03 +03:00
Esa Kataja b67c4edd5a Ask before deleting, and put the row actions on icons
Muokkaa and Poista become a pencil and a bin, which stops the catalog rows
being two words wide. Both carry a Finnish aria-label and title, so nothing
is lost by dropping the text.

An icon is easier to hit by accident than a word, so neither delete happens
immediately now. A tapped bin turns that row's actions into "Poista? Kyllä /
Peruuta", and a logged meal asks "Poistetaanko merkintä?" before it goes.
The meal is the more destructive of the two: a dish is only soft-deleted and
its name still resolves in old entries, while the log row is dropped outright.

Both confirmations are plain links and forms, so they work with the back
button and need no client code.

Also fixes a test that was passing for the wrong reason. The delete check only
asserted a 303 and took the first dish id on the page, which stopped being the
one it had just created when the catalog became grouped and alphabetical — so
it was deleting an unrelated dish. It now finds that dish's own id, and a new
refute helper asserts the dish is actually gone afterwards.
2026-09-05 21:34:18 +03:00
Esa Kataja e64c4aa212 Refuse to log meals in the future
The log is a record of what was eaten, so there is nothing to write down for
a dinner that has not happened, and a stray entry dated next year would sit
at the top of the history forever. A future date is now clamped to today.

The clamp lives in the one function every read and write already goes
through, so ?pvm=, the date picker, saving and deleting are all covered. The
picker also gets max=today, which avoids offering the dead end at all.

Fixes a latent bug found alongside it: today() returned the current instant
with its time of day, while dates parsed from ?pvm= are midnight, so the two
never compared equal. After saving today's dinner the redirect landed on
/?pvm=... and the card then read "la 5.9. kirjattu" instead of "Tänään
kirjattu", and "Tänään" stopped highlighting whenever the date was spelled
out. today() now truncates to midnight in the configured location.
2026-09-05 21:31:23 +03:00
Esa Kataja 4ce189d1e4 Merge the log and history into one page, group dishes by category
Kirjaa and Historia were two views of the same thing: every history row was
already a link back into the logger, and the logger had a day switcher. They
are now one page — the day being logged on top, history underneath, each row
loading its day into the logger above. Two tabs instead of three.

That also closed a gap. On an already-logged day there was no way to swap to
a different dish; "Muokkaa" only reopened the sides for the same one. It now
opens the board, so changing a dish and choosing one for the first time are
the same path.

Dishes are grouped by category on both screens, with Sekalaiset collecting
the ones covering more than one. That group is derived from the stored set
rather than being a fifth category, so a single Tortillat still satisfies
meat, chicken, fish and vegetarian at once when the §8.1 suggester arrives.

The two screens sort differently on purpose. The log board keeps frequency
then name inside each group, so favourites surface without wandering between
categories as counts change. The catalog sorts by name, because there you are
hunting a specific dish to edit rather than picking one to eat. groupDishes
preserves the order it is handed; the caller decides which it wants.

Ruoat is renamed Ruuat throughout, label and route both.
2026-09-05 21:28:14 +03:00
Esa Kataja 705ad5af26 Cut releases from main only, enforced where it can be
main is protected on the remote and takes no direct pushes, so a release
arrives as a pull request from dev. Document that flow.

`make image` refuses to run outside main. That check has to live locally:
the tag and the image are made before anything reaches the remote, so branch
protection cannot catch a release built from the wrong branch.

A pre-commit hook was tried and dropped. It needed installing per clone, so
it enforced nothing that the remote was not already enforcing, while implying
it did.
2026-09-05 20:52:16 +03:00
Esa Kataja 813e82ef5c Say which path and user cannot open the database
sql.Open is lazy, so a permission problem surfaced from whichever query ran
first: "create schema_migrations: unable to open database file (14)", which
names neither the file nor the reason. Ping on open and report the path and
the effective uid and gid instead.

The cause in practice is a bind-mounted ./data that Docker created as root
while the container runs as FOODSTER_UID. Documented in the README.
2026-09-05 20:23:44 +03:00
9 changed files with 95 additions and 472 deletions
+1 -1
View File
@@ -138,7 +138,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)
} }
+11 -113
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"io" "io"
"log" "log"
@@ -18,27 +17,11 @@ import (
// kilobytes; a megabyte is already absurd generosity. // kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20 const maxUpload = 1 << 20
const ( // historyDays is how far back the history under the logger walks.
// historyDays is one window of the history under the logger, and the step //
// that "show more" grows it by. Older days arrive a window at a time // ponytail: a fixed window. After a year of daily entries this list is the
// rather than all at once. // thing that needs paging; load more on scroll when it actually hurts.
historyDays = 30 const historyDays = 60
// 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
@@ -88,9 +71,7 @@ type logView struct {
Groups []DishGroup // what the board actually renders Groups []DishGroup // what the board actually renders
Sides []Side Sides []Side
New mainForm // inline "add the dish you were looking for" New mainForm // inline "add the dish you were looking for"
History HistoryPage History []HistoryRow
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.
@@ -147,97 +128,18 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
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)
} }
} }
v.HistoryDays = historyWindow(r) if v.History, err = history(a.db, a.loc, historyDays); err != nil {
v.HistoryMore = v.HistoryDays + historyDays
if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
log.Printf("history: %v", err) log.Printf("history: %v", err)
} }
render(w, r, logPage(v)) 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)
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
// logging it. Hunting for something that is not in the catalog yet should not // logging it. Hunting for something that is not in the catalog yet should not
// mean a detour through Ruoat and a lost train of thought. // mean a detour through Ruoat and a lost train of thought.
@@ -293,9 +195,7 @@ 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)
v.HistoryDays = historyWindow(r) if v.History, err = history(a.db, a.loc, historyDays); err != nil {
v.HistoryMore = v.HistoryDays + historyDays
if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
log.Printf("history: %v", err) log.Printf("history: %v", err)
} }
render(w, r, logPage(v)) render(w, r, logPage(v))
@@ -376,7 +276,6 @@ 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.
@@ -387,7 +286,6 @@ 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.
@@ -428,7 +326,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, v.Search) mains, err := listDishes(a.db, "")
if err != nil { if err != nil {
log.Printf("list mains: %v", err) log.Printf("list mains: %v", err)
} }
@@ -436,7 +334,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, v.Search); err != nil { if v.Sides, err = listSides(a.db); err != nil {
log.Printf("list sides: %v", err) log.Printf("list sides: %v", err)
} }
render(w, r, catalogPage(v)) render(w, r, catalogPage(v))
-2
View File
@@ -158,10 +158,8 @@ 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)
-30
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -76,35 +75,6 @@ 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 {
+2 -52
View File
@@ -328,19 +328,6 @@ 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;
@@ -423,52 +410,15 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
cursor: pointer; cursor: pointer;
} }
/* Catalog structure: Pääruuat and Lisukkeet are the two halves of the /* Catalog rows */
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: 20px 0 2px; margin: 26px 0 6px;
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;
+23 -40
View File
@@ -128,12 +128,9 @@ func dishByID(db *sql.DB, id int64) (*Dish, error) {
return &d, nil return &d, nil
} }
func listSides(db *sql.DB, search string) ([]Side, error) { func listSides(db *sql.DB) ([]Side, error) {
rows, err := db.Query(` rows, err := db.Query(
SELECT id, name FROM side_dishes `SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`)
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
} }
@@ -398,50 +395,36 @@ type HistoryRow struct {
Entry *Entry Entry *Entry
} }
// HistoryPage is one window of history plus where to continue from. After a // history walks back day by day from today, so a day nobody wrote down shows
// few years of daily entries the whole log is far too much to render at once. // up as an explicit gap rather than silently missing. It stops at the first
type HistoryPage struct { // entry ever recorded — before that there is no history to be missing.
Rows []HistoryRow func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) {
More bool // older entries exist beyond this window var first string
Next time.Time // the day the next window starts at err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first)
} 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 !first.Valid || first.String == "" {
return HistoryPage{}, nil
}
firstDate, err := time.ParseInLocation(dateLayout, first.String, loc)
if err != nil { if err != nil {
return HistoryPage{}, err return nil, err
} }
if from.Before(firstDate) { firstDate, err := time.ParseInLocation(dateLayout, first, loc)
return HistoryPage{}, nil if err != nil {
return nil, err
} }
oldest := from.AddDate(0, 0, -days+1) now := today(loc)
page := HistoryPage{More: true} oldest := now.AddDate(0, 0, -days)
if !firstDate.Before(oldest) { if firstDate.After(oldest) {
oldest = firstDate oldest = firstDate
page.More = false
} }
page.Next = oldest.AddDate(0, 0, -1)
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) { var rows []HistoryRow
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 HistoryPage{}, err return nil, err
} }
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry}) rows = append(rows, HistoryRow{Date: d, Entry: entry})
} }
return page, nil return rows, nil
} }
+4 -55
View File
@@ -235,11 +235,10 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
t.Fatalf("save -3: %v", err) t.Fatalf("save -3: %v", err)
} }
page, err := history(h.db, loc, now, 60) rows, err := history(h.db, loc, 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))
@@ -253,66 +252,16 @@ 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)
page, err := history(h.db, time.UTC, today(time.UTC), 60) rows, err := history(h.db, time.UTC, 60)
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
if len(page.Rows) != 0 { if len(rows) != 0 {
t.Errorf("%d rows for an empty log, want 0", len(page.Rows)) t.Errorf("%d rows for an empty log, want 0", len(rows))
}
if page.More {
t.Error("More is set although there is no history at all")
} }
} }
+25 -128
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -52,26 +51,6 @@ func pickSeparator(v logView) string {
return "&" return "&"
} }
// 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))
@@ -286,12 +265,12 @@ templ logPage(v logView) {
templ historyList(v logView) { templ historyList(v logView) {
<section class="history"> <section class="history">
<h3 class="sechead">Aiemmin</h3> <h3 class="sechead">Aiemmin</h3>
if len(v.History.Rows) == 0 { if len(v.History) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p> <p class="muted small">Ei vielä merkintöjä.</p>
} }
for i, row := range v.History.Rows { for i, row := range v.History {
if !row.Date.Equal(v.Date) { if !row.Date.Equal(v.Date) {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() { if i == 0 || v.History[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p> <p class="monthrule">{ monthFI(row.Date) }</p>
} }
if row.Entry != nil { if row.Entry != nil {
@@ -315,12 +294,6 @@ templ historyList(v logView) {
} }
} }
} }
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>
} }
@@ -345,38 +318,16 @@ 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) {
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/" class="searchrow"> <form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) { if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
} }
<input <input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
class="filter"
type="search"
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> </form>
@boardList(v) // Grouped by category, and inside each group the most-eaten first — so a
</div> // dish keeps a predictable neighbourhood while favourites still surface
} // at the top of it.
// 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 { for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3> <h3 class="sechead">{ g.Label }</h3>
<div class="board"> <div class="board">
@@ -388,7 +339,6 @@ templ boardList(v logView) {
if len(v.Dishes) == 0 { if len(v.Dishes) == 0 {
@quickAddCard(v) @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.
@@ -531,46 +481,10 @@ templ catalogPage(v catalogView) {
if v.Report != nil { if v.Report != nil {
@importReport(v.Report) @importReport(v.Report)
} }
<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) @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 {
@emptyNote(v.Search) <h3 class="sechead">Pääruuat</h3>
<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.
@@ -589,11 +503,10 @@ templ catalogList(v catalogView) {
</div> </div>
} }
} }
</section> @sideForm_(v.Side)
<section class="section"> <h3 class="sechead">Lisukkeet</h3>
@sectionTitle("Lisukkeet", len(v.Sides))
if len(v.Sides) == 0 { if len(v.Sides) == 0 {
@emptyNote(v.Search) <p class="muted small">Ei vielä lisukkeita.</p>
} }
for _, s := range v.Sides { for _, s := range v.Sides {
<div class="row"> <div class="row">
@@ -601,25 +514,12 @@ templ catalogList(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>
} }
</section> <details class="card">
</div> <summary>Tuo ruokia tiedostosta</summary>
} @importForm()
</details>
templ sectionTitle(label string, n int) { </main>
<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
@@ -667,18 +567,15 @@ 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) {
<details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }> <section class="card" id="paaruoka">
<summary> <h3>
if f.ID == 0 { if f.ID == 0 {
Lisää pääruoka Lisää pääruoka
} else { } else {
Muokkaa pääruokaa Muokkaa pääruokaa
} }
</summary> </h3>
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
@@ -712,7 +609,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>
} }
</details> </section>
} }
templ categoryChip(value, label string, f mainForm) { templ categoryChip(value, label string, f mainForm) {
@@ -728,14 +625,14 @@ templ categoryChip(value, label string, f mainForm) {
} }
templ sideForm_(f sideForm) { templ sideForm_(f sideForm) {
<details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }> <section class="card" id="lisuke">
<summary> <h3>
if f.ID == 0 { if f.ID == 0 {
Lisää lisuke Lisää lisuke
} else { } else {
Muokkaa lisuketta Muokkaa lisuketta
} }
</summary> </h3>
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
@@ -752,7 +649,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>
} }
</details> </section>
} }
templ importForm() { templ importForm() {
-22
View File
@@ -150,28 +150,6 @@ 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" \