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.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+50
-29
@@ -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,17 +47,22 @@ 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
|
||||||
Search string
|
Search string
|
||||||
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
|
||||||
Sides []Side
|
Dishes []Dish // flat, only to know whether anything matched
|
||||||
New mainForm // inline "add the dish you were looking for"
|
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) {
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
// Rejected: back to the board with the form filled in and the search
|
||||||
// still narrowed, so the add card stays on screen.
|
// still narrowed, so the add card stays on screen.
|
||||||
v := logView{
|
v := logView{
|
||||||
Date: date,
|
Date: date,
|
||||||
Today: today(a.loc),
|
Today: today(a.loc),
|
||||||
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
|
||||||
|
|||||||
+75
-72
@@ -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
|
||||||
<div class="row">
|
// you manage, so a predictable position beats a useful one.
|
||||||
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
for _, g := range v.Groups {
|
||||||
<div class="rowtext">
|
<h3 class="sechead">{ g.Label }</h3>
|
||||||
<div class="nm">{ d.Name }</div>
|
for _, d := range g.Dishes {
|
||||||
<div class="sd">
|
<div class="row">
|
||||||
{ categoryLabels(d) }
|
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
||||||
|
<div class="rowtext">
|
||||||
|
<div class="nm">{ d.Name }</div>
|
||||||
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