Release: one log page, grouped dishes, live search #1
@@ -321,7 +321,7 @@ build and no asset bundler.
|
||||
lines of `database/sql`. There are no down-migrations: restoring the
|
||||
database file is the rollback for a single-household app.
|
||||
- **Bundle import**: the §7.3 mass import is a live feature of the running
|
||||
app, on the Ruoat tab — paste JSON or upload a file, get a per-row report
|
||||
app, on the Ruuat tab — paste JSON or upload a file, get a per-row report
|
||||
back. A plain multipart form rather than a Datastar round trip, since the
|
||||
response is a whole-page report and a form needs no client code. Uploads
|
||||
are capped at 1 MiB. The same importer is also reachable as
|
||||
|
||||
@@ -17,18 +17,29 @@ weeks of real history to weight against.
|
||||
|
||||
Working:
|
||||
|
||||
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized
|
||||
by how often they are eaten. Edit or delete the day's entry.
|
||||
- **Historia** — every day back to the first entry, with unlogged days shown
|
||||
as explicit gaps.
|
||||
- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle
|
||||
by paste or file upload. Deletes are soft, so old log entries keep showing
|
||||
the dish they used.
|
||||
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered
|
||||
and sized by how often they are eaten, so the likely answer is the biggest
|
||||
target. The history sits on the same page underneath: every day back to the
|
||||
first entry, unlogged days shown as explicit gaps, and every row a link that
|
||||
loads that day into the logger above it.
|
||||
- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
|
||||
by paste or file upload. Grouped by category and alphabetical inside, since
|
||||
this is a list you manage rather than one you pick from. Deletes are soft,
|
||||
so old log entries keep showing the dish they used.
|
||||
- **Light / dark**, remembered per device, dark by default. The button shows
|
||||
the theme that is on — moon while dark, sun while light — not the one a
|
||||
click would bring.
|
||||
|
||||
Still to build:
|
||||
|
||||
- Live search as you type, and paging for the history and catalog lists once
|
||||
years of entries make them long. Both via Datastar.
|
||||
- Category icons instead of plain colour dots — colour and shape together, so
|
||||
a red blob and a yellow blob are told apart by more than hue.
|
||||
- Edit and delete as icons in the catalog rows, and a confirmation step before
|
||||
a delete actually happens.
|
||||
- A background for the header. Something subtle; the palette gets overhauled
|
||||
later.
|
||||
- Stage 2: the seven-meal suggester, which starts once there is history to
|
||||
weight against.
|
||||
|
||||
@@ -99,7 +110,7 @@ make up/down/logs compose
|
||||
|
||||
## Importing dishes
|
||||
|
||||
The **Ruoat** tab takes a bundle of mains and sides: paste the JSON or upload
|
||||
The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload
|
||||
a file, and the app reports row by row what it did.
|
||||
|
||||
```json
|
||||
|
||||
+50
-29
@@ -17,8 +17,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.
|
||||
// historyDays is how far back the history under the logger walks.
|
||||
//
|
||||
// ponytail: a fixed window. After a year of daily entries this list is the
|
||||
// thing that needs paging; load more on scroll when it actually hurts.
|
||||
const historyDays = 60
|
||||
|
||||
type app struct {
|
||||
@@ -45,17 +47,22 @@ func (a *app) date(r *http.Request) time.Time {
|
||||
return today(a.loc)
|
||||
}
|
||||
|
||||
// logView is everything the Kirjaa screen needs.
|
||||
// logView is everything the log screen needs. Logging and history are one
|
||||
// page: every history row was already a link back into the logger, and the
|
||||
// day switcher made them two views of the same thing.
|
||||
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
|
||||
New mainForm // inline "add the dish you were looking for"
|
||||
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
|
||||
ShowBoard bool
|
||||
Dishes []Dish // flat, only to know whether anything matched
|
||||
Groups []DishGroup // what the board actually renders
|
||||
Sides []Side
|
||||
New mainForm // inline "add the dish you were looking for"
|
||||
History []HistoryRow
|
||||
}
|
||||
|
||||
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -89,10 +96,18 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if v.Chosen == nil && v.Entry == nil {
|
||||
// The board shows when there is nothing logged yet, or when the entry is
|
||||
// being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here,
|
||||
// so swapping the dish and picking one for the first time are one path.
|
||||
changing := r.URL.Query().Get("muuta") != "" || v.Search != ""
|
||||
if v.Chosen == nil && (v.Entry == nil || changing) {
|
||||
v.ShowBoard = true
|
||||
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||
log.Printf("list dishes: %v", err)
|
||||
}
|
||||
// listDishes already orders by frequency then name, so grouping keeps
|
||||
// the favourites at the top of each category.
|
||||
v.Groups = groupDishes(v.Dishes)
|
||||
// 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}
|
||||
@@ -103,6 +118,10 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
}
|
||||
|
||||
render(w, r, logPage(v))
|
||||
}
|
||||
|
||||
@@ -149,16 +168,21 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
// Rejected: back to the board with the form filled in and the search
|
||||
// still narrowed, so the add card stays on screen.
|
||||
v := logView{
|
||||
Date: date,
|
||||
Today: today(a.loc),
|
||||
Search: form.Name,
|
||||
Checked: map[int64]bool{},
|
||||
New: form,
|
||||
Date: date,
|
||||
Today: today(a.loc),
|
||||
Search: form.Name,
|
||||
Checked: map[int64]bool{},
|
||||
New: form,
|
||||
ShowBoard: true,
|
||||
}
|
||||
var err error
|
||||
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||
log.Printf("list dishes: %v", err)
|
||||
}
|
||||
v.Groups = groupDishes(v.Dishes)
|
||||
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
}
|
||||
render(w, r, logPage(v))
|
||||
}
|
||||
|
||||
@@ -214,14 +238,6 @@ func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Ti
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) history(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := history(a.db, a.loc, historyDays)
|
||||
if err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
}
|
||||
render(w, r, historyPage(rows))
|
||||
}
|
||||
|
||||
// mainForm and sideForm carry what the user typed, so a rejected submission
|
||||
// comes back filled in rather than blank.
|
||||
type mainForm struct {
|
||||
@@ -239,11 +255,12 @@ type sideForm struct {
|
||||
}
|
||||
|
||||
type catalogView struct {
|
||||
Mains []Dish
|
||||
Groups []DishGroup
|
||||
Sides []Side
|
||||
Main mainForm
|
||||
Side sideForm
|
||||
Report *ImportReport
|
||||
Mains int // count, for the header
|
||||
}
|
||||
|
||||
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -283,10 +300,14 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
|
||||
v.Main.Categories = map[string]bool{}
|
||||
}
|
||||
|
||||
var err error
|
||||
if v.Mains, err = listDishes(a.db, ""); err != nil {
|
||||
mains, err := listDishes(a.db, "")
|
||||
if err != nil {
|
||||
log.Printf("list mains: %v", err)
|
||||
}
|
||||
v.Mains = len(mains)
|
||||
sortByName(mains) // the catalog is managed, so position should be predictable
|
||||
v.Groups = groupDishes(mains)
|
||||
|
||||
if v.Sides, err = listSides(a.db); err != nil {
|
||||
log.Printf("list sides: %v", err)
|
||||
}
|
||||
|
||||
@@ -159,12 +159,11 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
||||
mux.HandleFunc("POST /kirjaa", a.save)
|
||||
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
||||
mux.HandleFunc("POST /poista", a.delete)
|
||||
mux.HandleFunc("GET /historia", a.history)
|
||||
mux.HandleFunc("GET /ruoat", a.catalog)
|
||||
mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
|
||||
mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
|
||||
mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
|
||||
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
||||
mux.HandleFunc("GET /ruuat", a.catalog)
|
||||
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
|
||||
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
|
||||
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
||||
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
|
||||
|
||||
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
||||
root := http.NewServeMux()
|
||||
|
||||
@@ -307,6 +307,8 @@ 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; }
|
||||
.entry, .gapline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -314,6 +316,14 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
||||
min-height: var(--tap);
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.gapline .act {
|
||||
margin-left: auto;
|
||||
padding: 0 4px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
|
||||
.entry time, .gapline time {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -341,6 +342,53 @@ func sideByID(db *sql.DB, id int64) (*Side, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// DishGroup is one category's worth of dishes for the catalog listing.
|
||||
type DishGroup struct {
|
||||
Key string
|
||||
Label string
|
||||
Dishes []Dish
|
||||
}
|
||||
|
||||
// groupOrder fixes the order the catalog lists categories in. Sekalaiset is a
|
||||
// display grouping for dishes covering more than one category, not a fifth
|
||||
// category: the stored set is what PRD §8.1 counts for coverage, and one
|
||||
// Tortillat still satisfies meat, chicken, fish and vegetarian at once.
|
||||
var groupOrder = []DishGroup{
|
||||
{Key: "liha", Label: "Liha"},
|
||||
{Key: "kana", Label: "Kana"},
|
||||
{Key: "kala", Label: "Kala"},
|
||||
{Key: "kasvis", Label: "Kasvis"},
|
||||
{Key: "sek", Label: "Sekalaiset"},
|
||||
}
|
||||
|
||||
// groupDishes buckets dishes by category, keeping whatever order they arrived
|
||||
// in. The caller decides that order: the log board hands over listDishes'
|
||||
// frequency-then-name ordering, the catalog sorts by name first.
|
||||
func groupDishes(dishes []Dish) []DishGroup {
|
||||
byKey := make(map[string][]Dish, len(groupOrder))
|
||||
for _, d := range dishes {
|
||||
key := d.CategoryKey()
|
||||
byKey[key] = append(byKey[key], d)
|
||||
}
|
||||
|
||||
var groups []DishGroup
|
||||
for _, g := range groupOrder {
|
||||
in := byKey[g.Key]
|
||||
if len(in) == 0 {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, DishGroup{Key: g.Key, Label: g.Label, Dishes: in})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// sortByName orders dishes alphabetically, case-insensitively.
|
||||
func sortByName(dishes []Dish) {
|
||||
slices.SortFunc(dishes, func(a, b Dish) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
}
|
||||
|
||||
// HistoryRow is one calendar day: either what was eaten or an unfilled gap.
|
||||
type HistoryRow struct {
|
||||
Date time.Time
|
||||
|
||||
+75
-72
@@ -109,9 +109,6 @@ templ brandbar() {
|
||||
</header>
|
||||
}
|
||||
|
||||
// themeSwitch marks the active theme rather than labelling itself with the one
|
||||
// a click would produce. aria-pressed is set by theme.js on load, because only
|
||||
// the device knows what was chosen.
|
||||
// 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.
|
||||
@@ -145,8 +142,7 @@ templ iconMoon() {
|
||||
templ tabbar(current string) {
|
||||
<nav class="tabbar">
|
||||
@tab("/", "Kirjaa", current)
|
||||
@tab("/historia", "Historia", current)
|
||||
@tab("/ruoat", "Ruoat", current)
|
||||
@tab("/ruuat", "Ruuat", current)
|
||||
</nav>
|
||||
}
|
||||
|
||||
@@ -170,15 +166,53 @@ templ logPage(v logView) {
|
||||
switch {
|
||||
case v.Chosen != nil:
|
||||
@sidesStep(v)
|
||||
case v.Entry != nil:
|
||||
@loggedCard(v)
|
||||
default:
|
||||
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)
|
||||
@@ -205,9 +239,13 @@ templ board(v logView) {
|
||||
}
|
||||
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
|
||||
</form>
|
||||
if len(v.Dishes) > 0 {
|
||||
// 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 v.Dishes {
|
||||
for _, d := range g.Dishes {
|
||||
@dishPill(d, v)
|
||||
}
|
||||
</div>
|
||||
@@ -225,7 +263,7 @@ templ quickAddCard(v logView) {
|
||||
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 Ruoat-välilehdeltä.
|
||||
Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä.
|
||||
</p>
|
||||
} else {
|
||||
<h3>Ei osumia. Lisätäänkö?</h3>
|
||||
@@ -323,7 +361,7 @@ templ loggedCard(v logView) {
|
||||
<div class="pair">
|
||||
<a
|
||||
class="btn"
|
||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
|
||||
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) }/>
|
||||
@@ -334,50 +372,13 @@ templ loggedCard(v logView) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
// ---------------------------------------------------------------- Ruuat
|
||||
templ catalogPage(v catalogView) {
|
||||
@page("Ruoat — Foodster", "/ruoat") {
|
||||
@page("Ruuat — Foodster", "/ruuat") {
|
||||
<header class="appbar">
|
||||
<h2>Ruoat</h2>
|
||||
<h2>Ruuat</h2>
|
||||
<p class="meta">
|
||||
{ countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
|
||||
{ countFI(v.Mains, "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
|
||||
</p>
|
||||
</header>
|
||||
<main class="pad">
|
||||
@@ -385,24 +386,26 @@ templ catalogPage(v catalogView) {
|
||||
@importReport(v.Report)
|
||||
}
|
||||
@mainForm_(v.Main)
|
||||
<h3 class="sechead">Pääruoat</h3>
|
||||
if len(v.Mains) == 0 {
|
||||
if v.Mains == 0 {
|
||||
<h3 class="sechead">Pääruuat</h3>
|
||||
<p class="muted small">Ei vielä pääruokia.</p>
|
||||
}
|
||||
for _, d := range v.Mains {
|
||||
<div class="row">
|
||||
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
||||
<div class="rowtext">
|
||||
<div class="nm">{ d.Name }</div>
|
||||
<div class="sd">
|
||||
{ categoryLabels(d) }
|
||||
// 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 {
|
||||
· ei lisukkeita
|
||||
<div class="sd">Ei lisukkeita</div>
|
||||
}
|
||||
</div>
|
||||
@rowActions("/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
||||
</div>
|
||||
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@sideForm_(v.Side)
|
||||
<h3 class="sechead">Lisukkeet</h3>
|
||||
@@ -412,7 +415,7 @@ templ catalogPage(v catalogView) {
|
||||
for _, s := range v.Sides {
|
||||
<div class="row">
|
||||
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
|
||||
@rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
|
||||
@rowActions("/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
|
||||
</div>
|
||||
}
|
||||
<details class="card">
|
||||
@@ -426,7 +429,7 @@ templ catalogPage(v catalogView) {
|
||||
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="/ruoat/poista">
|
||||
<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>
|
||||
@@ -446,7 +449,7 @@ templ mainForm_(f mainForm) {
|
||||
if f.Err != "" {
|
||||
<p class="formerr">{ f.Err }</p>
|
||||
}
|
||||
<form method="post" action="/ruoat/paaruoka">
|
||||
<form method="post" action="/ruuat/paaruoka">
|
||||
if f.ID != 0 {
|
||||
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
||||
}
|
||||
@@ -474,7 +477,7 @@ templ mainForm_(f mainForm) {
|
||||
<button class="primary" type="submit">Tallenna</button>
|
||||
</form>
|
||||
if f.ID != 0 {
|
||||
<a class="ghost" href="/ruoat">Peruuta</a>
|
||||
<a class="ghost" href="/ruuat">Peruuta</a>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -503,7 +506,7 @@ templ sideForm_(f sideForm) {
|
||||
if f.Err != "" {
|
||||
<p class="formerr">{ f.Err }</p>
|
||||
}
|
||||
<form method="post" action="/ruoat/lisuke">
|
||||
<form method="post" action="/ruuat/lisuke">
|
||||
if f.ID != 0 {
|
||||
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
||||
}
|
||||
@@ -514,7 +517,7 @@ templ sideForm_(f sideForm) {
|
||||
<button class="primary" type="submit">Tallenna</button>
|
||||
</form>
|
||||
if f.ID != 0 {
|
||||
<a class="ghost" href="/ruoat">Peruuta</a>
|
||||
<a class="ghost" href="/ruuat">Peruuta</a>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -525,7 +528,7 @@ templ importForm() {
|
||||
<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">
|
||||
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
|
||||
<label class="field">
|
||||
<span>JSON</span>
|
||||
<textarea
|
||||
|
||||
+18
-18
@@ -75,29 +75,29 @@ check "manifest has the right content type" \
|
||||
"application/manifest+json"
|
||||
|
||||
check "catalog starts empty" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "0 pääruokaa"
|
||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa"
|
||||
|
||||
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")
|
||||
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
|
||||
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
|
||||
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
|
||||
|
||||
check "re-import refuses duplicates" \
|
||||
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")" \
|
||||
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
|
||||
"jo listalla"
|
||||
|
||||
check "pasted JSON imports" \
|
||||
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
|
||||
"http://$addr/ruoat/tuonti")" "Lisätty 1"
|
||||
"http://$addr/ruuat/tuonti")" "Lisätty 1"
|
||||
|
||||
check "unknown category is reported" \
|
||||
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
|
||||
"http://$addr/ruoat/tuonti")" "tuntematon kategoria"
|
||||
"http://$addr/ruuat/tuonti")" "tuntematon kategoria"
|
||||
|
||||
check "empty submit is explained" \
|
||||
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruoat/tuonti")" "Ei tuotavaa"
|
||||
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
|
||||
|
||||
check "malformed JSON is explained" \
|
||||
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa"
|
||||
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
|
||||
|
||||
# ---- the log flow, against the dishes imported above --------------------
|
||||
|
||||
@@ -122,8 +122,8 @@ 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 lists the entry" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu"
|
||||
check "history is on the same page as the logger" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
|
||||
|
||||
check "deleting redirects back" \
|
||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||
@@ -157,37 +157,37 @@ check "the quick-added dish is on the board" \
|
||||
|
||||
check "adding a main redirects" \
|
||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruoat/paaruoka")" "303"
|
||||
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303"
|
||||
|
||||
catalog=$(curl -s -u ":$pass" "http://$addr/ruoat")
|
||||
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
|
||||
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
||||
|
||||
check "a duplicate name is refused" \
|
||||
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
|
||||
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||
"Nimi on jo listalla."
|
||||
|
||||
check "a main with no category is refused" \
|
||||
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruoat/paaruoka")" \
|
||||
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
|
||||
"Valitse vähintään yksi kategoria."
|
||||
|
||||
check "a nameless dish is refused" \
|
||||
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
|
||||
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||
"Anna nimi."
|
||||
|
||||
check "adding a side redirects" \
|
||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||
-d 'nimi=lohkoperunat' "http://$addr/ruoat/lisuke")" "303"
|
||||
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303"
|
||||
|
||||
check "the new side is listed" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "Lohkoperunat"
|
||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
|
||||
|
||||
uusi=$(printf '%s' "$catalog" | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
|
||||
check "the edit form is prefilled" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/ruoat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
||||
"$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
||||
|
||||
check "deleting a main redirects" \
|
||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||
-d "id=$uusi&tyyppi=paa" "http://$addr/ruoat/poista")" "303"
|
||||
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303"
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "smoke: FAILED"
|
||||
|
||||
Reference in New Issue
Block a user