Files
foodster/cmd/foodster/store.go
T
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

448 lines
12 KiB
Go

package main
import (
"database/sql"
"errors"
"slices"
"strings"
"time"
)
// dateLayout is how calendar days are stored and passed around in URLs.
const dateLayout = "2006-01-02"
// categoryFI maps the stored English category onto the Finnish key used for
// CSS custom properties and labels.
var categoryFI = map[string]string{
"meat": "liha",
"chicken": "kana",
"fish": "kala",
"vegetarian": "kasvis",
}
type Dish struct {
ID int64
Name string
HasSides bool
Categories []string
TimesEaten int
}
// CategoryKey is the class suffix for the colour dot. A dish covering several
// categories (tortillas, build-your-own pizza) gets the mixed marker.
func (d Dish) CategoryKey() string {
if len(d.Categories) == 1 {
return categoryFI[d.Categories[0]]
}
return "sek"
}
// Size buckets the dish by how often it has been eaten. The board draws
// favourites as bigger targets, so the likely answer is the easiest to hit.
func (d Dish) Size() string {
switch {
case d.TimesEaten >= 10:
return "xl"
case d.TimesEaten >= 6:
return "lg"
case d.TimesEaten >= 3:
return "md"
default:
return "sm"
}
}
type Side struct {
ID int64
Name string
}
type Entry struct {
Date time.Time
Main Dish
Sides []Side
}
func (e Entry) SidesLabel() string {
if len(e.Sides) == 0 {
return "Ei lisukkeita"
}
names := make([]string, len(e.Sides))
for i, s := range e.Sides {
names[i] = s.Name
}
return strings.Join(names, ", ")
}
// listDishes returns live mains ordered by how often they have been eaten.
// An empty search matches everything.
func listDishes(db *sql.DB, search string) ([]Dish, error) {
rows, err := db.Query(`
SELECT m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category)
FROM main_dish_categories c
WHERE c.main_dish_id = m.id), ''),
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
FROM main_dishes m
WHERE m.deleted_at IS NULL
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%')
ORDER BY 5 DESC, m.name`, search, search)
if err != nil {
return nil, err
}
defer rows.Close()
var dishes []Dish
for rows.Next() {
var d Dish
var cats string
if err := rows.Scan(&d.ID, &d.Name, &d.HasSides, &cats, &d.TimesEaten); err != nil {
return nil, err
}
if cats != "" {
d.Categories = strings.Split(cats, ",")
}
dishes = append(dishes, d)
}
return dishes, rows.Err()
}
func dishByID(db *sql.DB, id int64) (*Dish, error) {
var d Dish
var cats string
err := db.QueryRow(`
SELECT m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category)
FROM main_dish_categories c
WHERE c.main_dish_id = m.id), ''),
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
FROM main_dishes m
WHERE m.id = ? AND m.deleted_at IS NULL`, id,
).Scan(&d.ID, &d.Name, &d.HasSides, &cats, &d.TimesEaten)
if err != nil {
return nil, err
}
if cats != "" {
d.Categories = strings.Split(cats, ",")
}
return &d, nil
}
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
}
defer rows.Close()
var sides []Side
for rows.Next() {
var s Side
if err := rows.Scan(&s.ID, &s.Name); err != nil {
return nil, err
}
sides = append(sides, s)
}
return sides, rows.Err()
}
// entryFor returns the meal logged on a date, or nil when nothing is. Dishes
// are resolved even if they have since been soft-deleted, so history keeps
// displaying its names (PRD §6).
func entryFor(db *sql.DB, date time.Time) (*Entry, error) {
e := &Entry{Date: date}
var logID int64
var cats string
err := db.QueryRow(`
SELECT l.id, m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category)
FROM main_dish_categories c
WHERE c.main_dish_id = m.id), '')
FROM meal_log l
JOIN main_dishes m ON m.id = l.main_dish_id
WHERE l.date = ?`, date.Format(dateLayout),
).Scan(&logID, &e.Main.ID, &e.Main.Name, &e.Main.HasSides, &cats)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if cats != "" {
e.Main.Categories = strings.Split(cats, ",")
}
rows, err := db.Query(`
SELECT s.id, s.name
FROM meal_log_sides ls
JOIN side_dishes s ON s.id = ls.side_dish_id
WHERE ls.meal_log_id = ?
ORDER BY s.name`, logID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var s Side
if err := rows.Scan(&s.ID, &s.Name); err != nil {
return nil, err
}
e.Sides = append(e.Sides, s)
}
return e, rows.Err()
}
// saveEntry records what was eaten, replacing whatever was there. The unique
// constraint allows one entry per date, so editing is delete-then-insert; the
// old sides go with it through ON DELETE CASCADE.
func saveEntry(db *sql.DB, date time.Time, mainID int64, sideIDs []int64) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
day := date.Format(dateLayout)
if _, err := tx.Exec(`DELETE FROM meal_log WHERE date = ?`, day); err != nil {
return err
}
res, err := tx.Exec(
`INSERT INTO meal_log (date, main_dish_id) VALUES (?, ?)`, day, mainID)
if err != nil {
return err
}
logID, err := res.LastInsertId()
if err != nil {
return err
}
for _, id := range sideIDs {
if _, err := tx.Exec(
`INSERT OR IGNORE INTO meal_log_sides (meal_log_id, side_dish_id) VALUES (?, ?)`,
logID, id,
); err != nil {
return err
}
}
return tx.Commit()
}
func deleteEntry(db *sql.DB, date time.Time) error {
_, err := db.Exec(`DELETE FROM meal_log WHERE date = ?`, date.Format(dateLayout))
return err
}
// errNameTaken is returned when a name collides with a live dish. The
// comparison is case-insensitive and ignores soft-deleted rows (PRD §7.3).
var errNameTaken = errors.New("nimi on jo listalla")
func taken(err error) error {
// ponytail: string match rather than a driver-specific error type, so
// this survives swapping the driver.
if err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") {
return errNameTaken
}
return err
}
// createMain inserts a main dish and its categories in one transaction.
func createMain(db *sql.DB, name string, categories []string, hasSides bool) (int64, error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.Exec(
`INSERT INTO main_dishes (name, has_sides) VALUES (?, ?)`, name, hasSides)
if err != nil {
return 0, taken(err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if err := setCategories(tx, id, categories); err != nil {
return 0, err
}
return id, tx.Commit()
}
// updateMain rewrites a main dish, replacing its category set wholesale.
func updateMain(db *sql.DB, id int64, name string, categories []string, hasSides bool) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE main_dishes SET name = ?, has_sides = ? WHERE id = ? AND deleted_at IS NULL`,
name, hasSides, id,
); err != nil {
return taken(err)
}
if _, err := tx.Exec(
`DELETE FROM main_dish_categories WHERE main_dish_id = ?`, id); err != nil {
return err
}
if err := setCategories(tx, id, categories); err != nil {
return err
}
return tx.Commit()
}
func setCategories(tx *sql.Tx, mainID int64, categories []string) error {
for _, c := range categories {
if _, err := tx.Exec(
`INSERT OR IGNORE INTO main_dish_categories (main_dish_id, category) VALUES (?, ?)`,
mainID, c,
); err != nil {
return err
}
}
return nil
}
func createSide(db *sql.DB, name string) error {
_, err := db.Exec(`INSERT INTO side_dishes (name) VALUES (?)`, name)
return taken(err)
}
func updateSide(db *sql.DB, id int64, name string) error {
_, err := db.Exec(
`UPDATE side_dishes SET name = ? WHERE id = ? AND deleted_at IS NULL`, name, id)
return taken(err)
}
// Soft delete: the row stays so historical log entries keep resolving their
// names, but it disappears from the catalog and every picker (PRD §6).
func softDeleteMain(db *sql.DB, id int64) error {
_, err := db.Exec(
`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = ?`, id)
return err
}
func softDeleteSide(db *sql.DB, id int64) error {
_, err := db.Exec(
`UPDATE side_dishes SET deleted_at = datetime('now') WHERE id = ?`, id)
return err
}
func sideByID(db *sql.DB, id int64) (*Side, error) {
var s Side
err := db.QueryRow(
`SELECT id, name FROM side_dishes WHERE id = ? AND deleted_at IS NULL`, id,
).Scan(&s.ID, &s.Name)
if err != nil {
return nil, err
}
return &s, nil
}
// DishGroup is one category's worth of dishes for the catalog listing.
type DishGroup struct {
Key string
Label string
Dishes []Dish
}
// groupOrder fixes the order the catalog lists categories in. Sekalaiset is a
// display grouping for dishes covering more than one category, not a fifth
// category: the stored set is what PRD §8.1 counts for coverage, and one
// Tortillat still satisfies meat, chicken, fish and vegetarian at once.
var groupOrder = []DishGroup{
{Key: "liha", Label: "Liha"},
{Key: "kana", Label: "Kana"},
{Key: "kala", Label: "Kala"},
{Key: "kasvis", Label: "Kasvis"},
{Key: "sek", Label: "Sekalaiset"},
}
// groupDishes buckets dishes by category, keeping whatever order they arrived
// in. The caller decides that order: the log board hands over listDishes'
// frequency-then-name ordering, the catalog sorts by name first.
func groupDishes(dishes []Dish) []DishGroup {
byKey := make(map[string][]Dish, len(groupOrder))
for _, d := range dishes {
key := d.CategoryKey()
byKey[key] = append(byKey[key], d)
}
var groups []DishGroup
for _, g := range groupOrder {
in := byKey[g.Key]
if len(in) == 0 {
continue
}
groups = append(groups, DishGroup{Key: g.Key, Label: g.Label, Dishes: in})
}
return groups
}
// sortByName orders dishes alphabetically, case-insensitively.
func sortByName(dishes []Dish) {
slices.SortFunc(dishes, func(a, b Dish) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
}
// HistoryRow is one calendar day: either what was eaten or an unfilled gap.
type HistoryRow struct {
Date time.Time
Entry *Entry
}
// 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 !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)
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
entry, err := entryFor(db, d)
if err != nil {
return HistoryPage{}, err
}
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry})
}
return page, nil
}