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.
326 lines
8.8 KiB
Templ
326 lines
8.8 KiB
Templ
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>
|
||
}
|