Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768ab50140 | ||
|
|
d743327cd2 |
@@ -113,6 +113,23 @@ in English.
|
||||
Side dishes live in their own table and have no category. The pool is
|
||||
expected to stay small.
|
||||
|
||||
### Tähteet — leftovers (stage 1)
|
||||
|
||||
A single built-in entry, flagged `special` on the main dish table. It is
|
||||
**not food**: it exists so a day can be recorded as "we ate what was already
|
||||
there" without inventing a meal that was never cooked.
|
||||
|
||||
- No category, which is why it cannot be an ordinary main: those must have
|
||||
at least one.
|
||||
- Created by a migration. The household does not add, edit or delete it, and
|
||||
it never appears in the Ruuat catalog.
|
||||
- Loggable exactly like any other entry, and shown on the log board apart
|
||||
from the categories.
|
||||
- **The stage 2 suggester must never propose it.** It is excluded from the
|
||||
eligible pool outright, so cooldown, category coverage (§8.1) and
|
||||
frequency weighting (§8.2) all skip it — despite it being among the
|
||||
most-logged entries.
|
||||
|
||||
### Meal log entry ("what was actually eaten") (stage 1)
|
||||
- `id`
|
||||
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field
|
||||
|
||||
@@ -131,6 +131,55 @@ func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTahteetIsLoggableButNotFood(t *testing.T) {
|
||||
h := seeded(t)
|
||||
|
||||
// The migration creates it; nobody adds it.
|
||||
special, err := listSpecial(h.db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("listSpecial: %v", err)
|
||||
}
|
||||
if len(special) != 1 || special[0].Name != "Tähteet" {
|
||||
t.Fatalf("special = %+v, want exactly Tähteet", special)
|
||||
}
|
||||
|
||||
// It must not turn up among the dishes: not on the board's categories,
|
||||
// not in the catalog, and not in whatever the suggester later draws from.
|
||||
dishes, err := listDishes(h.db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("listDishes: %v", err)
|
||||
}
|
||||
for _, d := range dishes {
|
||||
if d.Name == "Tähteet" {
|
||||
t.Fatal("Tähteet appears among the dishes")
|
||||
}
|
||||
}
|
||||
|
||||
// It carries no category at all, which is why it cannot be an ordinary
|
||||
// dish: those are required to have one.
|
||||
if len(special[0].Categories) != 0 {
|
||||
t.Errorf("categories = %v, want none", special[0].Categories)
|
||||
}
|
||||
// And it gets its own mark: no categories is not the same as several, so
|
||||
// it must not fall through to the mixed Sekalaiset one.
|
||||
if got := special[0].CategoryKey(); got != "tahteet" {
|
||||
t.Errorf("CategoryKey = %q, want tahteet", got)
|
||||
}
|
||||
|
||||
// Logging it has to work exactly like logging a real meal.
|
||||
date := day(t, "2026-09-05")
|
||||
if err := saveEntry(h.db, date, special[0].ID, nil); err != nil {
|
||||
t.Fatalf("saveEntry: %v", err)
|
||||
}
|
||||
entry, err := entryFor(h.db, date)
|
||||
if err != nil || entry == nil {
|
||||
t.Fatalf("entryFor: %v, %v", entry, err)
|
||||
}
|
||||
if entry.Main.Name != "Tähteet" {
|
||||
t.Errorf("logged %q, want Tähteet", entry.Main.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
|
||||
h := seeded(t)
|
||||
id := h.sideNamed(t, "Riisi")
|
||||
|
||||
+39
-13
@@ -86,6 +86,7 @@ type logView struct {
|
||||
ShowBoard bool
|
||||
Dishes []Dish // flat, only to know whether anything matched
|
||||
Groups []DishGroup // what the board actually renders
|
||||
Special []Dish // Tähteet and the like: loggable, but not food
|
||||
Sides []Side
|
||||
New mainForm // inline "add the dish you were looking for"
|
||||
History HistoryPage
|
||||
@@ -142,6 +143,9 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||
// listDishes already orders by frequency then name, so grouping keeps
|
||||
// the favourites at the top of each category.
|
||||
v.Groups = groupDishes(v.Dishes)
|
||||
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
||||
log.Printf("list special: %v", err)
|
||||
}
|
||||
// Seed the inline add form with whatever was searched for, so a miss
|
||||
// turns straight into "add it" without retyping.
|
||||
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
||||
@@ -152,13 +156,33 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
a.loadDays(r, &v)
|
||||
render(w, r, logPage(v))
|
||||
}
|
||||
|
||||
// loadDays fills the day list. The selected day expands inside it rather than
|
||||
// in a panel above it, so choosing a day from the list does not reorder the
|
||||
// list underneath the tap.
|
||||
func (a *app) loadDays(r *http.Request, v *logView) {
|
||||
v.HistoryDays = historyWindow(r)
|
||||
|
||||
// The window has to reach the selected day, or it would have nowhere to
|
||||
// expand.
|
||||
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
|
||||
v.HistoryDays = min(reach, maxHistoryDays)
|
||||
}
|
||||
v.HistoryMore = v.HistoryDays + historyDays
|
||||
if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
|
||||
|
||||
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
|
||||
if err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
}
|
||||
|
||||
render(w, r, logPage(v))
|
||||
// Nothing logged ever: the selected day is still the one being worked on,
|
||||
// so it needs a row of its own to open in.
|
||||
if len(page.Rows) == 0 {
|
||||
page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}}
|
||||
}
|
||||
v.History = page
|
||||
}
|
||||
|
||||
// searchSignals is what Datastar sends back: for a GET it JSON-encodes the
|
||||
@@ -210,6 +234,9 @@ func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
v.Dishes = dishes
|
||||
v.Groups = groupDishes(dishes)
|
||||
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
||||
log.Printf("search special: %v", err)
|
||||
}
|
||||
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
||||
|
||||
fragment(w, r, boardList(v))
|
||||
@@ -293,11 +320,10 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("list dishes: %v", err)
|
||||
}
|
||||
v.Groups = groupDishes(v.Dishes)
|
||||
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)
|
||||
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
||||
log.Printf("list special: %v", err)
|
||||
}
|
||||
a.loadDays(r, &v)
|
||||
render(w, r, logPage(v))
|
||||
}
|
||||
|
||||
@@ -307,7 +333,9 @@ func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.T
|
||||
if strings.Contains(target, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||
http.Redirect(w, r,
|
||||
target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date),
|
||||
http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// save records the meal and redirects, so a refresh cannot double-post.
|
||||
@@ -345,12 +373,10 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
||||
a.redirectToDay(w, r, date)
|
||||
}
|
||||
|
||||
// redirectToDay returns to the day in the list, anchor included, so saving or
|
||||
// deleting leaves the viewport where the work was happening.
|
||||
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
||||
target := "/"
|
||||
if !date.Equal(today(a.loc)) {
|
||||
target += "?pvm=" + date.Format(dateLayout)
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// mainForm and sideForm carry what the user typed, so a rejected submission
|
||||
|
||||
@@ -246,14 +246,16 @@ func TestMealLogOneEntryPerDate(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil {
|
||||
// Ids well clear of anything the migrations create.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO main_dishes (id, name) VALUES (101, 'Lohikeitto'), (102, 'Lihapullat')`); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil {
|
||||
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); err != nil {
|
||||
t.Fatalf("first entry: %v", err)
|
||||
}
|
||||
// PRD §6: a second dinner for the same day must be refused.
|
||||
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil {
|
||||
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 102)`); err == nil {
|
||||
t.Error("second entry for the same date was accepted, want a unique violation")
|
||||
}
|
||||
}
|
||||
@@ -265,18 +267,18 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil {
|
||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (101, 'Kanacurry')`); err != nil {
|
||||
t.Fatalf("first insert: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil {
|
||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err == nil {
|
||||
t.Error("case-variant duplicate was accepted, want a unique violation")
|
||||
}
|
||||
|
||||
// Soft-deleting the original frees the name again (PRD §7.3).
|
||||
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
|
||||
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 101`); err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil {
|
||||
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err != nil {
|
||||
t.Errorf("name still blocked after soft delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 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 that was never cooked. It has no category,
|
||||
-- it is not something the household adds or edits, and the stage 2 suggester
|
||||
-- must never propose it (PRD §8).
|
||||
--
|
||||
-- Modelled as a flagged row in main_dishes rather than a nullable
|
||||
-- main_dish_id on meal_log: the log keeps one shape, and every foreign key
|
||||
-- and join carries on working untouched.
|
||||
|
||||
ALTER TABLE main_dishes
|
||||
ADD COLUMN special INTEGER NOT NULL DEFAULT 0 CHECK (special IN (0, 1));
|
||||
|
||||
-- OR IGNORE in case a household already typed a dish by this name: the unique
|
||||
-- index on lower(name) would otherwise fail the migration. Their row stays as
|
||||
-- an ordinary dish, which is wrong but harmless and fixable by hand.
|
||||
INSERT OR IGNORE INTO main_dishes (name, has_sides, special)
|
||||
VALUES ('Tähteet', 0, 1);
|
||||
@@ -134,6 +134,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
||||
.c-kana { color: var(--kana); }
|
||||
.c-kala { color: var(--kala); }
|
||||
.c-kasvis { color: var(--kasvis); }
|
||||
/* Not a category, so not a category colour. */
|
||||
.c-tahteet { color: var(--muted); }
|
||||
|
||||
/* Day switcher */
|
||||
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
|
||||
@@ -208,6 +210,20 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* Not food: set apart from the categories, and deliberately quiet. */
|
||||
.special {
|
||||
margin-top: 22px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.pill.plain {
|
||||
font-size: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--sunk);
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
|
||||
.pill.lg { font-size: 18px; padding: 12px 16px; }
|
||||
.pill.md { font-size: 15.5px; padding: 11px 14px; }
|
||||
@@ -310,8 +326,26 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* History sits under the logger on the same page, so whole rows are links. */
|
||||
.history { margin-top: 8px; }
|
||||
/* The day list is the page; rows are links and the selected one expands. */
|
||||
.history { margin-top: 4px; }
|
||||
|
||||
/* Marked with a bar down the side, not rules above and below: the rows either
|
||||
side already draw a bottom border, so a horizontal rule here doubled up.
|
||||
scroll-margin keeps the anchor off the viewport edge. */
|
||||
.open {
|
||||
scroll-margin-top: 12px;
|
||||
margin: 8px 0 18px;
|
||||
padding: 10px 0 4px 13px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.openday {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.entry, .gapline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
+28
-5
@@ -28,13 +28,24 @@ type Dish struct {
|
||||
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.
|
||||
// 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 {
|
||||
if len(d.Categories) == 1 {
|
||||
switch len(d.Categories) {
|
||||
case 0:
|
||||
return "tahteet"
|
||||
case 1:
|
||||
return categoryFI[d.Categories[0]]
|
||||
default:
|
||||
return "sek"
|
||||
}
|
||||
return "sek"
|
||||
}
|
||||
|
||||
// Size buckets the dish by how often it has been eaten. The board draws
|
||||
@@ -77,6 +88,17 @@ func (e Entry) SidesLabel() string {
|
||||
// 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)
|
||||
@@ -85,8 +107,9 @@ func listDishes(db *sql.DB, search string) ([]Dish, error) {
|
||||
(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`, search, search)
|
||||
ORDER BY 5 DESC, m.name`, special, search, search)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+107
-43
@@ -43,6 +43,18 @@ func dayURL(base string, d, now time.Time) string {
|
||||
return base + "?pvm=" + isoDate(d)
|
||||
}
|
||||
|
||||
// dayAnchor names the element a day expands into.
|
||||
func dayAnchor(d time.Time) string {
|
||||
return "paiva-" + isoDate(d)
|
||||
}
|
||||
|
||||
// dayLink opens a day and lands the viewport on it. Without the fragment the
|
||||
// browser would jump to the top of a page whose selected day might be far
|
||||
// down the list.
|
||||
func dayLink(d, now time.Time) string {
|
||||
return dayURL("/", d, now) + "#" + dayAnchor(d)
|
||||
}
|
||||
|
||||
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
|
||||
// any day but today.
|
||||
func pickSeparator(v logView) string {
|
||||
@@ -52,6 +64,17 @@ func pickSeparator(v logView) string {
|
||||
return "&"
|
||||
}
|
||||
|
||||
// stepURL is any link inside the open day. It keeps the anchor, so picking a
|
||||
// dish or cancelling stays where the day is instead of throwing the viewport
|
||||
// back to the top of the list.
|
||||
func stepURL(v logView, param string) string {
|
||||
url := dayURL("/", v.Date, v.Today)
|
||||
if param != "" {
|
||||
url += pickSeparator(v) + param
|
||||
}
|
||||
return url + "#" + dayAnchor(v.Date)
|
||||
}
|
||||
|
||||
// jsString renders a Go string as a JavaScript literal, for the data-signals
|
||||
// attribute that seeds the search box.
|
||||
func jsString(s string) string {
|
||||
@@ -183,6 +206,10 @@ templ categoryIcon(key string) {
|
||||
<span class="cat c-kasvis">
|
||||
@glyphKasvis()
|
||||
</span>
|
||||
case "tahteet":
|
||||
<span class="cat c-tahteet">
|
||||
@glyphTahteet()
|
||||
</span>
|
||||
default:
|
||||
<span class="cat">
|
||||
@glyphSekalaiset()
|
||||
@@ -190,6 +217,15 @@ templ categoryIcon(key string) {
|
||||
}
|
||||
}
|
||||
|
||||
// A lidded tub. Tähteet is not food and not a mixture of categories, so it
|
||||
// gets neither a category colour nor the quartered mark.
|
||||
templ glyphTahteet() {
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
|
||||
<rect x="1.4" y="2.6" width="13.2" height="3" rx="1.3"></rect>
|
||||
<path d="M2.8 6.8h10.4l-.9 6.6a1.6 1.6 0 0 1-1.6 1.4H5.3a1.6 1.6 0 0 1-1.6-1.4z"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
// A steak, its bone knocked out with fill-rule so the hole is transparent on
|
||||
// whatever background the icon lands on.
|
||||
templ glyphLiha() {
|
||||
@@ -268,51 +304,57 @@ templ logPage(v logView) {
|
||||
@daySwitch(v)
|
||||
</header>
|
||||
<main class="pad">
|
||||
switch {
|
||||
case v.Chosen != nil:
|
||||
@sidesStep(v)
|
||||
case v.ShowBoard:
|
||||
@board(v)
|
||||
default:
|
||||
@loggedCard(v)
|
||||
}
|
||||
@historyList(v)
|
||||
@dayList(v)
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
// historyList sits under the day being logged: the two were always one thing,
|
||||
// since every row here is a link back into the logger above it.
|
||||
templ historyList(v logView) {
|
||||
// dayList is the whole page: every day back through the window, with the
|
||||
// selected one expanded where it sits. Opening a day used to swap in a panel
|
||||
// above the list and drop that day out of it, so the rows below jumped up
|
||||
// under the tap. Now nothing moves — the row grows.
|
||||
templ dayList(v logView) {
|
||||
<section class="history">
|
||||
<h3 class="sechead">Aiemmin</h3>
|
||||
if len(v.History.Rows) == 0 {
|
||||
<p class="muted small">Ei vielä merkintöjä.</p>
|
||||
}
|
||||
for i, row := range v.History.Rows {
|
||||
if !row.Date.Equal(v.Date) {
|
||||
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
|
||||
<p class="monthrule">{ monthFI(row.Date) }</p>
|
||||
}
|
||||
if row.Entry != nil {
|
||||
<a class="entry" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
|
||||
<time>{ dayLabelFI(row.Date) }</time>
|
||||
<div>
|
||||
<div class="nm">
|
||||
@categoryIcon(row.Entry.Main.CategoryKey())
|
||||
{ row.Entry.Main.Name }
|
||||
</div>
|
||||
<div class="sd">{ row.Entry.SidesLabel() }</div>
|
||||
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
|
||||
<p class="monthrule">{ monthFI(row.Date) }</p>
|
||||
}
|
||||
if row.Date.Equal(v.Date) {
|
||||
<div class="open" id={ dayAnchor(row.Date) }>
|
||||
<p class="openday">
|
||||
if row.Date.Equal(v.Today) {
|
||||
Tänään
|
||||
} else {
|
||||
{ longDateFI(row.Date) }
|
||||
}
|
||||
</p>
|
||||
switch {
|
||||
case v.Chosen != nil:
|
||||
@sidesStep(v)
|
||||
case v.ShowBoard:
|
||||
@board(v)
|
||||
default:
|
||||
@loggedCard(v)
|
||||
}
|
||||
</div>
|
||||
} else if row.Entry != nil {
|
||||
<a class="entry" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
|
||||
<time>{ dayLabelFI(row.Date) }</time>
|
||||
<div>
|
||||
<div class="nm">
|
||||
@categoryIcon(row.Entry.Main.CategoryKey())
|
||||
{ row.Entry.Main.Name }
|
||||
</div>
|
||||
<span class="chev">›</span>
|
||||
</a>
|
||||
} else {
|
||||
<a class="gapline" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
|
||||
<time>{ dayLabelFI(row.Date) }</time>
|
||||
<span>Ei merkintää</span>
|
||||
<span class="act">Merkitse</span>
|
||||
</a>
|
||||
}
|
||||
<div class="sd">{ row.Entry.SidesLabel() }</div>
|
||||
</div>
|
||||
<span class="chev">›</span>
|
||||
</a>
|
||||
} else {
|
||||
<a class="gapline" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
|
||||
<time>{ dayLabelFI(row.Date) }</time>
|
||||
<span>Ei merkintää</span>
|
||||
<span class="act">Merkitse</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
if v.History.More {
|
||||
@@ -385,6 +427,28 @@ templ boardList(v logView) {
|
||||
}
|
||||
</div>
|
||||
}
|
||||
// Tähteet is not food, so it sits apart from the categories rather
|
||||
// than inside one. Fixed size: it will be among the most-logged
|
||||
// entries, and it should not tower over the actual cooking.
|
||||
if len(v.Special) > 0 {
|
||||
<div class="special">
|
||||
for _, d := range v.Special {
|
||||
<a
|
||||
class="pill plain"
|
||||
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
|
||||
>
|
||||
@categoryIcon(d.CategoryKey())
|
||||
{ d.Name }
|
||||
if d.TimesEaten > 0 {
|
||||
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
// Tähteet always matches an empty search, so the add card keys off the
|
||||
// real dishes only: otherwise a fresh install would show leftovers and
|
||||
// no way to add anything.
|
||||
if len(v.Dishes) == 0 {
|
||||
@quickAddCard(v)
|
||||
}
|
||||
@@ -438,7 +502,7 @@ templ quickAddCard(v logView) {
|
||||
templ dishPill(d Dish, v logView) {
|
||||
<a
|
||||
class={ "pill", d.Size() }
|
||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
|
||||
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
|
||||
>
|
||||
@categoryIcon(d.CategoryKey())
|
||||
{ d.Name }
|
||||
@@ -476,7 +540,7 @@ templ sidesStep(v logView) {
|
||||
}
|
||||
<button class="primary" type="submit">Tallenna</button>
|
||||
</form>
|
||||
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
|
||||
<a class="ghost" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -501,17 +565,17 @@ templ loggedCard(v logView) {
|
||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||
<button class="btn del" type="submit">Kyllä, poista</button>
|
||||
</form>
|
||||
<a class="btn" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
|
||||
<a class="btn" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="pair">
|
||||
<a
|
||||
class="btn"
|
||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "muuta=1") }
|
||||
href={ templ.SafeURL(stepURL(v, "muuta=1")) }
|
||||
>Muokkaa</a>
|
||||
<a
|
||||
class="btn del"
|
||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "poista=1") }
|
||||
href={ templ.SafeURL(stepURL(v, "poista=1")) }
|
||||
>Poista</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
+15
-2
@@ -114,6 +114,11 @@ check "malformed JSON is explained" \
|
||||
board=$(curl -s -u ":$pass" "http://$addr/")
|
||||
check "board lists imported dishes" "$board" "Lihapullat"
|
||||
|
||||
# Tähteet is loggable but is not food: on the board, never in the catalog.
|
||||
check "leftovers are on the board" "$board" "Tähteet"
|
||||
refute "leftovers are not in the catalog" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet"
|
||||
|
||||
# Pull a real dish id out of the board rather than assuming one.
|
||||
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
|
||||
if [ -z "$ruoka" ]; then
|
||||
@@ -132,8 +137,16 @@ check "saving redirects back to the day" \
|
||||
check "the saved day shows what was eaten" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
||||
|
||||
check "history is on the same page as the logger" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
|
||||
# The selected day expands inside the list rather than in a panel above it,
|
||||
# so the rows below do not shift when one is tapped.
|
||||
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
|
||||
check "the selected day expands in place" "$day" 'id="paiva-2026-09-05"'
|
||||
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
||||
check "links inside it keep the anchor" "$day" "#paiva-2026-09-05"
|
||||
|
||||
check "saving returns to the day, not the top" \
|
||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "#paiva-2026-09-04"
|
||||
|
||||
# Deleting a logged meal drops the row outright, so it asks first.
|
||||
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
|
||||
|
||||
+47
-10
@@ -1,19 +1,56 @@
|
||||
{
|
||||
"mains": [
|
||||
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
|
||||
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
|
||||
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
|
||||
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
|
||||
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false}
|
||||
{"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
|
||||
{"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true},
|
||||
{"name": "Kebab", "categories": ["meat"], "has_sides": true},
|
||||
{"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Lihapullat/pihvit", "categories": ["meat"], "has_sides": true},
|
||||
{"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Makaronimössö", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Maksalaatikko", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Nachopelti", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Nakkikeitto", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Possunsuikalekastike", "categories": ["meat"], "has_sides": true},
|
||||
{"name": "Possurisotto", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Pyttipannu", "categories": ["meat"], "has_sides": false},
|
||||
{"name": "Uuniliha", "categories": ["meat"], "has_sides": true},
|
||||
{"name": "Uunimakkara", "categories": ["meat"], "has_sides": true},
|
||||
|
||||
{"name": "Broilerin koipireidet", "categories": ["chicken"], "has_sides": true},
|
||||
{"name": "Kanakastike", "categories": ["chicken"], "has_sides": true},
|
||||
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
|
||||
{"name": "Kanamakaronilaatikko", "categories": ["chicken"], "has_sides": false},
|
||||
{"name": "Kanapasta", "categories": ["chicken"], "has_sides": false},
|
||||
{"name": "Kanarisotto", "categories": ["chicken"], "has_sides": false},
|
||||
|
||||
{"name": "Kalakeitto", "categories": ["fish"], "has_sides": false},
|
||||
{"name": "Lohicuscus-salaatti", "categories": ["fish"], "has_sides": false},
|
||||
{"name": "Lohipyörykät", "categories": ["fish"], "has_sides": true},
|
||||
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
|
||||
{"name": "Uuniperunat (lohitäytteellä)", "categories": ["fish"], "has_sides": false},
|
||||
|
||||
{"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false},
|
||||
{"name": "Italianpata (lihaton)", "categories": ["vegetarian"], "has_sides": true},
|
||||
{"name": "Kasvispihvit", "categories": ["vegetarian"], "has_sides": true},
|
||||
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false},
|
||||
{"name": "Pinaattiletut", "categories": ["vegetarian"], "has_sides": false},
|
||||
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
|
||||
|
||||
{"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
|
||||
],
|
||||
"sides": [
|
||||
{"name": "Keitetyt perunat"},
|
||||
{"name": "Ranskalaiset"},
|
||||
{"name": "Lohkoperunat"},
|
||||
{"name": "Muussi"},
|
||||
{"name": "Muusi"},
|
||||
{"name": "Pasta"},
|
||||
{"name": "Ranskalaiset"},
|
||||
{"name": "Riisi"},
|
||||
{"name": "Pasta"}
|
||||
{"name": "Spagetti"},
|
||||
{"name": "Tillikastike"},
|
||||
{"name": "Wokkivihannekset"}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user