Build the Kirjaa log flow and Historia list
Stage 1's point is collecting eating history, so the logging path is the one that has to be frictionless: pick a dish, tick sides, done. Kirjaa: - dishes ordered and sized by how often they have been eaten, so the likely answer is the biggest target on the screen - picking a dish opens the sides step; a dish with has_sides false says so instead of offering an empty list - saving redirects, so a refresh cannot double-post - a day already logged shows the entry with edit and delete. Editing reopens the sides step with the existing sides ticked, which makes editing and creating the same screen - day switcher and a server-side search over the catalog Historia walks back day by day to the oldest entry, so a day nobody wrote down appears as an explicit gap rather than quietly missing. No JavaScript is involved: every interaction is a link or a form, and the checked-chip styling is :has(input:checked). Datastar stays loaded but unused until an interaction genuinely needs to avoid a page load. Also fix a Makefile ordering bug: lint did not depend on generate, so `go vet` could run against templ output that was being rewritten. check now runs its phases as sub-makes so `make -j` cannot interleave them. Records two features that are specified but not built: dish CRUD in the UI (§7.3, only import exists today) and a per-device light/dark switch (§7.4). The switch must show the state that is active, not the one clicking produces.
This commit is contained in:
+119
-2
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,10 @@ import (
|
||||
// kilobytes; a megabyte is already absurd generosity.
|
||||
const maxUpload = 1 << 20
|
||||
|
||||
// historyDays is how far back the Historia list walks. Long enough to see a
|
||||
// couple of months, short enough to stay one scroll.
|
||||
const historyDays = 60
|
||||
|
||||
type app struct {
|
||||
db *sql.DB
|
||||
loc *time.Location
|
||||
@@ -27,12 +32,124 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||
}
|
||||
}
|
||||
|
||||
// date reads the ?pvm= parameter, falling back to today. An unparseable value
|
||||
// is treated as today rather than an error: a mangled URL should not be a
|
||||
// dead end.
|
||||
func (a *app) date(r *http.Request) time.Time {
|
||||
if raw := r.FormValue("pvm"); raw != "" {
|
||||
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return today(a.loc)
|
||||
}
|
||||
|
||||
// logView is everything the Kirjaa screen needs.
|
||||
type logView struct {
|
||||
Date time.Time
|
||||
Today time.Time
|
||||
Search string
|
||||
Entry *Entry // what is already logged for Date, if anything
|
||||
Chosen *Dish // dish picked, so the sides step is showing
|
||||
Checked map[int64]bool // sides ticked in that step
|
||||
Dishes []Dish
|
||||
Sides []Side
|
||||
}
|
||||
|
||||
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, r, indexPage(today(a.loc)))
|
||||
date := a.date(r)
|
||||
v := logView{
|
||||
Date: date,
|
||||
Today: today(a.loc),
|
||||
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
|
||||
Checked: map[int64]bool{},
|
||||
}
|
||||
|
||||
entry, err := entryFor(a.db, date)
|
||||
if err != nil {
|
||||
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
|
||||
}
|
||||
v.Entry = entry
|
||||
|
||||
// ?ruoka= opens the sides step for that dish. When it is the dish already
|
||||
// logged, the existing sides come back ticked, which makes editing an
|
||||
// entry the same screen as creating one.
|
||||
if raw := r.URL.Query().Get("ruoka"); raw != "" {
|
||||
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
if dish, err := dishByID(a.db, id); err == nil {
|
||||
v.Chosen = dish
|
||||
if entry != nil && entry.Main.ID == id {
|
||||
for _, s := range entry.Sides {
|
||||
v.Checked[s.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v.Chosen == nil && v.Entry == nil {
|
||||
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||
log.Printf("list dishes: %v", err)
|
||||
}
|
||||
}
|
||||
if v.Chosen != nil && v.Chosen.HasSides {
|
||||
if v.Sides, err = listSides(a.db); err != nil {
|
||||
log.Printf("list sides: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
render(w, r, logPage(v))
|
||||
}
|
||||
|
||||
// save records the meal and redirects, so a refresh cannot double-post.
|
||||
func (a *app) save(w http.ResponseWriter, r *http.Request) {
|
||||
date := a.date(r)
|
||||
|
||||
mainID, err := strconv.ParseInt(r.FormValue("ruoka"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "tuntematon ruoka", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var sideIDs []int64
|
||||
for _, raw := range r.Form["lisuke"] {
|
||||
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
sideIDs = append(sideIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
if err := saveEntry(a.db, date, mainID, sideIDs); err != nil {
|
||||
log.Printf("save entry: %v", err)
|
||||
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.redirectToDay(w, r, date)
|
||||
}
|
||||
|
||||
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
||||
date := a.date(r)
|
||||
if err := deleteEntry(a.db, date); err != nil {
|
||||
log.Printf("delete entry: %v", err)
|
||||
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.redirectToDay(w, r, date)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (a *app) history(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, r, historyPage())
|
||||
rows, err := history(a.db, a.loc, historyDays)
|
||||
if err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
}
|
||||
render(w, r, historyPage(rows))
|
||||
}
|
||||
|
||||
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -145,6 +145,8 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||
mux.HandleFunc("GET /{$}", a.index)
|
||||
mux.HandleFunc("POST /kirjaa", a.save)
|
||||
mux.HandleFunc("POST /poista", a.delete)
|
||||
mux.HandleFunc("GET /historia", a.history)
|
||||
mux.HandleFunc("GET /ruoat", a.catalog)
|
||||
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
||||
|
||||
@@ -75,6 +75,228 @@ button, input, select { font: inherit; }
|
||||
}
|
||||
.tabbar a[aria-current] { color: var(--accent); }
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.d-liha { background: var(--liha); }
|
||||
.d-kana { background: var(--kana); }
|
||||
.d-kala { background: var(--kala); }
|
||||
.d-kasvis { background: var(--kasvis); }
|
||||
.d-sek {
|
||||
background: conic-gradient(var(--liha) 0 25%, var(--kana) 25% 50%,
|
||||
var(--kala) 50% 75%, var(--kasvis) 75% 100%);
|
||||
}
|
||||
|
||||
/* Day switcher */
|
||||
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
|
||||
.dayseg .seg {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dayseg .seg[aria-current] {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.daypick { display: flex; gap: 6px; flex: 1; min-width: 190px; }
|
||||
.daypick input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 16px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.daypick button {
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tap board */
|
||||
.searchrow { margin-bottom: 14px; }
|
||||
.filter {
|
||||
width: 100%;
|
||||
min-height: var(--tap);
|
||||
font-size: 16px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.board { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--tap);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.022em;
|
||||
}
|
||||
.pill .n {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.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; }
|
||||
.pill.sm { font-size: 14px; padding: 10px 13px; color: var(--muted); }
|
||||
|
||||
/* Sides step */
|
||||
.q { margin: 14px 0 10px; font-size: 13.5px; color: var(--muted); }
|
||||
.chips { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.chip:has(input:checked) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--onacc);
|
||||
}
|
||||
.chip:has(input:focus-visible) { outline: 2.5px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* Logged card */
|
||||
.logged .k {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.logged .nm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 3px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.logged .sd { margin: 0 0 16px; font-size: 14.5px; color: var(--muted); }
|
||||
.pair { display: flex; gap: 8px; }
|
||||
.pair form { flex: 1; display: flex; }
|
||||
.btn {
|
||||
flex: 1;
|
||||
min-height: var(--tap);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.del { color: var(--liha); }
|
||||
|
||||
.ghost {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: var(--tap);
|
||||
margin-top: 4px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
line-height: var(--tap);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.empty { text-align: center; }
|
||||
.empty p { margin: 0 0 8px; }
|
||||
.empty .primary { margin-top: 12px; line-height: var(--tap); text-decoration: none; }
|
||||
|
||||
/* Historia */
|
||||
.monthrule {
|
||||
margin: 22px 0 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.entry, .gapline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: var(--tap);
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
|
||||
.entry time, .gapline time {
|
||||
flex: none;
|
||||
min-width: 54px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.entry .nm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.entry .sd { margin-top: 1px; font-size: 12.5px; color: var(--muted); }
|
||||
.entry .chev {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
padding: 0 6px;
|
||||
font-size: 19px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.gapline a {
|
||||
margin-left: auto;
|
||||
padding: 0 4px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"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) ([]Side, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, name FROM side_dishes WHERE deleted_at IS NULL ORDER BY name`)
|
||||
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
|
||||
}
|
||||
|
||||
// HistoryRow is one calendar day: either what was eaten or an unfilled gap.
|
||||
type HistoryRow struct {
|
||||
Date time.Time
|
||||
Entry *Entry
|
||||
}
|
||||
|
||||
// history walks back day by day from today, 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, days int) ([]HistoryRow, error) {
|
||||
var first string
|
||||
err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first)
|
||||
if err == sql.ErrNoRows || first == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstDate, err := time.ParseInLocation(dateLayout, first, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := today(loc)
|
||||
oldest := now.AddDate(0, 0, -days)
|
||||
if firstDate.After(oldest) {
|
||||
oldest = firstDate
|
||||
}
|
||||
|
||||
var rows []HistoryRow
|
||||
for d := now; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
|
||||
entry, err := entryFor(db, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, HistoryRow{Date: d, Entry: entry})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dbHandle wraps a scratch database with lookups the tests need, so a test
|
||||
// reads as "log Lohikeitto" rather than juggling row ids.
|
||||
type dbHandle struct{ db *sql.DB }
|
||||
|
||||
func (h *dbHandle) mainNamed(t *testing.T, name string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := h.db.QueryRow(
|
||||
`SELECT id FROM main_dishes WHERE name = ?`, name).Scan(&id); err != nil {
|
||||
t.Fatalf("main dish %q: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *dbHandle) sideNamed(t *testing.T, name string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := h.db.QueryRow(
|
||||
`SELECT id FROM side_dishes WHERE name = ?`, name).Scan(&id); err != nil {
|
||||
t.Fatalf("side dish %q: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func containsFold(s, sub string) bool {
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
|
||||
}
|
||||
|
||||
// seeded opens a scratch database populated from the committed bundle.
|
||||
func seeded(t *testing.T) *dbHandle {
|
||||
t.Helper()
|
||||
|
||||
db, err := openDB(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("openDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
f, err := os.Open("../../seeds/testi.json")
|
||||
if err != nil {
|
||||
t.Fatalf("open seed: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := importBundle(db, f); err != nil {
|
||||
t.Fatalf("import seed: %v", err)
|
||||
}
|
||||
return &dbHandle{db}
|
||||
}
|
||||
|
||||
func day(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
d, err := time.ParseInLocation(dateLayout, s, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("bad date %q: %v", s, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestSaveAndReadEntry(t *testing.T) {
|
||||
h := seeded(t)
|
||||
date := day(t, "2026-09-05")
|
||||
|
||||
if e, err := entryFor(h.db, date); err != nil || e != nil {
|
||||
t.Fatalf("empty day: entry = %v, err = %v; want nil, nil", e, err)
|
||||
}
|
||||
|
||||
main := h.mainNamed(t, "Lohikeitto")
|
||||
side := h.sideNamed(t, "Ruisleipä")
|
||||
|
||||
if err := saveEntry(h.db, date, main, []int64{side}); err != nil {
|
||||
t.Fatalf("saveEntry: %v", err)
|
||||
}
|
||||
|
||||
e, err := entryFor(h.db, date)
|
||||
if err != nil || e == nil {
|
||||
t.Fatalf("entryFor: %v, %v", e, err)
|
||||
}
|
||||
if e.Main.Name != "Lohikeitto" {
|
||||
t.Errorf("main = %q, want Lohikeitto", e.Main.Name)
|
||||
}
|
||||
if e.SidesLabel() != "Ruisleipä" {
|
||||
t.Errorf("sides = %q, want Ruisleipä", e.SidesLabel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveEntryReplacesTheDay(t *testing.T) {
|
||||
h := seeded(t)
|
||||
date := day(t, "2026-09-05")
|
||||
|
||||
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"),
|
||||
[]int64{h.sideNamed(t, "Ruisleipä")}); err != nil {
|
||||
t.Fatalf("first save: %v", err)
|
||||
}
|
||||
// PRD §6 allows one entry per date, so a second save is an edit.
|
||||
if err := saveEntry(h.db, date, h.mainNamed(t, "Lihapullat"),
|
||||
[]int64{h.sideNamed(t, "Perunamuusi"), h.sideNamed(t, "Vihersalaatti")}); err != nil {
|
||||
t.Fatalf("second save: %v", err)
|
||||
}
|
||||
|
||||
var rows int
|
||||
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log WHERE date = ?`,
|
||||
date.Format(dateLayout)).Scan(&rows); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Errorf("%d rows for one date, want 1", rows)
|
||||
}
|
||||
|
||||
e, _ := entryFor(h.db, date)
|
||||
if e.Main.Name != "Lihapullat" {
|
||||
t.Errorf("main = %q, want Lihapullat", e.Main.Name)
|
||||
}
|
||||
if len(e.Sides) != 2 {
|
||||
t.Errorf("%d sides, want 2 (%s)", len(e.Sides), e.SidesLabel())
|
||||
}
|
||||
|
||||
// The replaced entry's sides must go with it, not linger orphaned.
|
||||
var orphans int
|
||||
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log_sides ls
|
||||
WHERE NOT EXISTS (SELECT 1 FROM meal_log l WHERE l.id = ls.meal_log_id)`,
|
||||
).Scan(&orphans); err != nil {
|
||||
t.Fatalf("orphan check: %v", err)
|
||||
}
|
||||
if orphans != 0 {
|
||||
t.Errorf("%d orphaned sides after replace", orphans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEntry(t *testing.T) {
|
||||
h := seeded(t)
|
||||
date := day(t, "2026-09-05")
|
||||
|
||||
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if err := deleteEntry(h.db, date); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if e, _ := entryFor(h.db, date); e != nil {
|
||||
t.Errorf("entry survived delete: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDishesOrdersByTimesEaten(t *testing.T) {
|
||||
h := seeded(t)
|
||||
lihapullat := h.mainNamed(t, "Lihapullat")
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
d := day(t, fmt.Sprintf("2026-09-%02d", i))
|
||||
if err := saveEntry(h.db, d, lihapullat, nil); err != nil {
|
||||
t.Fatalf("save %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
dishes, err := listDishes(h.db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("listDishes: %v", err)
|
||||
}
|
||||
if len(dishes) == 0 {
|
||||
t.Fatal("no dishes")
|
||||
}
|
||||
if dishes[0].Name != "Lihapullat" {
|
||||
t.Errorf("first dish = %q, want the most-eaten Lihapullat", dishes[0].Name)
|
||||
}
|
||||
if dishes[0].TimesEaten != 3 {
|
||||
t.Errorf("timesEaten = %d, want 3", dishes[0].TimesEaten)
|
||||
}
|
||||
if got := dishes[0].Size(); got != "md" {
|
||||
t.Errorf("size for 3 = %q, want md", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDishesSearchIsCaseInsensitive(t *testing.T) {
|
||||
h := seeded(t)
|
||||
|
||||
dishes, err := listDishes(h.db, "KEITTO")
|
||||
if err != nil {
|
||||
t.Fatalf("listDishes: %v", err)
|
||||
}
|
||||
if len(dishes) == 0 {
|
||||
t.Fatal("no matches for KEITTO, want the soups")
|
||||
}
|
||||
for _, d := range dishes {
|
||||
if !containsFold(d.Name, "keitto") {
|
||||
t.Errorf("%q does not match the search", d.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiCategoryDishUsesMixedMarker(t *testing.T) {
|
||||
h := seeded(t)
|
||||
|
||||
tortillat, err := dishByID(h.db, h.mainNamed(t, "Tortillat"))
|
||||
if err != nil {
|
||||
t.Fatalf("dishByID: %v", err)
|
||||
}
|
||||
if len(tortillat.Categories) != 4 {
|
||||
t.Fatalf("%d categories, want 4", len(tortillat.Categories))
|
||||
}
|
||||
if got := tortillat.CategoryKey(); got != "sek" {
|
||||
t.Errorf("CategoryKey = %q, want sek", got)
|
||||
}
|
||||
|
||||
lohi, err := dishByID(h.db, h.mainNamed(t, "Lohikeitto"))
|
||||
if err != nil {
|
||||
t.Fatalf("dishByID: %v", err)
|
||||
}
|
||||
if got := lohi.CategoryKey(); got != "kala" {
|
||||
t.Errorf("CategoryKey = %q, want kala", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
|
||||
h := seeded(t)
|
||||
loc := time.UTC
|
||||
now := today(loc)
|
||||
|
||||
// Log today and three days ago, leaving two gaps between them.
|
||||
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
|
||||
t.Fatalf("save today: %v", err)
|
||||
}
|
||||
if err := saveEntry(h.db, now.AddDate(0, 0, -3), h.mainNamed(t, "Lihapullat"), nil); err != nil {
|
||||
t.Fatalf("save -3: %v", err)
|
||||
}
|
||||
|
||||
rows, err := history(h.db, loc, 60)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
// Walks back to the oldest entry only: today, -1, -2, -3.
|
||||
if len(rows) != 4 {
|
||||
t.Fatalf("%d rows, want 4", len(rows))
|
||||
}
|
||||
if rows[0].Entry == nil || rows[0].Entry.Main.Name != "Lohikeitto" {
|
||||
t.Errorf("first row should be today's Lohikeitto, got %+v", rows[0].Entry)
|
||||
}
|
||||
if rows[1].Entry != nil || rows[2].Entry != nil {
|
||||
t.Error("the two unlogged days should be gaps")
|
||||
}
|
||||
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
|
||||
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
||||
h := seeded(t)
|
||||
|
||||
rows, err := history(h.db, time.UTC, 60)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("%d rows for an empty log, want 0", len(rows))
|
||||
}
|
||||
}
|
||||
+189
-7
@@ -2,15 +2,19 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Finnish weekday names. Go formats dates in English only, and this app has
|
||||
// exactly one locale (PRD §5).
|
||||
// Finnish weekday and month names. Go formats dates in English only, and this
|
||||
// app has exactly one locale (PRD §5).
|
||||
var (
|
||||
weekdaysFI = [...]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko",
|
||||
"torstai", "perjantai", "lauantai"}
|
||||
weekdayAbbrFI = [...]string{"su", "ma", "ti", "ke", "to", "pe", "la"}
|
||||
monthsFI = [...]string{"", "tammikuu", "helmikuu", "maaliskuu", "huhtikuu",
|
||||
"toukokuu", "kesäkuu", "heinäkuu", "elokuu", "syyskuu", "lokakuu",
|
||||
"marraskuu", "joulukuu"}
|
||||
)
|
||||
|
||||
func longDateFI(t time.Time) string {
|
||||
@@ -21,6 +25,31 @@ func dayLabelFI(t time.Time) string {
|
||||
return weekdayAbbrFI[t.Weekday()] + " " + t.Format("2.1.")
|
||||
}
|
||||
|
||||
func monthFI(t time.Time) string {
|
||||
return monthsFI[int(t.Month())]
|
||||
}
|
||||
|
||||
func isoDate(t time.Time) string {
|
||||
return t.Format(dateLayout)
|
||||
}
|
||||
|
||||
// dayURL links to a specific day, leaving today as the bare path.
|
||||
func dayURL(base string, d, now time.Time) string {
|
||||
if d.Equal(now) {
|
||||
return base
|
||||
}
|
||||
return base + "?pvm=" + isoDate(d)
|
||||
}
|
||||
|
||||
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
|
||||
// any day but today.
|
||||
func pickSeparator(v logView) string {
|
||||
if v.Date.Equal(v.Today) {
|
||||
return "?"
|
||||
}
|
||||
return "&"
|
||||
}
|
||||
|
||||
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
|
||||
// after every number except one.
|
||||
func countFI(n int, one, many string) string {
|
||||
@@ -64,29 +93,182 @@ templ tab(href, label, current string) {
|
||||
}
|
||||
}
|
||||
|
||||
templ indexPage(now time.Time) {
|
||||
// ---------------------------------------------------------------- Kirjaa
|
||||
templ logPage(v logView) {
|
||||
@page("Foodster", "/") {
|
||||
<header class="appbar">
|
||||
<h2>Mitä syötiin?</h2>
|
||||
<p class="meta">{ longDateFI(now) }</p>
|
||||
<p class="meta">{ longDateFI(v.Date) }</p>
|
||||
@daySwitch(v)
|
||||
</header>
|
||||
<main class="pad">
|
||||
<p class="muted">Ruokien kirjaus tulee tähän.</p>
|
||||
switch {
|
||||
case v.Chosen != nil:
|
||||
@sidesStep(v)
|
||||
case v.Entry != nil:
|
||||
@loggedCard(v)
|
||||
default:
|
||||
@board(v)
|
||||
}
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
templ historyPage() {
|
||||
templ daySwitch(v logView) {
|
||||
<div class="dayseg">
|
||||
@dayButton("Tänään", v.Today, v.Date, v.Today)
|
||||
@dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
|
||||
<form method="get" action="/" class="daypick">
|
||||
<input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/>
|
||||
<button type="submit">Näytä</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ dayButton(label string, target, selected, now time.Time) {
|
||||
if target.Equal(selected) {
|
||||
<a class="seg" aria-current="true" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
|
||||
} else {
|
||||
<a class="seg" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
|
||||
}
|
||||
}
|
||||
|
||||
templ board(v logView) {
|
||||
if len(v.Dishes) == 0 && v.Search == "" {
|
||||
<section class="card empty">
|
||||
<p>Ruokalista on tyhjä.</p>
|
||||
<p class="muted small">Tuo ruokia Ruoat-välilehdellä, niin ne ilmestyvät tähän.</p>
|
||||
<a class="primary" href="/ruoat">Siirry ruokiin</a>
|
||||
</section>
|
||||
} else {
|
||||
<form method="get" action="/" class="searchrow">
|
||||
if !v.Date.Equal(v.Today) {
|
||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||
}
|
||||
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi" aria-label="Etsi"/>
|
||||
</form>
|
||||
if len(v.Dishes) == 0 {
|
||||
<p class="muted">Ei osumia haulle { v.Search }.</p>
|
||||
}
|
||||
<div class="board">
|
||||
for _, d := range v.Dishes {
|
||||
@dishPill(d, v)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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)) }
|
||||
>
|
||||
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
||||
{ d.Name }
|
||||
if d.TimesEaten > 0 {
|
||||
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
||||
templ sidesStep(v logView) {
|
||||
<section class="card">
|
||||
<h3>
|
||||
<i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i>
|
||||
{ v.Chosen.Name }
|
||||
</h3>
|
||||
<form method="post" action="/kirjaa">
|
||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
|
||||
if v.Chosen.HasSides && len(v.Sides) > 0 {
|
||||
<p class="q">Lisukkeita?</p>
|
||||
<div class="chips">
|
||||
for _, s := range v.Sides {
|
||||
<label class="chip">
|
||||
if v.Checked[s.ID] {
|
||||
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) } checked/>
|
||||
} else {
|
||||
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) }/>
|
||||
}
|
||||
<span>{ s.Name }</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="q">Tarjoillaan sellaisenaan.</p>
|
||||
}
|
||||
<button class="primary" type="submit">Tallenna</button>
|
||||
</form>
|
||||
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a>
|
||||
</section>
|
||||
}
|
||||
|
||||
templ loggedCard(v logView) {
|
||||
<section class="card logged">
|
||||
<p class="k">
|
||||
if v.Date.Equal(v.Today) {
|
||||
Tänään kirjattu
|
||||
} else {
|
||||
{ dayLabelFI(v.Date) } kirjattu
|
||||
}
|
||||
</p>
|
||||
<p class="nm">
|
||||
<i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i>
|
||||
{ v.Entry.Main.Name }
|
||||
</p>
|
||||
<p class="sd">{ v.Entry.SidesLabel() }</p>
|
||||
<div class="pair">
|
||||
<a
|
||||
class="btn"
|
||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
|
||||
>Muokkaa</a>
|
||||
<form method="post" action="/poista">
|
||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||
<button class="btn del" type="submit">Poista</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Historia
|
||||
templ historyPage(rows []HistoryRow) {
|
||||
@page("Historia — Foodster", "/historia") {
|
||||
<header class="appbar">
|
||||
<h2>Historia</h2>
|
||||
</header>
|
||||
<main class="pad">
|
||||
<p class="muted">Merkinnät tulevat tähän.</p>
|
||||
if len(rows) == 0 {
|
||||
<p class="muted">Ei vielä merkintöjä.</p>
|
||||
}
|
||||
for i, row := range rows {
|
||||
if i == 0 || rows[i-1].Date.Month() != row.Date.Month() {
|
||||
<p class="monthrule">{ monthFI(row.Date) }</p>
|
||||
}
|
||||
if row.Entry != nil {
|
||||
<div class="entry">
|
||||
<time>{ dayLabelFI(row.Date) }</time>
|
||||
<div>
|
||||
<div class="nm">
|
||||
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
|
||||
{ row.Entry.Main.Name }
|
||||
</div>
|
||||
<div class="sd">{ row.Entry.SidesLabel() }</div>
|
||||
</div>
|
||||
<a class="chev" href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) } aria-label="Muokkaa">›</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="gapline">
|
||||
<time>{ dayLabelFI(row.Date) }</time>
|
||||
<span>Ei merkintää</span>
|
||||
<a href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) }>Merkitse</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ruoat
|
||||
templ catalogPage(mains, sides int, report *ImportReport) {
|
||||
@page("Ruoat — Foodster", "/ruoat") {
|
||||
<header class="appbar">
|
||||
|
||||
Reference in New Issue
Block a user