Files
foodster/cmd/foodster/handlers.go
T
Esa Kataja b67c4edd5a Ask before deleting, and put the row actions on icons
Muokkaa and Poista become a pencil and a bin, which stops the catalog rows
being two words wide. Both carry a Finnish aria-label and title, so nothing
is lost by dropping the text.

An icon is easier to hit by accident than a word, so neither delete happens
immediately now. A tapped bin turns that row's actions into "Poista? Kyllä /
Peruuta", and a logged meal asks "Poistetaanko merkintä?" before it goes.
The meal is the more destructive of the two: a dish is only soft-deleted and
its name still resolves in old entries, while the log row is dropped outright.

Both confirmations are plain links and forms, so they work with the back
button and need no client code.

Also fixes a test that was passing for the wrong reason. The delete check only
asserted a 303 and took the first dish id on the page, which stopped being the
one it had just created when the catalog became grouped and alphabetical — so
it was deleting an unrelated dish. It now finds that dish's own id, and a new
refute helper asserts the dish is actually gone afterwards.
2026-09-05 21:34:18 +03:00

481 lines
14 KiB
Go

package main
import (
"database/sql"
"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
// historyDays is how far back the history under the logger walks.
//
// ponytail: a fixed window. After a year of daily entries this list is the
// thing that needs paging; load more on scroll when it actually hurts.
const historyDays = 60
type app struct {
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
Sides []Side
New mainForm // inline "add the dish you were looking for"
History []HistoryRow
// Deleting a logged meal drops the row outright, unlike a dish which is
// only soft-deleted, so it asks first.
Confirming bool
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
date := a.date(r)
v := logView{
Date: date,
Today: today(a.loc),
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
Checked: map[int64]bool{},
}
v.Confirming = r.URL.Query().Get("poista") != ""
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 raw := r.URL.Query().Get("ruoka"); raw != "" {
if id, err := strconv.ParseInt(raw, 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 := r.URL.Query().Get("muuta") != "" || v.Search != ""
if v.Chosen == nil && (v.Entry == nil || changing) {
v.ShowBoard = true
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
}
// listDishes already orders by frequency then name, so grouping keeps
// the favourites at the top of each category.
v.Groups = groupDishes(v.Dishes)
// Seed the inline add form with whatever was searched for, so a miss
// turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
}
if v.Chosen != nil && v.Chosen.HasSides {
if v.Sides, err = listSides(a.db); err != nil {
log.Printf("list sides: %v", err)
}
}
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
log.Printf("history: %v", err)
}
render(w, r, logPage(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:
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 := logView{
Date: date,
Today: today(a.loc),
Search: form.Name,
Checked: map[int64]bool{},
New: form,
ShowBoard: true,
}
var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
}
v.Groups = groupDishes(v.Dishes)
if v.History, err = history(a.db, a.loc, historyDays); err != nil {
log.Printf("history: %v", err)
}
render(w, r, logPage(v))
}
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.redirectToDay(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.redirectToDay(w, r, date)
}
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
target := "/"
if !date.Equal(today(a.loc)) {
target += "?pvm=" + date.Format(dateLayout)
}
http.Redirect(w, r, target, 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
// 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
}
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
v := catalogView{
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
}
// ?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")
}
}
a.renderCatalog(w, r, v)
}
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
if v.Main.Categories == nil {
v.Main.Categories = map[string]bool{}
}
mains, err := listDishes(a.db, "")
if err != nil {
log.Printf("list mains: %v", err)
}
v.Mains = len(mains)
sortByName(mains) // the catalog is managed, so position should be predictable
v.Groups = groupDishes(mains)
if v.Sides, err = listSides(a.db); err != nil {
log.Printf("list sides: %v", err)
}
render(w, r, catalogPage(v))
}
// 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:
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
return
}
}
a.renderCatalog(w, r, catalogView{Main: form})
}
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:
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
return
}
}
a.renderCatalog(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
}
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
}
// 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}}
}