Page the history and search both lists as you type

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.
This commit is contained in:
Esa Kataja
2026-09-05 21:57:15 +03:00
parent aa6541addd
commit 0f2cd9f0a9
10 changed files with 412 additions and 104 deletions
+1 -1
View File
@@ -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)
}
+99 -11
View File
@@ -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
@@ -71,7 +88,9 @@ type logView struct {
Groups []DishGroup // what the board actually renders
Sides []Side
New mainForm // inline "add the dish you were looking for"
History []HistoryRow
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.
@@ -286,6 +373,7 @@ type catalogView struct {
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
v := catalogView{
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))
+2
View File
@@ -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)
+13
View File
@@ -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;
+40 -23
View File
@@ -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
}
if err != nil {
return nil, err
return HistoryPage{}, err
}
firstDate, err := time.ParseInLocation(dateLayout, first, loc)
if !first.Valid || first.String == "" {
return HistoryPage{}, nil
}
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
}
+55 -4
View File
@@ -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")
}
}
+102 -18
View File
@@ -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) {
<section class="history">
<h3 class="sechead">Aiemmin</h3>
if len(v.History) == 0 {
if len(v.History.Rows) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p>
}
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() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
@@ -294,6 +315,12 @@ 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>
}
@@ -318,16 +345,38 @@ 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) {
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
}
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
<input
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>
// 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.
@boardList(v)
</div>
}
// boardList is what Datastar patches: it carries the id, so a plain text/html
// response is matched to it and swapped in place.
templ boardList(v logView) {
<div id="lauta">
// Grouped by category, and inside each group the most-eaten first — so
// a dish keeps a predictable neighbourhood while favourites still
// surface at the top of it.
for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3>
<div class="board">
@@ -339,6 +388,7 @@ templ board(v logView) {
if len(v.Dishes) == 0 {
@quickAddCard(v)
}
</div>
}
// quickAddCard turns a search that found nothing into the thing to do next.
@@ -482,12 +532,46 @@ templ catalogPage(v catalogView) {
@importReport(v.Report)
}
@mainForm_(v.Main)
@sideForm_(v.Side)
<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>
@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 reloading the forms above them.
templ catalogList(v catalogView) {
<div id="ruokalista">
if v.Mains == 0 {
<h3 class="sechead">Pääruuat</h3>
<p class="muted small">Ei vielä pääruokia.</p>
<p class="muted small">
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.
</p>
}
// 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 {
<h3 class="sechead">{ g.Label }</h3>
for _, d := range g.Dishes {
@@ -503,10 +587,15 @@ templ catalogPage(v catalogView) {
</div>
}
}
@sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3>
if len(v.Sides) == 0 {
<p class="muted small">Ei vielä lisukkeita.</p>
<p class="muted small">
if v.Search == "" {
Ei vielä lisukkeita.
} else {
Ei osumia.
}
</p>
}
for _, s := range v.Sides {
<div class="row">
@@ -514,12 +603,7 @@ templ catalogPage(v catalogView) {
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div>
}
<details class="card">
<summary>Tuo ruokia tiedostosta</summary>
@importForm()
</details>
</main>
}
</div>
}
// rowActions is a pencil and a bin, until the bin is tapped: then the row
+4
View File
@@ -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
+27
View File
@@ -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=
+22
View File
@@ -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" "<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.
future=$(date -d '+30 days' +%Y-%m-%d)
check "a future date falls back to today" \