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:
@@ -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)
|
||||
}
|
||||
|
||||
+111
-23
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+137
-53
@@ -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,27 +345,50 @@ 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) {
|
||||
<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"/>
|
||||
</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.
|
||||
for _, g := range v.Groups {
|
||||
<h3 class="sechead">{ g.Label }</h3>
|
||||
<div class="board">
|
||||
for _, d := range g.Dishes {
|
||||
@dishPill(d, v)
|
||||
<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) }/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if len(v.Dishes) == 0 {
|
||||
@quickAddCard(v)
|
||||
}
|
||||
<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>
|
||||
@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">
|
||||
for _, d := range g.Dishes {
|
||||
@dishPill(d, v)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if len(v.Dishes) == 0 {
|
||||
@quickAddCard(v)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// 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 {
|
||||
<h3 class="sechead">Pääruuat</h3>
|
||||
<p class="muted small">Ei vielä pääruokia.</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 {
|
||||
<div class="row">
|
||||
@categoryIcon(d.CategoryKey())
|
||||
<div class="rowtext">
|
||||
<div class="nm">{ d.Name }</div>
|
||||
if !d.HasSides {
|
||||
<div class="sd">Ei lisukkeita</div>
|
||||
}
|
||||
</div>
|
||||
@rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@sideForm_(v.Side)
|
||||
<h3 class="sechead">Lisukkeet</h3>
|
||||
if len(v.Sides) == 0 {
|
||||
<p class="muted small">Ei vielä lisukkeita.</p>
|
||||
}
|
||||
for _, s := range v.Sides {
|
||||
<div class="row">
|
||||
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
|
||||
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
|
||||
</div>
|
||||
}
|
||||
<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()
|
||||
@@ -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) {
|
||||
<div id="ruokalista">
|
||||
if v.Mains == 0 {
|
||||
<h3 class="sechead">Pääruuat</h3>
|
||||
<p class="muted small">
|
||||
if v.Search == "" {
|
||||
Ei vielä pääruokia.
|
||||
} else {
|
||||
Ei osumia.
|
||||
}
|
||||
</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 {
|
||||
<div class="row">
|
||||
@categoryIcon(d.CategoryKey())
|
||||
<div class="rowtext">
|
||||
<div class="nm">{ d.Name }</div>
|
||||
if !d.HasSides {
|
||||
<div class="sd">Ei lisukkeita</div>
|
||||
}
|
||||
</div>
|
||||
@rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<h3 class="sechead">Lisukkeet</h3>
|
||||
if len(v.Sides) == 0 {
|
||||
<p class="muted small">
|
||||
if v.Search == "" {
|
||||
Ei vielä lisukkeita.
|
||||
} else {
|
||||
Ei osumia.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
for _, s := range v.Sides {
|
||||
<div class="row">
|
||||
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
|
||||
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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" \
|
||||
|
||||
Reference in New Issue
Block a user