2 Commits
Author SHA1 Message Date
Esa Kataja 1ce2d2b9f5 Decode Datastar signals without the SDK
The SDK was used for exactly one call, ReadSignals, which is a JSON decode of
a query parameter. It brought four modules with it, including an HTTP
compression stack, for an SSE generator this app never touches: the search
handlers answer with plain text/html and let Datastar match the fragment by
id.

Five lines replace it. Absent or empty is deliberately not an error — the
first request carries no signals, and rejecting it would 400 the initial
load. Tests cover absent, empty, malformed, and extra signals present.

Nothing changes on the client: Datastar still sends the same
?datastar={"haku":"..."}, and the smoke checks that assert that wire format
are what make the swap safe.
2026-09-05 22:00:40 +03:00
Esa Kataja 0f2cd9f0a9 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.
2026-09-05 21:57:15 +03:00
9 changed files with 425 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 { if err := softDeleteSide(h.db, id); err != nil {
t.Fatalf("softDeleteSide: %v", err) t.Fatalf("softDeleteSide: %v", err)
} }
sides, err := listSides(h.db) sides, err := listSides(h.db, "")
if err != nil { if err != nil {
t.Fatalf("listSides: %v", err) t.Fatalf("listSides: %v", err)
} }
+113 -11
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"io" "io"
"log" "log"
@@ -17,11 +18,27 @@ import (
// kilobytes; a megabyte is already absurd generosity. // kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20 const maxUpload = 1 << 20
// historyDays is how far back the history under the logger walks. const (
// // historyDays is one window of the history under the logger, and the step
// ponytail: a fixed window. After a year of daily entries this list is the // that "show more" grows it by. Older days arrive a window at a time
// thing that needs paging; load more on scroll when it actually hurts. // rather than all at once.
const historyDays = 60 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 { type app struct {
db *sql.DB db *sql.DB
@@ -71,7 +88,9 @@ type logView struct {
Groups []DishGroup // what the board actually renders Groups []DishGroup // what the board actually renders
Sides []Side Sides []Side
New mainForm // inline "add the dish you were looking for" 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 // Deleting a logged meal drops the row outright, unlike a dish which is
// only soft-deleted, so it asks first. // only soft-deleted, so it asks first.
@@ -128,18 +147,97 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
} }
if v.Chosen != nil && v.Chosen.HasSides { 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) 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) log.Printf("history: %v", err)
} }
render(w, r, logPage(v)) 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"`
}
// readSignals decodes that parameter.
//
// ponytail: the Datastar SDK does this too, but pulling it in for one JSON
// decode dragged along four modules — an HTTP compression stack among them —
// for an SSE generator this app never uses. Absent or empty is not an error:
// the first request carries no signals.
func readSignals(r *http.Request, into any) error {
raw := r.URL.Query().Get("datastar")
if raw == "" {
return nil
}
return json.Unmarshal([]byte(raw), into)
}
// 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 := 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 := 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 // 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 // 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. // mean a detour through Ruoat and a lost train of thought.
@@ -195,7 +293,9 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
log.Printf("list dishes: %v", err) log.Printf("list dishes: %v", err)
} }
v.Groups = groupDishes(v.Dishes) 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) log.Printf("history: %v", err)
} }
render(w, r, logPage(v)) render(w, r, logPage(v))
@@ -276,6 +376,7 @@ type catalogView struct {
Side sideForm Side sideForm
Report *ImportReport Report *ImportReport
Mains int // count, for the header Mains int // count, for the header
Search string
// The row awaiting a delete confirmation, if any. A trash icon is easy to // The row awaiting a delete confirmation, if any. A trash icon is easy to
// hit by accident, so the row asks before anything happens. // hit by accident, so the row asks before anything happens.
@@ -286,6 +387,7 @@ type catalogView struct {
func (a *app) catalog(w http.ResponseWriter, r *http.Request) { func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
v := catalogView{ 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. // ?muokkaa= loads a dish into its form; the same form adds and edits.
@@ -326,7 +428,7 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
v.Main.Categories = map[string]bool{} v.Main.Categories = map[string]bool{}
} }
mains, err := listDishes(a.db, "") mains, err := listDishes(a.db, v.Search)
if err != nil { if err != nil {
log.Printf("list mains: %v", err) log.Printf("list mains: %v", err)
} }
@@ -334,7 +436,7 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
sortByName(mains) // the catalog is managed, so position should be predictable sortByName(mains) // the catalog is managed, so position should be predictable
v.Groups = groupDishes(mains) 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) log.Printf("list sides: %v", err)
} }
render(w, r, catalogPage(v)) 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("GET /{$}", a.index)
mux.HandleFunc("POST /kirjaa", a.save) mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd) mux.HandleFunc("POST /lisaa", a.quickAdd)
mux.HandleFunc("GET /etsi", a.searchBoard)
mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain) mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish) mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
+30
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -75,6 +76,35 @@ func TestDateRejectsTheFuture(t *testing.T) {
} }
} }
func TestReadSignals(t *testing.T) {
cases := []struct {
name string
query string
want string
wantErr bool
}{
{"a signal", `/etsi?datastar=` + url.QueryEscape(`{"haku":"keitto"}`), "keitto", false},
{"other signals are ignored", `/etsi?datastar=` + url.QueryEscape(`{"haku":"kala","muu":1}`), "kala", false},
// The first request carries no signals at all; that is not a failure.
{"no parameter", "/etsi", "", false},
{"empty parameter", "/etsi?datastar=", "", false},
{"malformed json", "/etsi?datastar=%7Bnope", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var got searchSignals
err := readSignals(httptest.NewRequest(http.MethodGet, c.query, nil), &got)
if (err != nil) != c.wantErr {
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
}
if got.Haku != c.want {
t.Errorf("haku = %q, want %q", got.Haku, c.want)
}
})
}
}
func TestMigrateCreatesSchema(t *testing.T) { func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db") db, err := openDB(t.TempDir() + "/test.db")
if err != nil { if err != nil {
+13
View File
@@ -328,6 +328,19 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
color: var(--accent); color: var(--accent);
font-weight: 600; 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); } .gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
.entry time, .gapline time { .entry time, .gapline time {
flex: none; flex: none;
+40 -23
View File
@@ -128,9 +128,12 @@ func dishByID(db *sql.DB, id int64) (*Dish, error) {
return &d, nil return &d, nil
} }
func listSides(db *sql.DB) ([]Side, error) { func listSides(db *sql.DB, search string) ([]Side, error) {
rows, err := db.Query( rows, err := db.Query(`
`SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -395,36 +398,50 @@ type HistoryRow struct {
Entry *Entry Entry *Entry
} }
// history walks back day by day from today, so a day nobody wrote down shows // HistoryPage is one window of history plus where to continue from. After a
// up as an explicit gap rather than silently missing. It stops at the first // few years of daily entries the whole log is far too much to render at once.
// entry ever recorded — before that there is no history to be missing. type HistoryPage struct {
func history(db *sql.DB, loc *time.Location, days int) ([]HistoryRow, error) { Rows []HistoryRow
var first string More bool // older entries exist beyond this window
err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first) Next time.Time // the day the next window starts at
if err == sql.ErrNoRows || first == "" { }
return nil, nil
// 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 HistoryPage{}, err
return nil, 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 { if err != nil {
return nil, err return HistoryPage{}, err
}
if from.Before(firstDate) {
return HistoryPage{}, nil
} }
now := today(loc) oldest := from.AddDate(0, 0, -days+1)
oldest := now.AddDate(0, 0, -days) page := HistoryPage{More: true}
if firstDate.After(oldest) { if !firstDate.Before(oldest) {
oldest = firstDate oldest = firstDate
page.More = false
} }
page.Next = oldest.AddDate(0, 0, -1)
var rows []HistoryRow for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
entry, err := entryFor(db, d) entry, err := entryFor(db, d)
if err != nil { 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) t.Fatalf("save -3: %v", err)
} }
rows, err := history(h.db, loc, 60) page, err := history(h.db, loc, now, 60)
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
rows := page.Rows
// Walks back to the oldest entry only: today, -1, -2, -3. // Walks back to the oldest entry only: today, -1, -2, -3.
if len(rows) != 4 { if len(rows) != 4 {
t.Fatalf("%d rows, want 4", len(rows)) 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" { if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry) 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) { func TestHistoryEmptyWithoutEntries(t *testing.T) {
h := seeded(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 { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
if len(rows) != 0 { if len(page.Rows) != 0 {
t.Errorf("%d rows for an empty log, want 0", len(rows)) 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 package main
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -51,6 +52,26 @@ func pickSeparator(v logView) string {
return "&" 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. // categoryLabels lists a dish's categories in Finnish, for the catalog rows.
func categoryLabels(d Dish) string { func categoryLabels(d Dish) string {
names := make([]string, 0, len(d.Categories)) names := make([]string, 0, len(d.Categories))
@@ -265,12 +286,12 @@ templ logPage(v logView) {
templ historyList(v logView) { templ historyList(v logView) {
<section class="history"> <section class="history">
<h3 class="sechead">Aiemmin</h3> <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> <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 !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> <p class="monthrule">{ monthFI(row.Date) }</p>
} }
if row.Entry != nil { 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> </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) { templ board(v logView) {
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/" class="searchrow"> <form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) { if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <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> </form>
// Grouped by category, and inside each group the most-eaten first — so a @boardList(v)
// dish keeps a predictable neighbourhood while favourites still surface </div>
// at the top of it. }
// 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 { for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3> <h3 class="sechead">{ g.Label }</h3>
<div class="board"> <div class="board">
@@ -339,6 +388,7 @@ templ board(v logView) {
if len(v.Dishes) == 0 { if len(v.Dishes) == 0 {
@quickAddCard(v) @quickAddCard(v)
} }
</div>
} }
// quickAddCard turns a search that found nothing into the thing to do next. // quickAddCard turns a search that found nothing into the thing to do next.
@@ -482,12 +532,46 @@ templ catalogPage(v catalogView) {
@importReport(v.Report) @importReport(v.Report)
} }
@mainForm_(v.Main) @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 { if v.Mains == 0 {
<h3 class="sechead">Pääruuat</h3> <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 </p>
// you manage, so a predictable position beats a useful one. }
// 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 { for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3> <h3 class="sechead">{ g.Label }</h3>
for _, d := range g.Dishes { for _, d := range g.Dishes {
@@ -503,10 +587,15 @@ templ catalogPage(v catalogView) {
</div> </div>
} }
} }
@sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3> <h3 class="sechead">Lisukkeet</h3>
if len(v.Sides) == 0 { 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 { for _, s := range v.Sides {
<div class="row"> <div class="row">
@@ -514,12 +603,7 @@ templ catalogPage(v catalogView) {
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") @rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div> </div>
} }
<details class="card"> </div>
<summary>Tuo ruokia tiedostosta</summary>
@importForm()
</details>
</main>
}
} }
// rowActions is a pencil and a bin, until the bin is tapped: then the row // rowActions is a pencil and a bin, until the bin is tapped: then the row
+22
View File
@@ -150,6 +150,28 @@ check "the day is empty again" \
check "search filters the board" \ check "search filters the board" \
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto" "$(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. # Nothing was eaten tomorrow. A future date is clamped rather than logged.
future=$(date -d '+30 days' +%Y-%m-%d) future=$(date -d '+30 days' +%Y-%m-%d)
check "a future date falls back to today" \ check "a future date falls back to today" \