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 } // Rows flagged `special` in the database — Tähteet — are loggable but are not // food. They carry no category, never appear in the catalog, and PRD §8 // excludes them from the suggester: cooldown, coverage and weighting all skip // them. dishByID deliberately does not filter on the flag, because logging one // has to work like logging anything else. // CategoryKey picks the mark for a dish. One covering several categories // (tortillas, build-your-own pizza) gets the mixed one; carrying none at all // means it is not food, and Tähteet is not a mixture of anything. func (d Dish) CategoryKey() string { switch len(d.Categories) { case 0: return "tahteet" case 1: return categoryFI[d.Categories[0]] default: 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) { return queryDishes(db, search, false) } // listSpecial returns the entries that are not food — Tähteet and anything // like it. They are loggable but never suggested, and never appear in the // catalog, so they are fetched deliberately rather than by accident. func listSpecial(db *sql.DB, search string) ([]Dish, error) { return queryDishes(db, search, true) } func queryDishes(db *sql.DB, search string, special bool) ([]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 m.special = ? AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%') ORDER BY 5 DESC, m.name`, special, 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 }