Release: one log page, grouped dishes, live search #1

Merged
Kessinen merged 10 commits from dev into main 2026-09-05 19:28:54 +00:00
10 changed files with 412 additions and 104 deletions
Showing only changes of commit 0f2cd9f0a9 - Show all commits
+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" \