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