Files
foodster/cmd/foodster/views.templ
T
Esa Kataja e4503e8433 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.
2026-09-05 18:29:38 +03:00

326 lines
8.8 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"
"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 "&"
}
// 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>
<html lang="fi">
<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"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/app.css"/>
<script type="module" src="/static/datastar.js"></script>
</head>
<body>
{ children... }
@tabbar(current)
</body>
</html>
}
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)
@tab("/historia", "Historia", current)
@tab("/ruoat", "Ruoat", 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.Entry != nil:
@loggedCard(v)
default:
@board(v)
}
</main>
}
}
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">
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">
<h2>Ruoat</h2>
<p class="meta">
{ countFI(mains, "pääruoka", "pääruokaa") }, { countFI(sides, "lisuke", "lisuketta") }
</p>
</header>
<main class="pad">
if report != nil {
@importReport(report)
}
@importForm()
</main>
}
}
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="/ruoat/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>
}