diff --git a/cmd/foodster/catalog_test.go b/cmd/foodster/catalog_test.go
index 4f55a36..d4ae0b1 100644
--- a/cmd/foodster/catalog_test.go
+++ b/cmd/foodster/catalog_test.go
@@ -138,7 +138,7 @@ func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
if err := softDeleteSide(h.db, id); err != nil {
t.Fatalf("softDeleteSide: %v", err)
}
- sides, err := listSides(h.db)
+ sides, err := listSides(h.db, "")
if err != nil {
t.Fatalf("listSides: %v", err)
}
diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go
index 2d49eca..247172f 100644
--- a/cmd/foodster/handlers.go
+++ b/cmd/foodster/handlers.go
@@ -11,17 +11,34 @@ import (
"time"
"github.com/a-h/templ"
+ "github.com/starfederation/datastar-go/datastar"
)
// maxUpload caps a pasted or uploaded bundle. A household catalog is a few
// kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20
-// historyDays is how far back the history under the logger walks.
-//
-// ponytail: a fixed window. After a year of daily entries this list is the
-// thing that needs paging; load more on scroll when it actually hurts.
-const historyDays = 60
+const (
+ // 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
+ // rather than all at once.
+ 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 {
db *sql.DB
@@ -60,18 +77,20 @@ func (a *app) date(r *http.Request) time.Time {
// page: every history row was already a link back into the logger, and the
// day switcher made them two views of the same thing.
type logView struct {
- Date time.Time
- Today time.Time
- Search string
- Entry *Entry // what is already logged for Date, if anything
- Chosen *Dish // dish picked, so the sides step is showing
- Checked map[int64]bool // sides ticked in that step
- ShowBoard bool
- Dishes []Dish // flat, only to know whether anything matched
- Groups []DishGroup // what the board actually renders
- Sides []Side
- New mainForm // inline "add the dish you were looking for"
- History []HistoryRow
+ Date time.Time
+ Today time.Time
+ Search string
+ Entry *Entry // what is already logged for Date, if anything
+ Chosen *Dish // dish picked, so the sides step is showing
+ Checked map[int64]bool // sides ticked in that step
+ ShowBoard bool
+ Dishes []Dish // flat, only to know whether anything matched
+ Groups []DishGroup // what the board actually renders
+ Sides []Side
+ New mainForm // inline "add the dish you were looking for"
+ History 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
// only soft-deleted, so it asks first.
@@ -128,18 +147,83 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
}
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)
}
}
- if v.History, err = history(a.db, a.loc, historyDays); err != nil {
+ v.HistoryDays = historyWindow(r)
+ 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)
}
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"`
+}
+
+// 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 := datastar.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 := datastar.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
// 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.
@@ -195,7 +279,9 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
log.Printf("list dishes: %v", err)
}
v.Groups = groupDishes(v.Dishes)
- if v.History, err = history(a.db, a.loc, historyDays); err != nil {
+ v.HistoryDays = historyWindow(r)
+ 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)
}
render(w, r, logPage(v))
@@ -276,6 +362,7 @@ type catalogView struct {
Side sideForm
Report *ImportReport
Mains int // count, for the header
+ Search string
// The row awaiting a delete confirmation, if any. A trash icon is easy to
// hit by accident, so the row asks before anything happens.
@@ -285,7 +372,8 @@ type catalogView struct {
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
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.
@@ -326,7 +414,7 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
v.Main.Categories = map[string]bool{}
}
- mains, err := listDishes(a.db, "")
+ mains, err := listDishes(a.db, v.Search)
if err != nil {
log.Printf("list mains: %v", err)
}
@@ -334,7 +422,7 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
sortByName(mains) // the catalog is managed, so position should be predictable
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)
}
render(w, r, catalogPage(v))
diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go
index fabe8af..b2182a9 100644
--- a/cmd/foodster/main.go
+++ b/cmd/foodster/main.go
@@ -158,8 +158,10 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd)
+ mux.HandleFunc("GET /etsi", a.searchBoard)
mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog)
+ mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css
index 2c5fd57..4fd1cf7 100644
--- a/cmd/foodster/static/app.css
+++ b/cmd/foodster/static/app.css
@@ -328,6 +328,19 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
color: var(--accent);
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); }
.entry time, .gapline time {
flex: none;
diff --git a/cmd/foodster/store.go b/cmd/foodster/store.go
index b1b72e8..8369440 100644
--- a/cmd/foodster/store.go
+++ b/cmd/foodster/store.go
@@ -128,9 +128,12 @@ func dishByID(db *sql.DB, id int64) (*Dish, error) {
return &d, nil
}
-func listSides(db *sql.DB) ([]Side, error) {
- rows, err := db.Query(
- `SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`)
+func listSides(db *sql.DB, search string) ([]Side, error) {
+ rows, err := db.Query(`
+ 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 {
return nil, err
}
@@ -395,36 +398,50 @@ type HistoryRow struct {
Entry *Entry
}
-// history walks back day by day from today, so a day nobody wrote down shows
-// up as an explicit gap rather than silently missing. It stops at the first
-// entry ever recorded — before that there is no history to be missing.
-func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) {
- var first string
- err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first)
- if err == sql.ErrNoRows || first == "" {
- return nil, nil
+// HistoryPage is one window of history plus where to continue from. After a
+// few years of daily entries the whole log is far too much to render at once.
+type HistoryPage struct {
+ Rows []HistoryRow
+ More bool // older entries exist beyond this window
+ Next time.Time // the day the next window starts at
+}
+
+// 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 {
- return nil, err
+ if !first.Valid || first.String == "" {
+ return HistoryPage{}, nil
}
- firstDate, err := time.ParseInLocation(dateLayout, first, loc)
+ firstDate, err := time.ParseInLocation(dateLayout, first.String, loc)
if err != nil {
- return nil, err
+ return HistoryPage{}, err
+ }
+ if from.Before(firstDate) {
+ return HistoryPage{}, nil
}
- now := today(loc)
- oldest := now.AddDate(0, 0, -days)
- if firstDate.After(oldest) {
+ oldest := from.AddDate(0, 0, -days+1)
+ page := HistoryPage{More: true}
+ if !firstDate.Before(oldest) {
oldest = firstDate
+ page.More = false
}
+ page.Next = oldest.AddDate(0, 0, -1)
- var rows []HistoryRow
- for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
+ for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
entry, err := entryFor(db, d)
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
}
diff --git a/cmd/foodster/store_test.go b/cmd/foodster/store_test.go
index 4ea0bed..1a2fdb7 100644
--- a/cmd/foodster/store_test.go
+++ b/cmd/foodster/store_test.go
@@ -235,10 +235,11 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
t.Fatalf("save -3: %v", err)
}
- rows, err := history(h.db, loc, 60)
+ page, err := history(h.db, loc, now, 60)
if err != nil {
t.Fatalf("history: %v", err)
}
+ rows := page.Rows
// Walks back to the oldest entry only: today, -1, -2, -3.
if len(rows) != 4 {
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" {
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) {
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 {
t.Fatalf("history: %v", err)
}
- if len(rows) != 0 {
- t.Errorf("%d rows for an empty log, want 0", len(rows))
+ if len(page.Rows) != 0 {
+ 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")
}
}
diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ
index 25e0a8e..2e46d0e 100644
--- a/cmd/foodster/views.templ
+++ b/cmd/foodster/views.templ
@@ -1,6 +1,7 @@
package main
import (
+ "encoding/json"
"fmt"
"strconv"
"strings"
@@ -51,6 +52,26 @@ func pickSeparator(v logView) string {
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.
func categoryLabels(d Dish) string {
names := make([]string, 0, len(d.Categories))
@@ -265,12 +286,12 @@ templ logPage(v logView) {
templ historyList(v logView) {
Ei vielä merkintöjä. { monthFI(row.Date) }Aiemmin
- if len(v.History) == 0 {
+ if len(v.History.Rows) == 0 {
Ei vielä pääruokia.
- } - // Grouped by category, alphabetical inside. The catalog is a list - // you manage, so a predictable position beats a useful one. - for _, g := range v.Groups { -Ei vielä lisukkeita.
- } - for _, s := range v.Sides { -+ if v.Search == "" { + Ei vielä pääruokia. + } else { + Ei osumia. + } +
+ } + // Grouped by category, alphabetical inside. The catalog is a list you + // manage, so a predictable position beats a useful one. + for _, g := range v.Groups { ++ if v.Search == "" { + Ei vielä lisukkeita. + } else { + Ei osumia. + } +
+ } + for _, s := range v.Sides { +