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
|
lines of `database/sql`. There are no down-migrations: restoring the
|
||||||
database file is the rollback for a single-household app.
|
database file is the rollback for a single-household app.
|
||||||
- **Bundle import**: the §7.3 mass import is a live feature of the running
|
- **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
|
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
|
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
|
are capped at 1 MiB. The same importer is also reachable as
|
||||||
|
|||||||
@@ -17,18 +17,29 @@ weeks of real history to weight against.
|
|||||||
|
|
||||||
Working:
|
Working:
|
||||||
|
|
||||||
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are sized
|
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered
|
||||||
by how often they are eaten. Edit or delete the day's entry.
|
and sized by how often they are eaten, so the likely answer is the biggest
|
||||||
- **Historia** — every day back to the first entry, with unlogged days shown
|
target. The history sits on the same page underneath: every day back to the
|
||||||
as explicit gaps.
|
first entry, unlogged days shown as explicit gaps, and every row a link that
|
||||||
- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle
|
loads that day into the logger above it.
|
||||||
by paste or file upload. Deletes are soft, so old log entries keep showing
|
- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
|
||||||
the dish they used.
|
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
|
- **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
|
the theme that is on — moon while dark, sun while light — not the one a
|
||||||
click would bring.
|
click would bring.
|
||||||
|
|
||||||
Still to build:
|
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
|
- Stage 2: the seven-meal suggester, which starts once there is history to
|
||||||
weight against.
|
weight against.
|
||||||
|
|
||||||
@@ -99,7 +110,7 @@ make up/down/logs compose
|
|||||||
|
|
||||||
## Importing dishes
|
## 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.
|
a file, and the app reports row by row what it did.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+37
-16
@@ -17,8 +17,10 @@ import (
|
|||||||
// kilobytes; a megabyte is already absurd generosity.
|
// kilobytes; a megabyte is already absurd generosity.
|
||||||
const maxUpload = 1 << 20
|
const maxUpload = 1 << 20
|
||||||
|
|
||||||
// historyDays is how far back the Historia list walks. Long enough to see a
|
// historyDays is how far back the history under the logger walks.
|
||||||
// couple of months, short enough to stay one scroll.
|
//
|
||||||
|
// 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
|
const historyDays = 60
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
@@ -45,7 +47,9 @@ func (a *app) date(r *http.Request) time.Time {
|
|||||||
return today(a.loc)
|
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 {
|
type logView struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
Today time.Time
|
Today time.Time
|
||||||
@@ -53,9 +57,12 @@ type logView struct {
|
|||||||
Entry *Entry // what is already logged for Date, if anything
|
Entry *Entry // what is already logged for Date, if anything
|
||||||
Chosen *Dish // dish picked, so the sides step is showing
|
Chosen *Dish // dish picked, so the sides step is showing
|
||||||
Checked map[int64]bool // sides ticked in that step
|
Checked map[int64]bool // sides ticked in that step
|
||||||
Dishes []Dish
|
ShowBoard bool
|
||||||
|
Dishes []Dish // flat, only to know whether anything matched
|
||||||
|
Groups []DishGroup // what the board actually renders
|
||||||
Sides []Side
|
Sides []Side
|
||||||
New mainForm // inline "add the dish you were looking for"
|
New mainForm // inline "add the dish you were looking for"
|
||||||
|
History []HistoryRow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||||
log.Printf("list dishes: %v", err)
|
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
|
// Seed the inline add form with whatever was searched for, so a miss
|
||||||
// turns straight into "add it" without retyping.
|
// turns straight into "add it" without retyping.
|
||||||
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
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))
|
render(w, r, logPage(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +173,16 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
Search: form.Name,
|
Search: form.Name,
|
||||||
Checked: map[int64]bool{},
|
Checked: map[int64]bool{},
|
||||||
New: form,
|
New: form,
|
||||||
|
ShowBoard: true,
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||||
log.Printf("list dishes: %v", err)
|
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))
|
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)
|
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
|
// mainForm and sideForm carry what the user typed, so a rejected submission
|
||||||
// comes back filled in rather than blank.
|
// comes back filled in rather than blank.
|
||||||
type mainForm struct {
|
type mainForm struct {
|
||||||
@@ -239,11 +255,12 @@ type sideForm struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type catalogView struct {
|
type catalogView struct {
|
||||||
Mains []Dish
|
Groups []DishGroup
|
||||||
Sides []Side
|
Sides []Side
|
||||||
Main mainForm
|
Main mainForm
|
||||||
Side sideForm
|
Side sideForm
|
||||||
Report *ImportReport
|
Report *ImportReport
|
||||||
|
Mains int // count, for the header
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
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{}
|
v.Main.Categories = map[string]bool{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
mains, err := listDishes(a.db, "")
|
||||||
if v.Mains, err = listDishes(a.db, ""); err != nil {
|
if err != nil {
|
||||||
log.Printf("list mains: %v", err)
|
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 {
|
if v.Sides, err = listSides(a.db); err != nil {
|
||||||
log.Printf("list sides: %v", err)
|
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 /kirjaa", a.save)
|
||||||
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
||||||
mux.HandleFunc("POST /poista", a.delete)
|
mux.HandleFunc("POST /poista", a.delete)
|
||||||
mux.HandleFunc("GET /historia", a.history)
|
mux.HandleFunc("GET /ruuat", a.catalog)
|
||||||
mux.HandleFunc("GET /ruoat", a.catalog)
|
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
|
||||||
mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
|
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
|
||||||
mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
|
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
||||||
mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
|
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
|
||||||
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
|
||||||
|
|
||||||
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
||||||
root := http.NewServeMux()
|
root := http.NewServeMux()
|
||||||
|
|||||||
@@ -307,6 +307,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
/* History sits under the logger on the same page, so whole rows are links. */
|
||||||
|
.history { margin-top: 8px; }
|
||||||
.entry, .gapline {
|
.entry, .gapline {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -314,6 +316,14 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
|
|||||||
min-height: var(--tap);
|
min-height: var(--tap);
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
border-bottom: 1px solid var(--line);
|
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); }
|
.gapline { border-bottom-style: dashed; font-size: 13.5px; color: var(--muted); }
|
||||||
.entry time, .gapline time {
|
.entry time, .gapline time {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -341,6 +342,53 @@ func sideByID(db *sql.DB, id int64) (*Side, error) {
|
|||||||
return &s, nil
|
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.
|
// HistoryRow is one calendar day: either what was eaten or an unfilled gap.
|
||||||
type HistoryRow struct {
|
type HistoryRow struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
|
|||||||
+71
-68
@@ -109,9 +109,6 @@ templ brandbar() {
|
|||||||
</header>
|
</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
|
// 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
|
// 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.
|
// picks one, so the server never has to know the device's choice.
|
||||||
@@ -145,8 +142,7 @@ templ iconMoon() {
|
|||||||
templ tabbar(current string) {
|
templ tabbar(current string) {
|
||||||
<nav class="tabbar">
|
<nav class="tabbar">
|
||||||
@tab("/", "Kirjaa", current)
|
@tab("/", "Kirjaa", current)
|
||||||
@tab("/historia", "Historia", current)
|
@tab("/ruuat", "Ruuat", current)
|
||||||
@tab("/ruoat", "Ruoat", current)
|
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,15 +166,53 @@ templ logPage(v logView) {
|
|||||||
switch {
|
switch {
|
||||||
case v.Chosen != nil:
|
case v.Chosen != nil:
|
||||||
@sidesStep(v)
|
@sidesStep(v)
|
||||||
case v.Entry != nil:
|
case v.ShowBoard:
|
||||||
@loggedCard(v)
|
|
||||||
default:
|
|
||||||
@board(v)
|
@board(v)
|
||||||
|
default:
|
||||||
|
@loggedCard(v)
|
||||||
}
|
}
|
||||||
|
@historyList(v)
|
||||||
</main>
|
</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) {
|
templ daySwitch(v logView) {
|
||||||
<div class="dayseg">
|
<div class="dayseg">
|
||||||
@dayButton("Tänään", v.Today, v.Date, v.Today)
|
@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"/>
|
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
|
||||||
</form>
|
</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">
|
<div class="board">
|
||||||
for _, d := range v.Dishes {
|
for _, d := range g.Dishes {
|
||||||
@dishPill(d, v)
|
@dishPill(d, v)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +263,7 @@ templ quickAddCard(v logView) {
|
|||||||
if v.Search == "" {
|
if v.Search == "" {
|
||||||
<h3>Lisää ensimmäinen ruoka</h3>
|
<h3>Lisää ensimmäinen ruoka</h3>
|
||||||
<p class="muted small">
|
<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>
|
</p>
|
||||||
} else {
|
} else {
|
||||||
<h3>Ei osumia. Lisätäänkö?</h3>
|
<h3>Ei osumia. Lisätäänkö?</h3>
|
||||||
@@ -323,7 +361,7 @@ templ loggedCard(v logView) {
|
|||||||
<div class="pair">
|
<div class="pair">
|
||||||
<a
|
<a
|
||||||
class="btn"
|
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>
|
>Muokkaa</a>
|
||||||
<form method="post" action="/poista">
|
<form method="post" action="/poista">
|
||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
@@ -334,50 +372,13 @@ templ loggedCard(v logView) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Historia
|
// ---------------------------------------------------------------- Historia
|
||||||
templ historyPage(rows []HistoryRow) {
|
// ---------------------------------------------------------------- Ruuat
|
||||||
@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(v catalogView) {
|
templ catalogPage(v catalogView) {
|
||||||
@page("Ruoat — Foodster", "/ruoat") {
|
@page("Ruuat — Foodster", "/ruuat") {
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<h2>Ruoat</h2>
|
<h2>Ruuat</h2>
|
||||||
<p class="meta">
|
<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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<main class="pad">
|
<main class="pad">
|
||||||
@@ -385,24 +386,26 @@ templ catalogPage(v catalogView) {
|
|||||||
@importReport(v.Report)
|
@importReport(v.Report)
|
||||||
}
|
}
|
||||||
@mainForm_(v.Main)
|
@mainForm_(v.Main)
|
||||||
<h3 class="sechead">Pääruoat</h3>
|
if v.Mains == 0 {
|
||||||
if len(v.Mains) == 0 {
|
<h3 class="sechead">Pääruuat</h3>
|
||||||
<p class="muted small">Ei vielä pääruokia.</p>
|
<p class="muted small">Ei vielä pääruokia.</p>
|
||||||
}
|
}
|
||||||
for _, d := range v.Mains {
|
// 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">
|
<div class="row">
|
||||||
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
||||||
<div class="rowtext">
|
<div class="rowtext">
|
||||||
<div class="nm">{ d.Name }</div>
|
<div class="nm">{ d.Name }</div>
|
||||||
<div class="sd">
|
|
||||||
{ categoryLabels(d) }
|
|
||||||
if !d.HasSides {
|
if !d.HasSides {
|
||||||
· ei lisukkeita
|
<div class="sd">Ei lisukkeita</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@rowActions("/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
||||||
</div>
|
</div>
|
||||||
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
}
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
@sideForm_(v.Side)
|
@sideForm_(v.Side)
|
||||||
<h3 class="sechead">Lisukkeet</h3>
|
<h3 class="sechead">Lisukkeet</h3>
|
||||||
@@ -412,7 +415,7 @@ templ catalogPage(v catalogView) {
|
|||||||
for _, s := range v.Sides {
|
for _, s := range v.Sides {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
<details class="card">
|
<details class="card">
|
||||||
@@ -426,7 +429,7 @@ templ catalogPage(v catalogView) {
|
|||||||
templ rowActions(editURL string, id int64, kind string) {
|
templ rowActions(editURL string, id int64, kind string) {
|
||||||
<div class="rowactions">
|
<div class="rowactions">
|
||||||
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
|
<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="id" value={ strconv.FormatInt(id, 10) }/>
|
||||||
<input type="hidden" name="tyyppi" value={ kind }/>
|
<input type="hidden" name="tyyppi" value={ kind }/>
|
||||||
<button type="submit" class="del" aria-label="Poista">Poista</button>
|
<button type="submit" class="del" aria-label="Poista">Poista</button>
|
||||||
@@ -446,7 +449,7 @@ templ mainForm_(f mainForm) {
|
|||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/ruoat/paaruoka">
|
<form method="post" action="/ruuat/paaruoka">
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
<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>
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
</form>
|
</form>
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<a class="ghost" href="/ruoat">Peruuta</a>
|
<a class="ghost" href="/ruuat">Peruuta</a>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@@ -503,7 +506,7 @@ templ sideForm_(f sideForm) {
|
|||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/ruoat/lisuke">
|
<form method="post" action="/ruuat/lisuke">
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
<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>
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
</form>
|
</form>
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<a class="ghost" href="/ruoat">Peruuta</a>
|
<a class="ghost" href="/ruuat">Peruuta</a>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@@ -525,7 +528,7 @@ templ importForm() {
|
|||||||
<p class="muted small">
|
<p class="muted small">
|
||||||
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
|
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="/ruoat/tuonti" enctype="multipart/form-data">
|
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>JSON</span>
|
<span>JSON</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
+18
-18
@@ -75,29 +75,29 @@ check "manifest has the right content type" \
|
|||||||
"application/manifest+json"
|
"application/manifest+json"
|
||||||
|
|
||||||
check "catalog starts empty" \
|
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 "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
|
||||||
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
|
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
|
||||||
|
|
||||||
check "re-import refuses duplicates" \
|
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"
|
"jo listalla"
|
||||||
|
|
||||||
check "pasted JSON imports" \
|
check "pasted JSON imports" \
|
||||||
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
|
"$(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" \
|
check "unknown category is reported" \
|
||||||
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
|
"$(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" \
|
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" \
|
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 --------------------
|
# ---- 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" \
|
check "the saved day shows what was eaten" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
||||||
|
|
||||||
check "history lists the entry" \
|
check "history is on the same page as the logger" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/historia")" "syyskuu"
|
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
|
||||||
|
|
||||||
check "deleting redirects back" \
|
check "deleting redirects back" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(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" \
|
check "adding a main redirects" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(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 "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
||||||
|
|
||||||
check "a duplicate name is refused" \
|
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."
|
"Nimi on jo listalla."
|
||||||
|
|
||||||
check "a main with no category is refused" \
|
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."
|
"Valitse vähintään yksi kategoria."
|
||||||
|
|
||||||
check "a nameless dish is refused" \
|
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."
|
"Anna nimi."
|
||||||
|
|
||||||
check "adding a side redirects" \
|
check "adding a side redirects" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(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" \
|
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)
|
uusi=$(printf '%s' "$catalog" | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
|
||||||
check "the edit form is prefilled" \
|
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" \
|
check "deleting a main redirects" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(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
|
if [ "$fail" -ne 0 ]; then
|
||||||
echo "smoke: FAILED"
|
echo "smoke: FAILED"
|
||||||
|
|||||||
Reference in New Issue
Block a user