Files
foodster/cmd/foodster/handlers.go
T
Esa Kataja b8a46cdd30 Patch the day list in place instead of navigating
Kirjaa now works like the catalog: opening a day, picking a dish, saving,
deleting, cancelling and "Näytä lisää" all patch the list where it stands.
Nothing loads a page, so the scroll position never moves.

One builder serves all three paths. buildLog takes what the screen should
show — the day, an open dish, whether the entry is being changed or a delete
confirmed — and the page render, the patch and the post-write response all go
through it. After a save it is called with only the date, so the day comes
back closed rather than reopening the sides step it was just submitted from.

Links stay links and forms stay forms, with data-on:click__prevent and
data-on:submit__prevent layered over them, so it all still works with
JavaScript off. Each response patches a single element, so plain text/html is
enough here; the SSE writer is only needed by the catalog, where the list and
both forms have to move together.

The anchors added in the previous attempt are gone. They could never have
worked: the browser positions an anchor without knowing where the page was
scrolled, so it jumped regardless.
2026-09-05 23:45:53 +03:00

727 lines
22 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/a-h/templ"
)
// maxUpload caps a pasted or uploaded bundle. A household catalog is a few
// kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20
const (
// historyDays is one window of the history under the logger, and the step
// that "show more" grows it by. Older days arrive a window at a time
// rather than all at once.
historyDays = 30
// maxHistoryDays caps what a hand-edited URL can ask for, so ?paivat=
// cannot be turned into a request to render a decade of rows.
maxHistoryDays = 366 * 5
)
// historyWindow reads ?paivat=, the number of days of history to show.
func historyWindow(r *http.Request) int {
days := historyDays
if raw := r.URL.Query().Get("paivat"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > days {
days = min(n, maxHistoryDays)
}
}
return days
}
type app struct {
db *sql.DB
loc *time.Location
}
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {
log.Printf("render %s: %v", r.URL.Path, err)
}
}
// date reads the ?pvm= parameter, falling back to today. An unparseable value
// is treated as today rather than an error: a mangled URL should not be a
// dead end.
//
// A future date is clamped to today. This is a record of what was eaten, so
// there is nothing to write down for a dinner that has not happened, and a
// stray entry dated next year would sit at the top of the history forever.
// Every read and write goes through here, so the clamp covers them all.
func (a *app) date(r *http.Request) time.Time {
now := today(a.loc)
if raw := r.FormValue("pvm"); raw != "" {
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
if d.After(now) {
return now
}
return d
}
}
return now
}
// 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
ShowBoard bool
Dishes []Dish // flat, only to know whether anything matched
Groups []DishGroup // what the board actually renders
Special []Dish // Tähteet and the like: loggable, but not food
Sides []Side
New mainForm // inline "add the dish you were looking for"
History HistoryPage
HistoryDays int // size of the window currently shown
HistoryMore int // the window size the "show more" link asks for
// Deleting a logged meal drops the row outright, unlike a dish which is
// only soft-deleted, so it asks first.
Confirming bool
}
// logOptions is what the Kirjaa screen is being asked to show. Pulled out of
// the request for a page load or a patch, and set directly after a write,
// where the answer is simply "that day, nothing else open".
type logOptions struct {
Date time.Time
Dish string // ?ruoka=, opening the sides step
Changing bool // ?muuta=, swapping the dish on a logged day
Confirming bool // ?poista=, asking before deleting the entry
Search string
}
func (a *app) logOptionsFrom(r *http.Request) logOptions {
q := r.URL.Query()
return logOptions{
Date: a.date(r),
Dish: q.Get("ruoka"),
Changing: q.Get("muuta") != "",
Confirming: q.Get("poista") != "",
Search: strings.TrimSpace(q.Get("haku")),
}
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
render(w, r, logPage(a.buildLog(r, a.logOptionsFrom(r))))
}
// day patches the list in place. Every link in it calls here rather than
// loading a page, so opening a day leaves the scroll position alone.
func (a *app) day(w http.ResponseWriter, r *http.Request) {
fragment(w, r, dayList(a.buildLog(r, a.logOptionsFrom(r))))
}
// finishDay answers a write: a patch for Datastar, a redirect otherwise.
func (a *app) finishDay(w http.ResponseWriter, r *http.Request, date time.Time) {
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, logOptions{Date: date})))
return
}
a.redirectToDay(w, r, date)
}
func (a *app) buildLog(r *http.Request, o logOptions) logView {
date := o.Date
v := logView{
Date: date,
Today: today(a.loc),
Search: o.Search,
Checked: map[int64]bool{},
Confirming: o.Confirming,
}
entry, err := entryFor(a.db, date)
if err != nil {
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
}
v.Entry = entry
// ?ruoka= opens the sides step for that dish. When it is the dish already
// logged, the existing sides come back ticked, which makes editing an
// entry the same screen as creating one.
if o.Dish != "" {
if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil {
if dish, err := dishByID(a.db, id); err == nil {
v.Chosen = dish
if entry != nil && entry.Main.ID == id {
for _, s := range entry.Sides {
v.Checked[s.ID] = true
}
}
}
}
}
// 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 := o.Changing || 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)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("list special: %v", err)
}
// 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}
}
if v.Chosen != nil && v.Chosen.HasSides {
if v.Sides, err = listSides(a.db, ""); err != nil {
log.Printf("list sides: %v", err)
}
}
a.loadDays(r, &v)
return v
}
// loadDays fills the day list. The selected day expands inside it rather than
// in a panel above it, so choosing a day from the list does not reorder the
// list underneath the tap.
func (a *app) loadDays(r *http.Request, v *logView) {
v.HistoryDays = historyWindow(r)
// The window has to reach the selected day, or it would have nowhere to
// expand.
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
v.HistoryDays = min(reach, maxHistoryDays)
}
v.HistoryMore = v.HistoryDays + historyDays
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
if err != nil {
log.Printf("history: %v", err)
}
// Nothing logged ever: the selected day is still the one being worked on,
// so it needs a row of its own to open in.
if len(page.Rows) == 0 {
page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}}
}
v.History = page
}
// searchSignals is what Datastar sends back: for a GET it JSON-encodes the
// signals into the `datastar` query parameter.
type searchSignals struct {
Haku string `json:"haku"`
}
// readSignals decodes that parameter.
//
// ponytail: the Datastar SDK does this too, but pulling it in for one JSON
// decode dragged along four modules — an HTTP compression stack among them —
// for an SSE generator this app never uses. Absent or empty is not an error:
// the first request carries no signals.
func readSignals(r *http.Request, into any) error {
raw := r.URL.Query().Get("datastar")
if raw == "" {
return nil
}
return json.Unmarshal([]byte(raw), into)
}
// fragment renders a piece of a page for Datastar to patch in. A plain
// text/html response is enough — Datastar matches the returned element by its
// id and replaces it, so there is no SSE stream to manage.
func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {
log.Printf("fragment %s: %v", r.URL.Path, err)
}
}
// isDatastar reports whether the request came from the client library, which
// tags its own. Everything below keeps working without JavaScript: the same
// handlers redirect instead of patching when the header is absent.
func isDatastar(r *http.Request) bool {
return r.Header.Get("Datastar-Request") != ""
}
// patchElements sends one Datastar event carrying several elements, each
// matched to the page by its id. A text/html response can only replace one
// element, and the catalog has to move its list and its forms together —
// opening an edit form also has to un-highlight whatever was open before.
//
// ponytail: about twenty lines instead of the SDK, which brought four modules
// for an SSE generator we would otherwise never call.
func patchElements(w http.ResponseWriter, r *http.Request, components ...templ.Component) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
var out strings.Builder
out.WriteString("event: datastar-patch-elements\n")
for _, c := range components {
var html strings.Builder
if err := c.Render(r.Context(), &html); err != nil {
log.Printf("patch %s: %v", r.URL.Path, err)
return
}
// One `data: elements` line per line of HTML, as the protocol wants.
for _, line := range strings.Split(html.String(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
out.WriteString("data: elements ")
out.WriteString(line)
out.WriteString("\n")
}
}
out.WriteString("\n")
io.WriteString(w, out.String())
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// searchBoard re-renders the dish board as the search box is typed into.
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
var signals searchSignals
if err := readSignals(r, &signals); err != nil {
http.Error(w, "bad signals", http.StatusBadRequest)
return
}
v := logView{
Date: a.date(r),
Today: today(a.loc),
Search: strings.TrimSpace(signals.Haku),
}
dishes, err := listDishes(a.db, v.Search)
if err != nil {
log.Printf("search dishes: %v", err)
}
v.Dishes = dishes
v.Groups = groupDishes(dishes)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("search special: %v", err)
}
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
fragment(w, r, boardList(v))
}
// searchCatalog re-renders the catalog lists as the search box is typed into.
func (a *app) searchCatalog(w http.ResponseWriter, r *http.Request) {
var signals searchSignals
if err := readSignals(r, &signals); err != nil {
http.Error(w, "bad signals", http.StatusBadRequest)
return
}
v := catalogView{Search: strings.TrimSpace(signals.Haku)}
mains, err := listDishes(a.db, v.Search)
if err != nil {
log.Printf("search catalog: %v", err)
}
v.Mains = len(mains)
sortByName(mains)
v.Groups = groupDishes(mains)
if v.Sides, err = listSides(a.db, v.Search); err != nil {
log.Printf("search sides: %v", err)
}
fragment(w, r, catalogList(v))
}
// quickAdd creates a dish from the Kirjaa screen and goes straight on to
// logging it. Hunting for something that is not in the catalog yet should not
// mean a detour through Ruoat and a lost train of thought.
func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
date := a.date(r)
form := mainForm{
Name: normalizeName(r.FormValue("nimi")),
Categories: map[string]bool{},
HasSides: r.FormValue("lisukkeita") != "",
}
var categories []string
for _, c := range r.Form["kategoria"] {
if validCategories[c] {
categories = append(categories, c)
form.Categories[c] = true
}
}
switch {
case form.Name == "":
form.Err = "Anna nimi."
case len(categories) == 0:
form.Err = "Valitse vähintään yksi kategoria."
}
if form.Err == "" {
id, err := createMain(a.db, form.Name, categories, form.HasSides)
switch {
case errors.Is(err, errNameTaken):
form.Err = "Nimi on jo listalla."
case err != nil:
log.Printf("quick add: %v", err)
form.Err = "Tallennus epäonnistui."
default:
// Created: straight on to its sides step.
opts := logOptions{Date: date, Dish: strconv.FormatInt(id, 10)}
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, opts)))
return
}
a.redirectToPick(w, r, date, id)
return
}
}
// Rejected: back to the board with the form filled in and the search
// still narrowed, so the add card stays on screen.
v := a.buildLog(r, logOptions{Date: date, Search: form.Name})
v.New = form
if isDatastar(r) {
fragment(w, r, dayList(v))
return
}
render(w, r, logPage(v))
}
func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.Time, id int64) {
target := dayURL("/", date, today(a.loc))
sep := "?"
if strings.Contains(target, "?") {
sep = "&"
}
http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// save records the meal and redirects, so a refresh cannot double-post.
func (a *app) save(w http.ResponseWriter, r *http.Request) {
date := a.date(r)
mainID, err := strconv.ParseInt(r.FormValue("ruoka"), 10, 64)
if err != nil {
http.Error(w, "tuntematon ruoka", http.StatusBadRequest)
return
}
var sideIDs []int64
for _, raw := range r.Form["lisuke"] {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
sideIDs = append(sideIDs, id)
}
}
if err := saveEntry(a.db, date, mainID, sideIDs); err != nil {
log.Printf("save entry: %v", err)
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
return
}
a.finishDay(w, r, date)
}
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
date := a.date(r)
if err := deleteEntry(a.db, date); err != nil {
log.Printf("delete entry: %v", err)
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return
}
a.finishDay(w, r, date)
}
// redirectToDay is the no-JavaScript path back after a write.
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther)
}
// mainForm and sideForm carry what the user typed, so a rejected submission
// comes back filled in rather than blank.
type mainForm struct {
ID int64
Name string
Categories map[string]bool
HasSides bool
Err string
}
type sideForm struct {
ID int64
Name string
Err string
}
type catalogView struct {
Groups []DishGroup
Sides []Side
Main mainForm
Side sideForm
Report *ImportReport
Mains int // count, for the header
Search string
// The row awaiting a delete confirmation, if any. A trash icon is easy to
// hit by accident, so the row asks before anything happens.
DeleteID int64
DeleteKind string
}
// catalog renders the whole page. show patches the same state in place, so
// nothing navigates: both build the view the same way.
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
a.renderCatalog(w, r, a.catalogState(r))
}
// show is what every catalog link actually calls. It patches the list and both
// forms rather than loading a page, so opening an edit form or asking to
// delete a row leaves the scroll position exactly where it was.
func (a *app) show(w http.ResponseWriter, r *http.Request) {
a.patchCatalog(w, r, a.catalogState(r))
}
func (a *app) catalogState(r *http.Request) catalogView {
v := catalogView{
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
}
// ?muokkaa= loads a dish into its form; the same form adds and edits.
if raw := r.URL.Query().Get("muokkaa"); raw != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
if dish, err := dishByID(a.db, id); err == nil {
v.Main = mainForm{
ID: dish.ID,
Name: dish.Name,
Categories: map[string]bool{},
HasSides: dish.HasSides,
}
for _, c := range dish.Categories {
v.Main.Categories[c] = true
}
}
}
}
if raw := r.URL.Query().Get("muokkaa-lisuke"); raw != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
if side, err := sideByID(a.db, id); err == nil {
v.Side = sideForm{ID: side.ID, Name: side.Name}
}
}
}
if raw := r.URL.Query().Get("poista"); raw != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
v.DeleteID = id
v.DeleteKind = r.URL.Query().Get("tyyppi")
}
}
return v
}
// fillCatalog loads the lists into a view built from the request.
func (a *app) fillCatalog(v *catalogView) {
if v.Main.Categories == nil {
v.Main.Categories = map[string]bool{}
}
mains, err := listDishes(a.db, v.Search)
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, v.Search); err != nil {
log.Printf("list sides: %v", err)
}
}
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
render(w, r, catalogPage(v))
}
// patchCatalog swaps the list and both forms in one event. They move together:
// opening an edit form also has to clear whatever delete was being confirmed.
func (a *app) patchCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
patchElements(w, r, catalogList(v), mainForm_(v.Main), sideForm_(v.Side))
}
// saveMain adds or updates a main dish. A rejected form is re-rendered with
// the values still in it; a good one redirects, so refresh cannot re-submit.
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
form := mainForm{
Name: normalizeName(r.FormValue("nimi")),
Categories: map[string]bool{},
HasSides: r.FormValue("lisukkeita") != "",
}
if raw := r.FormValue("id"); raw != "" {
form.ID, _ = strconv.ParseInt(raw, 10, 64)
}
var categories []string
for _, c := range r.Form["kategoria"] {
if validCategories[c] {
categories = append(categories, c)
form.Categories[c] = true
}
}
switch {
case form.Name == "":
form.Err = "Anna nimi."
case len(categories) == 0:
form.Err = "Valitse vähintään yksi kategoria."
}
if form.Err == "" {
var err error
if form.ID == 0 {
_, err = createMain(a.db, form.Name, categories, form.HasSides)
} else {
err = updateMain(a.db, form.ID, form.Name, categories, form.HasSides)
}
switch {
case errors.Is(err, errNameTaken):
form.Err = "Nimi on jo listalla."
case err != nil:
log.Printf("save main: %v", err)
form.Err = "Tallennus epäonnistui."
default:
// Saved: hand back a blank form so it collapses, and a list with
// the dish in it.
a.finishCatalog(w, r, catalogView{})
return
}
}
a.finishCatalog(w, r, catalogView{Main: form})
}
// finishCatalog answers a catalog write: a patch for Datastar, a redirect for
// a plain form post. Without the redirect, submitting with JavaScript off
// would leave the browser sitting on a POST it could not reload.
func (a *app) finishCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
if isDatastar(r) {
a.patchCatalog(w, r, v)
return
}
if v.Main.Err != "" || v.Side.Err != "" {
a.renderCatalog(w, r, v)
return
}
http.Redirect(w, r, "/ruuat", http.StatusSeeOther)
}
func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
form := sideForm{Name: normalizeName(r.FormValue("nimi"))}
if raw := r.FormValue("id"); raw != "" {
form.ID, _ = strconv.ParseInt(raw, 10, 64)
}
if form.Name == "" {
form.Err = "Anna nimi."
} else {
var err error
if form.ID == 0 {
err = createSide(a.db, form.Name)
} else {
err = updateSide(a.db, form.ID, form.Name)
}
switch {
case errors.Is(err, errNameTaken):
form.Err = "Nimi on jo listalla."
case err != nil:
log.Printf("save side: %v", err)
form.Err = "Tallennus epäonnistui."
default:
a.finishCatalog(w, r, catalogView{})
return
}
}
a.finishCatalog(w, r, catalogView{Side: form})
}
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
//
// ponytail: no confirmation step. The row survives and history still reads
// correctly; add a confirm or an undo list if anyone actually mis-taps.
func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
http.Error(w, "tuntematon ruoka", http.StatusBadRequest)
return
}
if r.FormValue("tyyppi") == "lisuke" {
err = softDeleteSide(a.db, id)
} else {
err = softDeleteMain(a.db, id)
}
if err != nil {
log.Printf("delete dish: %v", err)
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return
}
a.finishCatalog(w, r, catalogView{})
}
// importDishes takes a bundle either pasted into the textarea or uploaded as a
// file, and reports row by row what happened (PRD §7.3).
//
// ponytail: a plain multipart form rather than a Datastar round trip. The
// result is a whole-page report, not a fragment, and a form needs no client
// code at all.
func (a *app) importDishes(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
if err := r.ParseMultipartForm(maxUpload); err != nil {
a.renderCatalog(w, r, catalogView{Report: failed("Tiedosto on liian suuri tai vioittunut.")})
return
}
var src io.Reader
if file, _, err := r.FormFile("tiedosto"); err == nil {
defer file.Close()
src = file
} else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" {
src = strings.NewReader(pasted)
} else {
a.renderCatalog(w, r, catalogView{Report: failed("Ei tuotavaa: liitä JSON tai valitse tiedosto.")})
return
}
report, err := importBundle(a.db, src)
if err != nil {
a.renderCatalog(w, r, catalogView{Report: failed("JSON ei kelpaa: " + err.Error())})
return
}
a.renderCatalog(w, r, catalogView{Report: report})
}
// failed builds a report for a whole-request failure, so the view only ever
// has one shape to render.
func failed(note string) *ImportReport {
return &ImportReport{Skipped: 1, Notes: []string{note}}
}