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.
448 lines
12 KiB
Go
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
|
|
}
|