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.
481 lines
14 KiB
Go
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}}
|
|
}
|