From 0f2cd9f0a9a7e1894715099007d126ef039ef27a Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 21:57:15 +0300 Subject: [PATCH] Page the history and search both lists as you type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that keep the lists usable after years of entries, and the first real use of the Datastar client that has been shipping unused. Paging: history() now walks back from any given day and returns the rows plus whether older ones exist. The page shows a 30-day window and "Näytä lisää" grows it by another 30 through ?paivat=, capped at five years so a hand-edited URL cannot ask for a decade of rows at once. Two tests check that consecutive windows meet exactly, repeating no day and skipping none, which is the mistake this shape invites. Live search: both search boxes bind to a Datastar signal and re-render their list 250 ms after typing stops. The board search and the catalog search each return only their own fragment, and the catalog search covers sides as well as mains. Three things worth knowing about the Datastar side. Its attribute syntax is colon-separated in v1.0.3 — data-on:input, not data-on-input; the dashed form parses as a plugin named "on-input", matches nothing, and fails silently. A plain text/html response is accepted and matched to the element by id, so there is no SSE stream to manage and the SDK is used only for ReadSignals. And both boxes are still ordinary GET forms, so ?haku= filters server-side with JavaScript off: the live version is an enhancement, not a requirement. Smoke checks send the real ?datastar={"haku":"..."} wire format and assert the response is a fragment rather than a whole page. --- cmd/foodster/catalog_test.go | 2 +- cmd/foodster/handlers.go | 134 +++++++++++++++++++----- cmd/foodster/main.go | 2 + cmd/foodster/static/app.css | 13 +++ cmd/foodster/store.go | 63 +++++++----- cmd/foodster/store_test.go | 59 ++++++++++- cmd/foodster/views.templ | 190 +++++++++++++++++++++++++---------- go.mod | 4 + go.sum | 27 +++++ scripts/smoke.sh | 22 ++++ 10 files changed, 412 insertions(+), 104 deletions(-) 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) {

Aiemmin

- if len(v.History) == 0 { + if len(v.History.Rows) == 0 {

Ei vielä merkintöjä.

} - for i, row := range v.History { + for i, row := range v.History.Rows { if !row.Date.Equal(v.Date) { - if i == 0 || v.History[i-1].Date.Month() != row.Date.Month() { + if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {

{ monthFI(row.Date) }

} if row.Entry != nil { @@ -294,6 +315,12 @@ templ historyList(v logView) { } } } + if v.History.More { + - if !v.Date.Equal(v.Today) { - - } - - - // 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 { -

{ g.Label }

-
- for _, d := range g.Dishes { - @dishPill(d, v) +
+
+ if !v.Date.Equal(v.Today) { + } -
- } - if len(v.Dishes) == 0 { - @quickAddCard(v) - } + + + @boardList(v) +
+} + +// 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) { +
+ // 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 { +

{ g.Label }

+
+ for _, d := range g.Dishes { + @dishPill(d, v) + } +
+ } + if len(v.Dishes) == 0 { + @quickAddCard(v) + } +
} // quickAddCard turns a search that found nothing into the thing to do next. @@ -482,38 +532,22 @@ templ catalogPage(v catalogView) { @importReport(v.Report) } @mainForm_(v.Main) - if v.Mains == 0 { -

Pääruuat

-

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 { -

{ g.Label }

- for _, d := range g.Dishes { -
- @categoryIcon(d.CategoryKey()) -
-
{ d.Name }
- if !d.HasSides { -
Ei lisukkeita
- } -
- @rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa") -
- } - } @sideForm_(v.Side) -

Lisukkeet

- if len(v.Sides) == 0 { -

Ei vielä lisukkeita.

- } - for _, s := range v.Sides { -
-
{ s.Name }
- @rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") -
- } +
+
+ +
+ @catalogList(v) +
Tuo ruokia tiedostosta @importForm() @@ -522,6 +556,56 @@ templ catalogPage(v catalogView) { } } +// catalogList carries the id Datastar patches, so typing in the search box +// swaps the lists without reloading the forms above them. +templ catalogList(v catalogView) { +
+ if v.Mains == 0 { +

Pääruuat

+

+ 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 { +

{ g.Label }

+ for _, d := range g.Dishes { +
+ @categoryIcon(d.CategoryKey()) +
+
{ d.Name }
+ if !d.HasSides { +
Ei lisukkeita
+ } +
+ @rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa") +
+ } + } +

Lisukkeet

+ if len(v.Sides) == 0 { +

+ if v.Search == "" { + Ei vielä lisukkeita. + } else { + Ei osumia. + } +

+ } + for _, s := range v.Sides { +
+
{ s.Name }
+ @rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") +
+ } +
+} + // rowActions is a pencil and a bin, until the bin is tapped: then the row // asks. An icon is a smaller target to hit by accident than a word, and the // dish disappears from every picker the moment it goes. diff --git a/go.mod b/go.mod index afe52eb..2a495cc 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,13 @@ tool github.com/a-h/templ/cmd/templ require ( github.com/a-h/templ v0.3.1020 + github.com/starfederation/datastar-go v1.2.2 golang.org/x/time v0.15.0 modernc.org/sqlite v1.58.0 ) require ( + github.com/CAFxX/httpcompression v0.0.9 // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -19,11 +21,13 @@ require ( github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/go.sum b/go.sum index 3fddf9d..9d2fc84 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,17 @@ +github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg= +github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -16,6 +20,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= +github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= @@ -24,6 +30,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -33,12 +43,27 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/starfederation/datastar-go v1.2.2 h1:f+U2y5FY5tXgXaTVXkuKu+Tz9uh7BU1j8jxthYmcC9Q= +github.com/starfederation/datastar-go v1.2.2/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g= +github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= @@ -54,6 +79,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 721feef..985b711 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -150,6 +150,28 @@ check "the day is empty again" \ check "search filters the board" \ "$(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" "