Tähteet — leftovers. Not a dish: it exists so a day can be recorded as "we ate what was already there" without inventing a meal nobody cooked. A special-flagged row created by migration 0002; never in the catalog, never editable, and excluded from the stage-2 suggester outright. It gets its own mark, a grey lidded tub — which exposed a bug where no categories drew the same icon as several. The real dish list. The seed bundle is now your December 2024 list: 37 mains, 9 sides. Letut and Pannari dropped as not-dinners, Kanakintut folded into Broilerin koipireidet, has_sides assigned by rule. The day list stays put. Choosing a day used to swap a panel in above the list and drop that day out of it, so rows below jumped up under the tap. The list is now the page; the selected day expands where it sits, with anchors so the viewport lands on the day rather than the top. Release pipeline fixed. The last release silently pushed the previous image: release declared image and push as prerequisites and your make runs -j16, so they raced. Also, push re-derived the tag by date-sorting, which is ambiguous when two tags share a commit. It now reads what image recorded, and verifies afterwards that the registry serves what was built. Co-authored-by: Esa Kataja <[email protected]> Reviewed-on: #2
471 lines
13 KiB
Go
471 lines
13 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
|
|
}
|
|
|
|
// 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
|
|
}
|