Compare commits
2
Commits
aa6541addd
...
1ce2d2b9f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ce2d2b9f5 | ||
|
|
0f2cd9f0a9 |
@@ -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)
|
||||
}
|
||||
|
||||
+113
-11
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
@@ -17,11 +18,27 @@ import (
|
||||
// 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,97 @@ 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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 +293,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 +376,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 +387,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 +428,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 +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
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"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) {
|
||||
db, err := openDB(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+43
-26
@@ -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
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstDate, err := time.ParseInLocation(dateLayout, first, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 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
|
||||
}
|
||||
|
||||
now := today(loc)
|
||||
oldest := now.AddDate(0, 0, -days)
|
||||
if firstDate.After(oldest) {
|
||||
// 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 !first.Valid || first.String == "" {
|
||||
return HistoryPage{}, nil
|
||||
}
|
||||
firstDate, err := time.ParseInLocation(dateLayout, first.String, loc)
|
||||
if err != nil {
|
||||
return HistoryPage{}, err
|
||||
}
|
||||
if from.Before(firstDate) {
|
||||
return HistoryPage{}, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+102
-18
@@ -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
|
||||
|
||||
@@ -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