Files
foodster/cmd/foodster/views.templ
T
Esa Kataja 4ce189d1e4 Merge the log and history into one page, group dishes by category
Kirjaa and Historia were two views of the same thing: every history row was
already a link back into the logger, and the logger had a day switcher. They
are now one page — the day being logged on top, history underneath, each row
loading its day into the logger above. Two tabs instead of three.

That also closed a gap. On an already-logged day there was no way to swap to
a different dish; "Muokkaa" only reopened the sides for the same one. It now
opens the board, so changing a dish and choosing one for the first time are
the same path.

Dishes are grouped by category on both screens, with Sekalaiset collecting
the ones covering more than one. That group is derived from the stored set
rather than being a fifth category, so a single Tortillat still satisfies
meat, chicken, fish and vegetarian at once when the §8.1 suggester arrives.

The two screens sort differently on purpose. The log board keeps frequency
then name inside each group, so favourites surface without wandering between
categories as counts change. The catalog sorts by name, because there you are
hunting a specific dish to edit rather than picking one to eat. groupDishes
preserves the order it is handed; the caller decides which it wants.

Ruoat is renamed Ruuat throughout, label and route both.
2026-09-05 21:28:14 +03:00

562 lines
16 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
// 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 {
return weekdaysFI[t.Weekday()] + " " + t.Format("2.1.2006")
}
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 "&"
}
// categoryLabels lists a dish's categories in Finnish, for the catalog rows.
func categoryLabels(d Dish) string {
names := make([]string, 0, len(d.Categories))
for _, c := range d.Categories {
names = append(names, categoryFI[c])
}
return strings.Join(names, ", ")
}
// 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 {
if n == 1 {
return fmt.Sprintf("%d %s", n, one)
}
return fmt.Sprintf("%d %s", n, many)
}
templ page(title, current string) {
<!DOCTYPE html>
// Dark is the default, set here so it holds even before theme.js runs and
// for anyone without JavaScript.
<html lang="fi" data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="color-scheme" content="light dark"/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ECEDE8"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121316"/>
<title>{ title }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and
would otherwise come back 401 and be ignored. -->
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
<link rel="stylesheet" href="/static/app.css"/>
<!-- Not deferred: it applies the stored theme before first paint. -->
<script src="/static/theme.js"></script>
<script type="module" src="/static/datastar.js"></script>
</head>
<body>
@brandbar()
{ children... }
@tabbar(current)
</body>
</html>
}
templ brandbar() {
<header class="brandbar">
<a class="brand" href="/">
<img src="/static/favicon.svg" width="24" height="24" alt=""/>
<span>Foodster</span>
</a>
@themeSwitch()
</header>
}
// themeSwitch shows the theme that is on right now — moon while dark, sun
// while light — and clicking swaps it. Both icons are in the markup and CSS
// picks one, so the server never has to know the device's choice.
// theme.js writes the label, which names the state and then the action.
templ themeSwitch() {
<button class="themetoggle" type="button" data-theme-toggle aria-label="Teema" title="Teema">
@iconSun()
@iconMoon()
</button>
}
templ iconSun() {
<svg class="i-sun" viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false">
<circle cx="8" cy="8" r="3.1" fill="currentColor"></circle>
<path
d="M8 1.1v1.7M8 13.2v1.7M1.1 8h1.7M13.2 8h1.7M3.2 3.2l1.2 1.2M11.6 11.6l1.2 1.2M12.8 3.2l-1.2 1.2M4.4 11.6l-1.2 1.2"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
></path>
</svg>
}
templ iconMoon() {
<svg class="i-moon" viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false">
<path d="M13.5 10.4A6 6 0 0 1 5.6 2.5 6.3 6.3 0 1 0 13.5 10.4z" fill="currentColor"></path>
</svg>
}
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)
@tab("/ruuat", "Ruuat", current)
</nav>
}
templ tab(href, label, current string) {
if href == current {
<a href={ templ.SafeURL(href) } aria-current="page">{ label }</a>
} else {
<a href={ templ.SafeURL(href) }>{ label }</a>
}
}
// ---------------------------------------------------------------- Kirjaa
templ logPage(v logView) {
@page("Foodster", "/") {
<header class="appbar">
<h2>Mitä syötiin?</h2>
<p class="meta">{ longDateFI(v.Date) }</p>
@daySwitch(v)
</header>
<main class="pad">
switch {
case v.Chosen != nil:
@sidesStep(v)
case v.ShowBoard:
@board(v)
default:
@loggedCard(v)
}
@historyList(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) {
<section class="history">
<h3 class="sechead">Aiemmin</h3>
if len(v.History) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p>
}
for i, row := range v.History {
if !row.Date.Equal(v.Date) {
if i == 0 || v.History[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">
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</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>
}
}
}
</section>
}
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) {
<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 tai lisää uusi" aria-label="Etsi"/>
</form>
// Grouped by category, and inside each group the most-eaten first — so a
// dish keeps a predictable neighbourhood while favourites still surface
// at the top of it.
for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3>
<div class="board">
for _, d := range g.Dishes {
@dishPill(d, v)
}
</div>
}
if len(v.Dishes) == 0 {
@quickAddCard(v)
}
}
// quickAddCard turns a search that found nothing into the thing to do next.
// Adding a dish here creates it and goes straight to logging it, so the user
// never leaves Kirjaa mid-thought.
templ quickAddCard(v logView) {
<section class="card">
if v.Search == "" {
<h3>Lisää ensimmäinen ruoka</h3>
<p class="muted small">
Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä.
</p>
} else {
<h3>Ei osumia. Lisätäänkö?</h3>
}
if v.New.Err != "" {
<p class="formerr">{ v.New.Err }</p>
}
<form method="post" action="/lisaa">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ v.New.Name } autocomplete="off" required/>
</label>
<div class="field">
<span>Kategoriat</span>
<div class="chips">
@categoryChip("meat", "liha", v.New)
@categoryChip("chicken", "kana", v.New)
@categoryChip("fish", "kala", v.New)
@categoryChip("vegetarian", "kasvis", v.New)
</div>
</div>
<label class="chip wide">
if v.New.HasSides {
<input type="checkbox" name="lisukkeita" value="1" checked/>
} else {
<input type="checkbox" name="lisukkeita" value="1"/>
}
<span>Tarjoillaan lisukkeiden kanssa</span>
</label>
<button class="primary" type="submit">Lisää ja kirjaa</button>
</form>
</section>
}
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) + "muuta=1") }
>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
// ---------------------------------------------------------------- Ruuat
templ catalogPage(v catalogView) {
@page("Ruuat — Foodster", "/ruuat") {
<header class="appbar">
<h2>Ruuat</h2>
<p class="meta">
{ countFI(v.Mains, "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
</p>
</header>
<main class="pad">
if v.Report != nil {
@importReport(v.Report)
}
@mainForm_(v.Main)
if v.Mains == 0 {
<h3 class="sechead">Pääruuat</h3>
<p class="muted small">Ei vielä pääruokia.</p>
}
// Grouped by category, alphabetical inside. The catalog is a list
// you manage, so a predictable position beats a useful one.
for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3>
for _, d := range g.Dishes {
<div class="row">
<i class={ "dot", "d-" + d.CategoryKey() }></i>
<div class="rowtext">
<div class="nm">{ d.Name }</div>
if !d.HasSides {
<div class="sd">Ei lisukkeita</div>
}
</div>
@rowActions("/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
}
}
@sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3>
if len(v.Sides) == 0 {
<p class="muted small">Ei vielä lisukkeita.</p>
}
for _, s := range v.Sides {
<div class="row">
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
@rowActions("/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div>
}
<details class="card">
<summary>Tuo ruokia tiedostosta</summary>
@importForm()
</details>
</main>
}
}
templ rowActions(editURL string, id int64, kind string) {
<div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
<form method="post" action="/ruuat/poista">
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del" aria-label="Poista">Poista</button>
</form>
</div>
}
templ mainForm_(f mainForm) {
<section class="card" id="paaruoka">
<h3>
if f.ID == 0 {
Lisää pääruoka
} else {
Muokkaa pääruokaa
}
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruuat/paaruoka">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
</label>
<div class="field">
<span>Kategoriat</span>
<div class="chips">
@categoryChip("meat", "liha", f)
@categoryChip("chicken", "kana", f)
@categoryChip("fish", "kala", f)
@categoryChip("vegetarian", "kasvis", f)
</div>
</div>
<label class="chip wide">
if f.HasSides {
<input type="checkbox" name="lisukkeita" value="1" checked/>
} else {
<input type="checkbox" name="lisukkeita" value="1"/>
}
<span>Tarjoillaan lisukkeiden kanssa</span>
</label>
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a>
}
</section>
}
templ categoryChip(value, label string, f mainForm) {
<label class="chip">
if f.Categories[value] {
<input type="checkbox" name="kategoria" value={ value } checked/>
} else {
<input type="checkbox" name="kategoria" value={ value }/>
}
<i class={ "dot", "d-" + categoryFI[value] }></i>
<span>{ label }</span>
</label>
}
templ sideForm_(f sideForm) {
<section class="card" id="lisuke">
<h3>
if f.ID == 0 {
Lisää lisuke
} else {
Muokkaa lisuketta
}
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruuat/lisuke">
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
</label>
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a>
}
</section>
}
templ importForm() {
<section class="card">
<h3>Tuo ruokia</h3>
<p class="muted small">
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
</p>
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
<label class="field">
<span>JSON</span>
<textarea
name="json"
rows="8"
spellcheck="false"
placeholder={ `{"mains": [{"name": "Kanacurry", "categories": ["chicken"]}], "sides": [{"name": "Riisi"}]}` }
></textarea>
</label>
<label class="field">
<span>tai tiedosto</span>
<input type="file" name="tiedosto" accept="application/json,.json"/>
</label>
<button class="primary" type="submit">Tuo</button>
</form>
</section>
}
templ importReport(r *ImportReport) {
<section class={ "card", "report", templ.KV("bad", r.Added == 0 && r.Skipped > 0) }>
<p class="tally">{ fmt.Sprintf("Lisätty %d, ohitettu %d", r.Added, r.Skipped) }</p>
if len(r.Notes) > 0 {
<ul class="notes">
for _, note := range r.Notes {
<li>{ note }</li>
}
</ul>
}
</section>
}