From c9a63bdd9e522ad9079469a7803a5b5a70f3ecae Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 18:15:17 +0300 Subject: [PATCH] Scaffold the Go app: auth, migrations, bundle import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring up the stage 1 skeleton described in the PRD, enough that the app builds, serves, and can be populated with dishes. - net/http server with shared-password Basic auth, /healthz outside it, graceful shutdown, and TZ-aware calendar days - SQLite via modernc (pure Go, static binary), opened with WAL and a single connection - migration runner: numbered SQL files embedded and applied once each inside a transaction, recorded in schema_migrations - bundle import (PRD §7.3) as a live feature on the Ruoat tab: paste JSON or upload a file, get a per-row Finnish report. The same importer is reachable as `foodster -import` for repopulating a scratch database - templ views and hand-written CSS with light-dark() theming; the Datastar v1.0.3 client is vendored, since the Go SDK ships no browser asset and a CDN would break an offline LAN Names are normalized to sentence case rather than title case: Finnish capitalizes only the first word of a phrase, so "Keitetyt perunat" is right and "Keitetyt Perunat" is not. PRD §6 and §7.3 are amended to match. Testing is behind make targets rather than ad-hoc commands: `make check` runs lint, unit tests and scripts/smoke.sh, which exercises auth, static assets and every import path against a scratch database on a spare port. --- Makefile | 22 ++- PRD.md | 39 +++- README.md | 45 +++++ cmd/foodster/bundle.go | 182 +++++++++++++++++ cmd/foodster/handlers.go | 91 +++++++++ cmd/foodster/main.go | 194 +++++++++++++++++++ cmd/foodster/main_test.go | 268 ++++++++++++++++++++++++++ cmd/foodster/migrate.go | 74 +++++++ cmd/foodster/migrations/0001_init.sql | 61 ++++++ cmd/foodster/static/app.css | 136 +++++++++++++ cmd/foodster/static/datastar.js | 10 + cmd/foodster/views.templ | 143 ++++++++++++++ go.mod | 34 ++++ go.sum | 84 ++++++++ scripts/smoke.sh | 86 +++++++++ seeds/kotiruoat.json | 18 ++ seeds/testi.json | 28 +++ 17 files changed, 1503 insertions(+), 12 deletions(-) create mode 100644 cmd/foodster/bundle.go create mode 100644 cmd/foodster/handlers.go create mode 100644 cmd/foodster/main.go create mode 100644 cmd/foodster/main_test.go create mode 100644 cmd/foodster/migrate.go create mode 100644 cmd/foodster/migrations/0001_init.sql create mode 100644 cmd/foodster/static/app.css create mode 100644 cmd/foodster/static/datastar.js create mode 100644 cmd/foodster/views.templ create mode 100644 go.mod create mode 100644 go.sum create mode 100755 scripts/smoke.sh create mode 100644 seeds/kotiruoat.json create mode 100644 seeds/testi.json diff --git a/Makefile b/Makefile index 2e3b804..acb1a26 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ COMPOSE ?= podman compose BIN := foodster PKG := ./cmd/foodster +# Vendored Datastar client. Bump, run `make vendor`, commit the result. +DATASTAR_VERSION ?= v1.0.3 +SEED ?= seeds/testi.json + # Registry coordinates, shared password and TZ live here. Gitignored. ifneq (,$(wildcard .env)) include .env @@ -14,7 +18,7 @@ endif GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null) .DEFAULT_GOAL := help -.PHONY: help generate build run test lint fix image push release up down logs clean +.PHONY: help generate build run seed test smoke check lint fix vendor image push release up down logs clean help: ## Show this help @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ @@ -32,9 +36,23 @@ run: generate ## Run locally on :8080 FOODSTER_DB=$${FOODSTER_DB:-./foodster.db} \ go run $(PKG) -test: generate ## Run tests +seed: ## Import a dish bundle (SEED=seeds/testi.json) + go run $(PKG) -import $(SEED) + +test: generate ## Run unit tests go test ./... +smoke: generate ## End-to-end check: auth, static assets, bundle import + ./scripts/smoke.sh + +check: lint test smoke ## Everything that must pass before a commit + @echo "check: all passed" + +vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3) + curl -sSfL -o cmd/foodster/static/datastar.js \ + "https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js" + @head -1 cmd/foodster/static/datastar.js + lint: ## go vet, gofmt check, golangci-lint when installed go vet ./... @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ diff --git a/PRD.md b/PRD.md index a091568..60796ec 100644 --- a/PRD.md +++ b/PRD.md @@ -84,7 +84,11 @@ in English. ### Main dish (stage 1) - `id` -- `name` — stored in Title Case (server normalizes on save). +- `name` — stored in sentence case: the server collapses whitespace and + capitalizes the first letter only, leaving the rest as typed. Finnish + capitalizes just the first word of a phrase, so "Keitetyt perunat" is + correct and "Keitetyt Perunat" is not. Existing capitals are preserved, so + "BBQ-kylkeä" and "Kotipizza" survive. - `categories` — non-empty set drawn from `meat`, `chicken`, `fish`, `vegetarian`. Most mains will have a single category. Dishes where each diner builds their own plate from a shared spread (e.g., tortillas, @@ -101,7 +105,7 @@ in English. ### Side dish (stage 1) - `id` -- `name` — stored in Title Case. +- `name` — stored in sentence case, as for mains. - `created_at` - `deleted_at` — nullable. Same soft-delete semantics as mains. @@ -168,8 +172,8 @@ regenerates or swaps. ### 7.3 Admin (stage 1 — catalog; stage 2 — settings) - Add / edit / delete main dishes (with categories and `has_sides`). Names - are normalized to Title Case on save. Delete is a soft-delete. -- Add / edit / delete side dishes. Same Title Case normalization and + are normalized to sentence case on save. Delete is a soft-delete. +- Add / edit / delete side dishes. Same sentence-case normalization and soft-delete behavior. - Duplicate detection on create/edit is **case-insensitive** against the set of non-soft-deleted items ("chicken curry" and "Chicken Curry" are @@ -191,8 +195,8 @@ regenerates or swaps. ``` - Import is **best-effort, not atomic**: valid rows are inserted, invalid rows (bad categories, missing fields, duplicates) are skipped and - reported back to the user in a per-row summary. Names are Title-Cased - on import; duplicate detection is case-insensitive. + reported back to the user in a per-row summary. Names are normalized to + sentence case on import; duplicate detection is case-insensitive. - Settings page for the cooldown window (default **14 days**, configurable). *(Stage 2 — only relevant once the suggester exists.)* @@ -290,9 +294,20 @@ build and no asset bundler. volume; there is no separate database service. A single household writing one row per day does not need Postgres. - **Data access**: `database/sql` and hand-written SQL. No ORM. -- **Schema**: a single `schema.sql` embedded with `embed.FS` and applied on - startup with `CREATE TABLE IF NOT EXISTS`. A migration tool arrives the - first time a live table genuinely needs altering, not before. +- **Migrations**: numbered `.sql` files under `cmd/foodster/migrations`, + embedded with `embed.FS` and applied in filename order on startup. Each one + runs inside a transaction and is recorded in a `schema_migrations` table, so + it applies exactly once. No migration library — the runner is about sixty + lines of `database/sql`. There are no down-migrations: restoring the + database file is the rollback for a single-household app. +- **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 + 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 + are capped at 1 MiB. The same importer is also reachable as + `foodster -import ` for repopulating a scratch database without + starting the server; `seeds/testi.json` is the committed fixture. One code + path serves both, so the format is exercised twice. - **Time**: the `TZ` environment variable, defaulting to `Europe/Helsinki`, loaded via `time.LoadLocation` and fatal on a bad value — a silent fallback to UTC would shift logged dinners to the wrong calendar day. `time/tzdata` @@ -342,7 +357,11 @@ on the server and run with Docker Compose. has no shell to run one and `restart: unless-stopped` already covers a dead process. Adding one would mean giving the binary a `-healthcheck` flag that calls its own endpoint. -- Schema is applied on app start. +- Pending migrations are applied on app start. +- The Datastar client is vendored at `cmd/foodster/static/datastar.js` and + served from the app's own origin — the SDK ships no browser asset, and a + CDN link would break an offline LAN. `make vendor` refreshes it; the pinned + version lives in the `Makefile` and in the file's first line. - No internet exposure; the server binds to the LAN. ## 11. Licensing diff --git a/README.md b/README.md index 67e4f2e..22877d3 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,57 @@ make fix gofmt, templ fmt, go mod tidy make lint go vet, gofmt check, golangci-lint when installed make test go test ./... make build ./foodster +make seed import a dish bundle (SEED=seeds/testi.json) +make vendor re-download the Datastar client make image build and tag vYYYYMMDD-N (creates a git tag) make push push the newest tag and :latest make release image + push make up/down/logs compose ``` +## Importing dishes + +The **Ruoat** 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. + +```json +{ + "mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}], + "sides": [{"name": "Riisi"}] +} +``` + +Categories are `meat`, `chicken`, `fish` and `vegetarian` — stored in English +even though the UI is Finnish. A dish may list several, which is how +build-your-own meals like tortillas cover every category at once. `has_sides` +defaults to `true` when omitted. + +Import is best-effort, never atomic. Valid rows land; invalid ones are skipped +and named (`tuntematon kategoria`, `ei kategorioita`, `jo listalla`). + +Names are normalized to sentence case on the way in — whitespace collapses and +the first letter is capitalized, the rest is left as typed, because Finnish +capitalizes only the first word of a phrase. `keitetyt perunat` is stored as +`Keitetyt perunat`, while `BBQ-kylkeä` and `Kotipizza` keep their capitals. +Duplicates are caught case-insensitively, so re-importing a bundle is safe. + +The same importer runs from the command line when you just want to repopulate +a scratch database: + +```sh +make seed # or: SEED=seeds/other.json make seed +``` + +## Migrations + +Numbered SQL files in `cmd/foodster/migrations`, embedded in the binary and +applied in filename order at startup. Each runs in a transaction and is +recorded in `schema_migrations`, so it applies exactly once. + +Adding one means dropping a new `NNNN_what_it_does.sql` into that directory. +Never edit a migration that has already shipped — a released file has run on +a live database and will not run again. + ## Configuration Everything is environment variables. `.env` is gitignored; start from diff --git a/cmd/foodster/bundle.go b/cmd/foodster/bundle.go new file mode 100644 index 0000000..0e9888d --- /dev/null +++ b/cmd/foodster/bundle.go @@ -0,0 +1,182 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +// A Bundle is a set of dishes in the mass-import shape from PRD §7.3. The +// same format backs the admin paste/upload and the -import flag, so a test +// fixture and a real import travel identically. +// +// { +// "mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}], +// "sides": [{"name": "Riisi"}] +// } +type Bundle struct { + Mains []BundleMain `json:"mains"` + Sides []BundleSide `json:"sides"` +} + +type BundleMain struct { + Name string `json:"name"` + Categories []string `json:"categories"` + // HasSides is a pointer so an omitted field is distinguishable from an + // explicit false, and defaults to true like the column does. + HasSides *bool `json:"has_sides"` +} + +type BundleSide struct { + Name string `json:"name"` +} + +// validCategories mirrors the CHECK constraint in 0001_init.sql. Stored values +// are English even though the UI is Finnish. +var validCategories = map[string]bool{ + "meat": true, "chicken": true, "fish": true, "vegetarian": true, +} + +// ImportReport is the per-row summary PRD §7.3 asks for. Import is +// best-effort, not atomic: good rows land, bad rows are skipped and named. +type ImportReport struct { + Added int + Skipped int + Notes []string +} + +func (r *ImportReport) skip(format string, args ...any) { + r.Skipped++ + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +func (r ImportReport) String() string { + var b strings.Builder + fmt.Fprintf(&b, "lisätty %d, ohitettu %d", r.Added, r.Skipped) + for _, n := range r.Notes { + fmt.Fprintf(&b, "\n - %s", n) + } + return b.String() +} + +// importBundle reads a JSON bundle and inserts what it can. Each dish gets its +// own transaction so one bad row cannot discard the others. +func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) { + var bundle Bundle + dec := json.NewDecoder(r) + dec.DisallowUnknownFields() + if err := dec.Decode(&bundle); err != nil { + // Unwrapped: callers add their own prefix, and the UI one is Finnish. + return nil, err + } + + report := &ImportReport{} + + for _, m := range bundle.Mains { + name := normalizeName(m.Name) + if name == "" { + report.skip("pääruoka ilman nimeä") + continue + } + if len(m.Categories) == 0 { + report.skip("%s: ei kategorioita", name) + continue + } + bad := "" + for _, c := range m.Categories { + if !validCategories[c] { + bad = c + break + } + } + if bad != "" { + report.skip("%s: tuntematon kategoria %q", name, bad) + continue + } + + hasSides := true + if m.HasSides != nil { + hasSides = *m.HasSides + } + + if err := insertMain(db, name, m.Categories, hasSides); err != nil { + report.skip("%s: %s", name, reason(err)) + continue + } + report.Added++ + } + + for _, s := range bundle.Sides { + name := normalizeName(s.Name) + if name == "" { + report.skip("lisuke ilman nimeä") + continue + } + if _, err := db.Exec(`INSERT INTO side_dishes (name) VALUES (?)`, name); err != nil { + report.skip("%s: %s", name, reason(err)) + continue + } + report.Added++ + } + + return report, nil +} + +// reason turns a driver error into something worth showing a person. The +// only failure a normal import hits is a name already in the catalog. +// +// ponytail: string match rather than unwrapping a driver-specific error type, +// so this keeps working if the driver is ever swapped. +func reason(err error) string { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return "jo listalla" + } + return err.Error() +} + +func insertMain(db *sql.DB, name string, categories []string, hasSides bool) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + res, err := tx.Exec( + `INSERT INTO main_dishes (name, has_sides) VALUES (?, ?)`, name, hasSides) + if err != nil { + return err + } + id, err := res.LastInsertId() + if err != nil { + return err + } + for _, c := range categories { + if _, err := tx.Exec( + `INSERT OR IGNORE INTO main_dish_categories (main_dish_id, category) VALUES (?, ?)`, + id, c, + ); err != nil { + return err + } + } + return tx.Commit() +} + +// runImport loads a bundle file, prints the report, and is what `-import` +// calls. Handy for reseeding a scratch database between test runs. +func runImport(db *sql.DB, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + report, err := importBundle(db, f) + if err != nil { + return err + } + fmt.Println(report) + return nil +} diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go new file mode 100644 index 0000000..c21799b --- /dev/null +++ b/cmd/foodster/handlers.go @@ -0,0 +1,91 @@ +package main + +import ( + "database/sql" + "io" + "log" + "net/http" + "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 + +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) + } +} + +func (a *app) index(w http.ResponseWriter, r *http.Request) { + render(w, r, indexPage(today(a.loc))) +} + +func (a *app) history(w http.ResponseWriter, r *http.Request) { + render(w, r, historyPage()) +} + +func (a *app) catalog(w http.ResponseWriter, r *http.Request) { + a.renderCatalog(w, r, nil) +} + +func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, report *ImportReport) { + var mains, sides int + err := a.db.QueryRow(` + SELECT (SELECT count(*) FROM main_dishes WHERE deleted_at IS NULL), + (SELECT count(*) FROM side_dishes WHERE deleted_at IS NULL)`, + ).Scan(&mains, &sides) + if err != nil { + log.Printf("catalog counts: %v", err) + } + render(w, r, catalogPage(mains, sides, report)) +} + +// 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, 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, failed("Ei tuotavaa: liitä JSON tai valitse tiedosto.")) + return + } + + report, err := importBundle(a.db, src) + if err != nil { + a.renderCatalog(w, r, failed("JSON ei kelpaa: "+err.Error())) + return + } + a.renderCatalog(w, r, 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}} +} diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go new file mode 100644 index 0000000..7735b67 --- /dev/null +++ b/cmd/foodster/main.go @@ -0,0 +1,194 @@ +// Command foodster is a household dinner log and meal suggester. +// +// Stage 1 (the eating history) is what exists today; the suggester in PRD §8 +// arrives once there is history to weight against. +package main + +import ( + "cmp" + "context" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "embed" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + "unicode" + + _ "modernc.org/sqlite" + _ "time/tzdata" // the runtime image is FROM scratch and carries no zoneinfo +) + +//go:embed static +var staticFS embed.FS + +// version is replaced at build time with the CalVer tag (see `make image`). +var version = "dev" + +const ( + listenAddr = ":8080" + defaultTZ = "Europe/Helsinki" + defaultDB = "./foodster.db" + + // failDelay throttles password guessing. + // ponytail: a fixed sleep is enough for a LAN-only app; swap in + // golang.org/x/time/rate keyed by IP if this is ever exposed. + failDelay = 500 * time.Millisecond +) + +func main() { + if err := run(); err != nil { + log.Fatalf("foodster: %v", err) + } +} + +func run() error { + importPath := flag.String("import", "", + "import a JSON dish bundle (PRD §7.3 shape) and exit") + flag.Parse() + + db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB)) + if err != nil { + return err + } + defer db.Close() + + // Importing is an offline chore: no password needed, no server started. + if *importPath != "" { + return runImport(db, *importPath) + } + + password := os.Getenv("FOODSTER_PASSWORD") + if password == "" { + return errors.New("FOODSTER_PASSWORD is not set") + } + + // Fail rather than fall back to UTC: a silently wrong zone shifts logged + // dinners onto the wrong calendar day, which is invisible until the + // history is already corrupt. + loc, err := time.LoadLocation(cmp.Or(os.Getenv("TZ"), defaultTZ)) + if err != nil { + return fmt.Errorf("TZ: %w", err) + } + + // The container always publishes :8080; FOODSTER_ADDR exists so tests and + // a second local instance can pick another port. + addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr) + + srv := &http.Server{ + Addr: addr, + Handler: routes(db, loc, password), + ReadHeaderTimeout: 10 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdown) + }() + + log.Printf("foodster %s listening on %s (%s)", version, addr, loc) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +// openDB opens the SQLite file and brings its schema up to date. +func openDB(path string) (*sql.DB, error) { + dsn := "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(ON)" + + "&_pragma=busy_timeout(5000)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + // ponytail: one household writing one row a day; a single connection + // sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend. + db.SetMaxOpenConns(1) + + if err := migrate(db); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func routes(db *sql.DB, loc *time.Location, password string) http.Handler { + a := &app{db: db, loc: loc} + + mux := http.NewServeMux() + mux.Handle("GET /static/", http.FileServerFS(staticFS)) + mux.HandleFunc("GET /{$}", a.index) + mux.HandleFunc("GET /historia", a.history) + mux.HandleFunc("GET /ruoat", a.catalog) + mux.HandleFunc("POST /ruoat/tuonti", a.importDishes) + + // /healthz stays outside auth so a monitor or reverse proxy can reach it. + root := http.NewServeMux() + root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, version) + }) + root.Handle("/", auth(password, mux)) + return root +} + +// auth gates everything behind one shared household password. There are no +// accounts, so the username is ignored (PRD §9). +func auth(password string, next http.Handler) http.Handler { + want := sha256.Sum256([]byte(password)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, given, ok := r.BasicAuth() + // Hashing first keeps the comparison a fixed length, so neither the + // password nor its length leaks through timing. + got := sha256.Sum256([]byte(given)) + if !ok || subtle.ConstantTimeCompare(got[:], want[:]) != 1 { + time.Sleep(failDelay) + w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// today is the current calendar day in the configured location. Every date in +// this app goes through here rather than time.Local, which would be UTC +// whenever TZ is unset and quietly shift evening entries to the day before. +func today(loc *time.Location) time.Time { + return time.Now().In(loc) +} + +// normalizeName collapses whitespace and capitalises the first letter for +// storage (PRD §6): " keitetyt perunat " becomes "Keitetyt perunat". +// +// Sentence case, not title case: Finnish capitalises only the first word of a +// phrase, so "Keitetyt Perunat" would read as an anglicism. +// +// ponytail: everything after the first rune is left exactly as typed. Forcing +// the remainder lower would mangle "BBQ-kylkeä" and "Kotipizza", and the +// household can type what it means. +func normalizeName(s string) string { + name := strings.Join(strings.Fields(s), " ") + if name == "" { + return "" + } + r := []rune(name) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go new file mode 100644 index 0000000..a335ff6 --- /dev/null +++ b/cmd/foodster/main_test.go @@ -0,0 +1,268 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func TestNormalizeName(t *testing.T) { + cases := []struct{ in, want string }{ + {"kanacurry", "Kanacurry"}, + {" jauhelihakastike ", "Jauhelihakastike"}, + // Sentence case: only the first word is capitalised (Finnish, PRD §6). + {"lohikeitto ja ruisleipä", "Lohikeitto ja ruisleipä"}, + {"keitetyt perunat", "Keitetyt perunat"}, + {"äyriäispata", "Äyriäispata"}, // Finnish diacritics must upper-case + {"öljyssä paistettu", "Öljyssä paistettu"}, + {"BBQ-kylkeä", "BBQ-kylkeä"}, // an existing capital survives + {"Keitetyt Perunat", "Keitetyt Perunat"}, // deliberate capitals are kept + {"kana\t\ncurry", "Kana curry"}, // any whitespace collapses + {"", ""}, + } + for _, c := range cases { + if got := normalizeName(c.in); got != c.want { + t.Errorf("normalizeName(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestMigrateCreatesSchema(t *testing.T) { + db, err := openDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("openDB: %v", err) + } + defer db.Close() + + for _, table := range []string{ + "main_dishes", "main_dish_categories", "side_dishes", + "meal_log", "meal_log_sides", "schema_migrations", + } { + var n int + if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil { + t.Errorf("table %s: %v", table, err) + } + } + + var applied int + if err := db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&applied); err != nil { + t.Fatalf("count migrations: %v", err) + } + if applied == 0 { + t.Fatal("no migrations recorded") + } + + // A second run must be a no-op: migrate() runs on every start. + if err := migrate(db); err != nil { + t.Fatalf("second migrate: %v", err) + } + var again int + if err := db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&again); err != nil { + t.Fatalf("recount: %v", err) + } + if again != applied { + t.Errorf("migrations reapplied: %d then %d", applied, again) + } +} + +func TestImportBundle(t *testing.T) { + db, err := openDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("openDB: %v", err) + } + defer db.Close() + + const bundle = `{ + "mains": [ + {"name": "kanacurry", "categories": ["chicken"], "has_sides": true}, + {"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false}, + {"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"]}, + {"name": "Kanacurry", "categories": ["chicken"]}, + {"name": "Rikkinäinen", "categories": ["kana"]}, + {"name": "Kategoriaton", "categories": []}, + {"name": " "} + ], + "sides": [{"name": "riisi"}, {"name": "Riisi"}] + }` + + report, err := importBundle(db, strings.NewReader(bundle)) + if err != nil { + t.Fatalf("importBundle: %v", err) + } + + // 3 mains + 1 side land; the duplicate main, bad category, empty category + // list, blank name and duplicate side are all skipped but reported. + if report.Added != 4 { + t.Errorf("added = %d, want 4 (%s)", report.Added, report) + } + if report.Skipped != 5 { + t.Errorf("skipped = %d, want 5 (%s)", report.Skipped, report) + } + if len(report.Notes) != report.Skipped { + t.Errorf("got %d notes for %d skips", len(report.Notes), report.Skipped) + } + + // Names are normalised on the way in. + var name string + if err := db.QueryRow( + `SELECT name FROM main_dishes WHERE lower(name) = 'kanacurry'`).Scan(&name); err != nil { + t.Fatalf("lookup: %v", err) + } + if name != "Kanacurry" { + t.Errorf("stored name = %q, want %q", name, "Kanacurry") + } + + // has_sides defaults to true when the field is omitted. + var hasSides bool + if err := db.QueryRow( + `SELECT has_sides FROM main_dishes WHERE name = 'Tortillat'`).Scan(&hasSides); err != nil { + t.Fatalf("lookup Tortillat: %v", err) + } + if !hasSides { + t.Error("omitted has_sides = false, want true") + } + + // A multi-category main keeps every category (PRD §6). + var cats int + if err := db.QueryRow(`SELECT count(*) FROM main_dish_categories c + JOIN main_dishes m ON m.id = c.main_dish_id + WHERE m.name = 'Tortillat'`).Scan(&cats); err != nil { + t.Fatalf("count categories: %v", err) + } + if cats != 4 { + t.Errorf("Tortillat has %d categories, want 4", cats) + } +} + +func TestImportSeedFile(t *testing.T) { + db, err := openDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("openDB: %v", err) + } + defer db.Close() + + // The committed seed bundle has to stay importable — it is the fixture + // used to reset a scratch database while testing. + f, err := os.Open("../../seeds/testi.json") + if err != nil { + t.Fatalf("open seed: %v", err) + } + defer f.Close() + + report, err := importBundle(db, f) + if err != nil { + t.Fatalf("importBundle: %v", err) + } + if report.Skipped != 0 { + t.Errorf("seed bundle has bad rows: %s", report) + } + if report.Added == 0 { + t.Error("seed bundle imported nothing") + } +} + +func TestMealLogOneEntryPerDate(t *testing.T) { + db, err := openDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("openDB: %v", err) + } + defer db.Close() + + if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil { + t.Fatalf("first entry: %v", err) + } + // PRD §6: a second dinner for the same day must be refused. + if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil { + t.Error("second entry for the same date was accepted, want a unique violation") + } +} + +func TestDuplicateNamesAreCaseInsensitive(t *testing.T) { + db, err := openDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("openDB: %v", err) + } + defer db.Close() + + if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil { + t.Fatalf("first insert: %v", err) + } + if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil { + t.Error("case-variant duplicate was accepted, want a unique violation") + } + + // Soft-deleting the original frees the name again (PRD §7.3). + if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil { + t.Fatalf("soft delete: %v", err) + } + if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil { + t.Errorf("name still blocked after soft delete: %v", err) + } +} + +func TestAuth(t *testing.T) { + handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) // proves we reached the wrapped handler + })) + + cases := []struct { + name string + user string + pass string + withAuth bool + want int + }{ + {"correct password", "", "hunter2", true, http.StatusTeapot}, + {"username is ignored", "anyone", "hunter2", true, http.StatusTeapot}, + {"wrong password", "", "wrong", true, http.StatusUnauthorized}, + {"empty password", "", "", true, http.StatusUnauthorized}, + {"no credentials", "", "", false, http.StatusUnauthorized}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + if c.withAuth { + r.SetBasicAuth(c.user, c.pass) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != c.want { + t.Errorf("status = %d, want %d", w.Code, c.want) + } + if c.want == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" { + t.Error("401 without a WWW-Authenticate header; the browser will not prompt") + } + }) + } +} + +func TestHealthzSkipsAuth(t *testing.T) { + db, err := openDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("openDB: %v", err) + } + defer db.Close() + + h := routes(db, time.UTC, "hunter2") + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if w.Code != http.StatusOK { + t.Errorf("/healthz without credentials = %d, want 200", w.Code) + } + + // Everything else must still be gated. + w = httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if w.Code != http.StatusUnauthorized { + t.Errorf("/ without credentials = %d, want 401", w.Code) + } +} diff --git a/cmd/foodster/migrate.go b/cmd/foodster/migrate.go new file mode 100644 index 0000000..3245912 --- /dev/null +++ b/cmd/foodster/migrate.go @@ -0,0 +1,74 @@ +package main + +import ( + "database/sql" + "embed" + "fmt" + "io/fs" + "log" + "path" + "sort" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// migrate applies every embedded migration that has not run yet, in filename +// order, and records it. Migrations are named NNNN_description.sql; the +// numeric prefix is the ordering, so never renumber one that has shipped. +// +// SQLite runs DDL inside transactions, so a migration either lands whole or +// not at all. There is no down-migration: for a single-household app, +// restoring the database file is the rollback. +func migrate(db *sql.DB) error { + const create = `CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )` + if _, err := db.Exec(create); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + files, err := fs.Glob(migrationsFS, "migrations/*.sql") + if err != nil { + return err + } + sort.Strings(files) + + for _, file := range files { + name := path.Base(file) + + var applied int + if err := db.QueryRow( + `SELECT count(*) FROM schema_migrations WHERE name = ?`, name, + ).Scan(&applied); err != nil { + return fmt.Errorf("check %s: %w", name, err) + } + if applied > 0 { + continue + } + + body, err := migrationsFS.ReadFile(file) + if err != nil { + return err + } + + tx, err := db.Begin() + if err != nil { + return err + } + if _, err := tx.Exec(string(body)); err != nil { + tx.Rollback() + return fmt.Errorf("migration %s: %w", name, err) + } + if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, name); err != nil { + tx.Rollback() + return fmt.Errorf("record %s: %w", name, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit %s: %w", name, err) + } + log.Printf("migration applied: %s", name) + } + return nil +} diff --git a/cmd/foodster/migrations/0001_init.sql b/cmd/foodster/migrations/0001_init.sql new file mode 100644 index 0000000..fa6d526 --- /dev/null +++ b/cmd/foodster/migrations/0001_init.sql @@ -0,0 +1,61 @@ +-- Initial Foodster schema. Runs once and is then recorded in +-- schema_migrations; never edit this file after it has shipped, add a new +-- numbered migration instead. +-- +-- Stage 1 only (PRD §6): mains, sides and the meal log. The stage 2 +-- suggestion cache is not here yet. +-- +-- Dates are TEXT in 'YYYY-MM-DD' form. SQLite has no DATE type, and the +-- domain is pure calendar days with no time or timezone component. + +CREATE TABLE IF NOT EXISTS main_dishes ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + has_sides INTEGER NOT NULL DEFAULT 1 CHECK (has_sides IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT +); + +-- Duplicate detection is case-insensitive and only applies to live rows, so +-- a soft-deleted "Kanacurry" does not block adding it back (PRD §7.3). +CREATE UNIQUE INDEX IF NOT EXISTS main_dishes_name_live + ON main_dishes (lower(name)) WHERE deleted_at IS NULL; + +-- A main belongs to one or more categories. Multi-category mains (tortillas, +-- build-your-own pizza) cover every listed category at once (PRD §6). +-- Non-emptiness is enforced in application code; SQLite cannot express it. +CREATE TABLE IF NOT EXISTS main_dish_categories ( + main_dish_id INTEGER NOT NULL REFERENCES main_dishes (id) ON DELETE CASCADE, + category TEXT NOT NULL + CHECK (category IN ('meat', 'chicken', 'fish', 'vegetarian')), + PRIMARY KEY (main_dish_id, category) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS side_dishes ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS side_dishes_name_live + ON side_dishes (lower(name)) WHERE deleted_at IS NULL; + +-- One entry per calendar day: editing a day replaces it, and no second +-- dinner can be logged for the same date (PRD §6). +CREATE TABLE IF NOT EXISTS meal_log ( + id INTEGER PRIMARY KEY, + date TEXT NOT NULL UNIQUE, + main_dish_id INTEGER NOT NULL REFERENCES main_dishes (id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS meal_log_by_date ON meal_log (date DESC); + +-- Foreign keys survive soft deletes, so historical entries still resolve +-- their dish names after the dish is removed from the pickers. +CREATE TABLE IF NOT EXISTS meal_log_sides ( + meal_log_id INTEGER NOT NULL REFERENCES meal_log (id) ON DELETE CASCADE, + side_dish_id INTEGER NOT NULL REFERENCES side_dishes (id), + PRIMARY KEY (meal_log_id, side_dish_id) +) WITHOUT ROWID; diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css new file mode 100644 index 0000000..3941a09 --- /dev/null +++ b/cmd/foodster/static/app.css @@ -0,0 +1,136 @@ +/* Foodster. Hand-written, no framework, no build step. + Themes come from color-scheme + light-dark(), which also gets native form + controls, scrollbars and focus rings themed for free. */ + +:root { + color-scheme: light dark; + + --paper: light-dark(#ECEDE8, #121316); + --card: light-dark(#FFFFFF, #1C1E22); + --sunk: light-dark(#E3E4DE, #17181B); + --ink: light-dark(#14161A, #E9EAE5); + --muted: light-dark(#6B6F6A, #8B8F89); + --line: light-dark(#D5D6D0, #2C2F34); + --accent: light-dark(#15616D, #58C6D2); + --onacc: light-dark(#FFFFFF, #0C1417); + + /* Category colours, keyed by the Finnish names used in the UI. */ + --liha: light-dark(#AF4230, #DE7561); + --kana: light-dark(#B57E10, #DFA83B); + --kala: light-dark(#25688F, #63AAD8); + --kasvis: light-dark(#457A3C, #82BE7A); + + --tap: 48px; /* minimum touch target */ +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: system-ui, sans-serif; + font-size: 16px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +button, input, select { font: inherit; } +:focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + +.appbar { + position: sticky; + top: 0; + z-index: 5; + background: var(--paper); + border-bottom: 1px solid var(--line); + padding: 14px 16px 12px; +} +.appbar h2 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; } +.appbar .meta { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); } + +.pad { padding: 16px 16px calc(80px + env(safe-area-inset-bottom)); } +.muted { color: var(--muted); } + +/* Bottom bar: primary navigation belongs in the thumb zone on a phone. */ +.tabbar { + position: fixed; + inset: auto 0 0 0; + display: flex; + background: var(--card); + border-top: 1px solid var(--line); + padding-bottom: env(safe-area-inset-bottom); +} +.tabbar a { + flex: 1; + min-height: var(--tap); + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + color: var(--muted); + font-size: 13px; + font-weight: 600; +} +.tabbar a[aria-current] { color: var(--accent); } + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 18px; + margin-bottom: 16px; +} +.card h3 { margin: 0 0 4px; font-size: 18px; letter-spacing: -0.025em; } +.small { font-size: 13.5px; } + +.field { display: block; margin: 0 0 14px; } +.field > span { + display: block; + margin-bottom: 6px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} +.field textarea, +.field input[type="file"] { + width: 100%; + background: var(--sunk); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px; + font-size: 16px; /* below 16px iOS zooms on focus */ +} +.field textarea { + font-family: ui-monospace, monospace; + font-size: 13px; + line-height: 1.4; + resize: vertical; +} + +.primary { + width: 100%; + min-height: var(--tap); + background: var(--accent); + color: var(--onacc); + border: 0; + border-radius: 11px; + font-size: 16.5px; + font-weight: 700; + cursor: pointer; +} + +.report { border-left: 4px solid var(--accent); } +.report.bad { border-left-color: var(--liha); } +.report .tally { margin: 0; font-weight: 700; } +.report .notes { + margin: 10px 0 0; + padding-left: 18px; + font-size: 13.5px; + color: var(--muted); +} +.report .notes li { margin-bottom: 3px; } diff --git a/cmd/foodster/static/datastar.js b/cmd/foodster/static/datastar.js new file mode 100644 index 0000000..4e24fdc --- /dev/null +++ b/cmd/foodster/static/datastar.js @@ -0,0 +1,10 @@ +// Datastar v1.0.3 +var W="datastar-fetch",be="datastar-prop-change",At="datastar-ready",et="datastar-scope-children",re="datastar-signal-patch",p=document,J=HTMLInputElement,R=MutationObserver;var wt="data-nonce",Mt=p.documentElement,tt=Mt.getAttribute(wt),nt=tt!==null,ve;if(nt){if(!tt)throw new Error("Datastar CSP requires a nonempty html data-nonce.");Mt.removeAttribute(wt),ve=window.trustedTypes?.createPolicy("datastar",{createHTML:e=>e,createScript:e=>e})}var Ee=(e,t)=>{nt&&(e.nonce=tt),e.text=ve?ve.createScript(t):t},rt=e=>ve?ve.createHTML(e):e,Rt=new Map,_e=(e,t)=>{if(!nt)return Function(...e,t);let n=`function(${e.join(",")}){${t} +}`,r=Rt.get(n);if(r)return r;let s=p.createElement("script");Ee(s,`document.currentScript.x=${n}`),p.head.appendChild(s),s.remove();let i=s.x;if(!i)throw new Error("CSP blocked Datastar expression compilation.");return Rt.set(n,i),i};var N=Object.hasOwn??Object.prototype.hasOwnProperty.call;var Y=e=>e!==null&&typeof e=="object"&&(Object.getPrototypeOf(e)===Object.prototype||Object.getPrototypeOf(e)===null),xt=e=>{for(let t in e)if(N(e,t))return!1;return!0},se=(e,t)=>{for(let n in e){let r=e[n];Y(r)||Array.isArray(r)?se(r,t):e[n]=t(r)}},He=e=>{let t={};for(let[n,r]of e){let s=n.split("."),i=s.pop(),o=s.reduce((a,c)=>a[c]??={},t);o[i]=r}return t};var ke=[],st=[],Ge=0,De=0,it=0,Se,K,Ie=0,O=()=>{Ge++},P=()=>{--Ge||(Nt(),X())},F=e=>{Se={t:K,u:Se},K=e},_=()=>{K=Se?.t,Se=Se?.u},Te=e=>yn.bind(0,{g:e,t:e,e:1}),ot=Symbol("computed"),je=e=>{let t=bn.bind(0,{e:17,h:e});return t[ot]=1,t},T=e=>{let t={T:e,e:2};K&&ct(t,K),F(t),O();try{t.T()}finally{P(),_()}return _t.bind(0,t)},Nt=()=>{for(;De"h"in e?Ot(e):Pt(e,e.t),Ot=e=>{F(e),Ht(e);try{let t=e.t;return t!==(e.t=e.h(t))}finally{_(),kt(e)}},Pt=(e,t)=>(e.e=1,e.g!==(e.g=t)),at=e=>{let t=e.e;if(!(t&64)){e.e=t|64;let n=e.r;n?at(n.c):st[it++]=e}},Ft=(e,t)=>{if(t&16||t&32&&Dt(e.s,e)){F(e),Ht(e),O();try{e.T()}finally{P(),_(),kt(e)}return}t&32&&(e.e=t&-33);let n=e.s;for(;n;){let r=n.p,s=r.e;s&64&&Ft(r,r.e=s&-65),n=n.i}},yn=(e,...t)=>{if(t.length){if(e.t!==(e.t=t[0])){e.e=17;let r=e.r;return r&&(vn(r),Ge||Nt()),!0}return!1}let n=e.t;if(e.e&16&&Pt(e,n)){let r=e.r;r&&qe(r)}return K&&ct(e,K),n},bn=e=>{let t=e.e;if(t&16||t&32&&Dt(e.s,e)){if(Ot(e)){let n=e.r;n&&qe(n)}}else t&32&&(e.e=t&-33);return K&&ct(e,K),e.t},_t=e=>{let t=e.s;for(;t;)t=$e(t,e);let n=e.r;n&&$e(n),e.e=0},ct=(e,t)=>{let n=t.l;if(n&&n.p===e)return;let r=n?n.i:t.s;if(r&&r.p===e){r.S=Ie,t.l=r;return}let s=e.A;if(s&&s.S===Ie&&s.c===t)return;let i=t.l=e.A={S:Ie,p:e,c:t,d:n,i:r,y:s};r&&(r.d=i),n?n.i=i:t.s=i,s?s.n=i:e.r=i},$e=(e,t=e.c)=>{let n=e.p,r=e.d,s=e.i,i=e.n,o=e.y;if(s?s.d=r:t.l=r,r?r.i=s:t.s=s,i?i.y=o:n.A=o,o)o.n=i;else if(!(n.r=i))if("h"in n){let a=n.s;if(a){n.e=17;do a=$e(a,n);while(a)}}else"g"in n||_t(n);return s},vn=e=>{let t=e.n,n;e:for(;;){let r=e.c,s=r.e;if(s&60?s&12?s&4?!(s&48)&&En(e,r)?(r.e=s|40,s&=1):s=0:r.e=s&-9|32:s=0:r.e=s|32,s&2&&at(r),s&1){let i=r.r;if(i){let o=(e=i).n;o&&(n={t,u:n},t=o);continue}}if(e=t){t=e.n;continue}for(;n;)if(e=n.t,n=n.u,e){t=e.n;continue e}break}},Ht=e=>{Ie++,e.l=void 0,e.e=e.e&-57|4},kt=e=>{let t=e.l,n=t?t.i:e.s;for(;n;)n=$e(n,e);e.e&=-5},Dt=(e,t)=>{let n,r=0,s=!1;e:for(;;){let i=e.p,o=i.e;if(t.e&16)s=!0;else if((o&17)===17){if(Lt(i)){let a=i.r;a.n&&qe(a),s=!0}}else if((o&33)===33){(e.n||e.y)&&(n={t:e,u:n}),e=i.s,t=i,++r;continue}if(!s){let a=e.i;if(a){e=a;continue}}for(;r--;){let a=t.r,c=a.n;if(c?(e=n.t,n=n.u):e=a,s){if(Lt(t)){c&&qe(a),t=e.c;continue}s=!1}else t.e&=-33;if(t=e.c,e.i){e=e.i;continue e}}return s}},qe=e=>{do{let t=e.c,n=t.e;(n&48)===32&&(t.e=n|16,n&2&&at(t))}while(e=e.n)},En=(e,t)=>{let n=t.l;for(;n;){if(n===e)return!0;n=n.d}return!1},oe=e=>{let t=ie,n=e.split(".");for(let r of n){if(t==null||!N(t,r))return;t=t[r]}return t},Ve=(e,t="")=>{let n=Array.isArray(e);if(n||Y(e)){let r=n?[]:{};for(let i in e)r[i]=Te(Ve(e[i],`${t+i}.`));let s=Te(0);return new Proxy(r,{get(i,o){if(!(o==="toJSON"&&!N(r,o)))return n&&o in Array.prototype?(s(),r[o]):typeof o=="symbol"?r[o]:((!N(r,o)||r[o]()==null)&&(r[o]=Te(""),X(t+o,""),s(s()+1)),r[o]())},set(i,o,a){let c=t+o;if(n&&o==="length"){let l=r[o]-a;if(r[o]=a,l>0){let f={};for(let u=a;u{if(e!==void 0&&t!==void 0&&ke.push([e,t]),!Ge&&ke.length){let n=He(ke);ke.length=0,p.dispatchEvent(new CustomEvent(re,{detail:n}))}},I=(e,{ifMissing:t}={})=>{O();for(let n in e)e[n]==null?t||delete ie[n]:It(e[n],n,ie,"",t);P()},S=(e,t)=>I(He(e),t),It=(e,t,n,r,s)=>{if(Y(e)){N(n,t)&&(Y(n[t])||Array.isArray(n[t]))||(n[t]={});for(let i in e)e[i]==null?s||delete n[t][i]:It(e[i],i,n[t],`${r+t}.`,s)}else s&&N(n,t)||(n[t]=e)},Ct=e=>typeof e=="string"?RegExp(e.replace(/^\/|\/$/g,"")):e,G=({include:e=/.*/,exclude:t=/(?!)/}={},n=ie)=>{let r=Ct(e),s=Ct(t),i=[],o=[[n,""]];for(;o.length;){let[a,c]=o.pop();for(let l in a){let f=c+l;Y(a[l])?o.push([a[l],`${f}.`]):r.test(f)&&!s.test(f)&&i.push([f,oe(f)])}}return He(i)},ie=Ve({});var ee=e=>e instanceof HTMLElement||e instanceof SVGElement||e instanceof MathMLElement;var ue=e=>e.replace(/([A-Z]+)([A-Z][a-z])/g,"$1-$2").replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/([a-z])([0-9]+)/gi,"$1-$2").replace(/([0-9]+)([a-z])/gi,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase(),$t=e=>ue(e).replace(/-./g,t=>t[1].toUpperCase()),qt=e=>ue(e).replace(/-/g,"_");var Be=e=>typeof e=="string"&&e.trim()==="true",Tn=/^(?:(?:async\s+)?function\b|(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/,Vt=e=>_e([],`return (${e})`)(),pe=(e,t={})=>{let{reviveFunctionStrings:n=!1}=t;try{return n?JSON.parse(e,(r,s)=>{if(typeof s!="string")return s;let i=s.trim();if(!Tn.test(i))return s;try{let o=Vt(i);return typeof o=="function"?o:s}catch{return s}}):JSON.parse(e)}catch{return Vt(e)}},Gt={camel:e=>e.replace(/-[a-z]/g,t=>t[1].toUpperCase()),snake:e=>e.replace(/-/g,"_"),pascal:e=>e[0].toUpperCase()+Gt.camel(e.slice(1))},H=(e,t,n="camel")=>{for(let r of t.get("case")||[n])e=Gt[r]?.(e)||e;return e},z=e=>`data-${e}`,lt=e=>e;var Sn="https://data-star.dev/errors",Ae=(e,t,n={})=>{Object.assign(n,e);let r=new Error,s=qt(t),i=new URLSearchParams({metadata:JSON.stringify(n)}).toString(),o=JSON.stringify(n,null,2);return r.message=`${t} +More info: ${Sn}/${s}?${i} +Context: ${o}`,r},Re=new Map,ft=new Map,Wt=new Map,Jt=new Proxy({},{get:(e,t)=>Re.get(t)?.apply,has:(e,t)=>Re.has(t),ownKeys:()=>Reflect.ownKeys(Re),set:()=>!1,deleteProperty:()=>!1}),Me=new Map,Ue=[],ut=new Set,we=new Set,jt=!1,m=e=>{Ue.push(e),Ue.length===1&&setTimeout(()=>{for(let n of Ue)ut.add(n.name),ft.set(n.name,n);Ue.length=0;let t=we.size?[...we]:[p.documentElement];for(let n of t)Cn(n,!we.has(n));ut.clear()})},V=e=>{Re.set(e.name,e)};p.addEventListener(W,(e=>{let t=Wt.get(e.detail.type);t&&t.apply({error:Ae.bind(0,{plugin:{type:"watcher",name:t.name},element:{id:e.target.id,tag:e.target.tagName}})},e.detail.argsRaw)}));var xe=e=>{Wt.set(e.name,e)},Bt=e=>{for(let t of e){let n=Me.get(t);if(n&&Me.delete(t))for(let r of n.values())for(let s of r.values())s()}},Kt=z("ignore"),An=`[${Kt}]`,zt=e=>e.hasAttribute(`${Kt}__self`)||!!e.closest(An),We=(e,t)=>{for(let n of e)if(!zt(n)){let r=new Set;for(let s in n.dataset){let i=s.replace(/[A-Z]/g,"-$&").toLowerCase();r.add(i),pt(n,i,n.dataset[s],t)}for(let s of Array.from(n.attributes)){if(!s.name.startsWith("data-"))continue;let i=s.name.slice(5);r.has(i)||pt(n,i,s.value,t)}}},Rn=e=>{for(let{target:t,type:n,attributeName:r,addedNodes:s,removedNodes:i}of e)if(n==="childList"){for(let o of i)ee(o)&&(Bt([o]),Bt(o.querySelectorAll("*")));for(let o of s)ee(o)&&(We([o]),We(o.querySelectorAll("*")))}else if(n==="attributes"&&r.startsWith("data-")&&ee(t)&&!zt(t)){let o=r.slice(5),a=lt(o);if(!a)continue;let c=t.getAttribute(r);if(c===null){let l=Me.get(t);if(l){let f=l.get(a);if(f){for(let u of f.values())u();l.delete(a)}}}else pt(t,o,c)}},wn=new R(Rn),Mn=e=>{let[t,...n]=e.split("__"),[r,s]=t.split(/:(.+)/),i=new Map;for(let o of n){let[a,...c]=o.split(".");i.set(a,new Set(c))}return{pluginName:r,key:s,mods:i}},xn=()=>we.has(p.documentElement),Ln=()=>{jt||!xn()||(jt=!0,p.dispatchEvent(new Event(At)))},Cn=(e=p.documentElement,t=!0)=>{ee(e)&&We([e],!0),We(e.querySelectorAll("*"),!0),t&&(wn.observe(e,{subtree:!0,childList:!0,attributes:!0}),we.add(e),Ln())};var pt=(e,t,n,r)=>{let s=lt(t);if(!s)return;let{pluginName:i,key:o,mods:a}=Mn(s),c=ft.get(i);if((!r||ut.has(i))&&!!c){let f={el:e,rawKey:s,mods:a,error:Ae.bind(0,{plugin:{type:"attribute",name:c.name},element:{id:e.id,tag:e.tagName},expression:{rawKey:s,key:o,value:n}}),key:o,value:n,loadedPluginNames:{actions:new Set(Re.keys()),attributes:new Set(ft.keys())},rx:void 0},u=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.key)||"allowed",g=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.value)||"allowed",w=o!=null&&o!=="",y=n!=null&&n!=="";if(w){if(u==="denied")throw f.error("KeyNotAllowed")}else if(u==="must")throw f.error("KeyRequired");if(y){if(g==="denied")throw f.error("ValueNotAllowed")}else if(g==="must")throw f.error("ValueRequired");if(u==="exclusive"||g==="exclusive"){if(w&&y)throw f.error("KeyAndValueProvided");if(!w&&!y)throw f.error("KeyOrValueRequired")}let A=new Map;if(y){let M;f.rx=(...k)=>(M||(M=Nn(n,{R:c.returnsValue,w:c.argNames,M:A})),M(e,...k))}let d=c.apply(f);d&&A.set("attribute",d);let h=Me.get(e);if(h){let M=h.get(s);if(M)for(let k of M.values())k()}else h=new Map,Me.set(e,h);h.set(s,A)}},Ut=e=>e.split(".").reduce((t,n)=>`${t}['${n}']`,"$"),Nn=(e,{R:t=!1,w:n=[],M:r=new Map}={})=>{let s="";if(t){let i=/(?:\/(?:\\\/|[^/])*\/|"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|`(?:\\`|[^`])*`|\(\s*(?:(?:function)\s*\(\s*\)|(?:\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/gm,o=e.trim().match(i);if(o){let a=o.length-1,c=o[a].trim();c.startsWith("return")||(o[a]=`return (${c});`),s=o.join(`; +`)}}else s=e.trim();s=s.replace(/(?:"[^"\\]*(?:\\.[^"\\]*)*"|'[^'\\]*(?:\\.[^'\\]*)*'|`[^`\\$]*(?:(?:\\.|\$(?!\{))[^`\\$]*)*`)|\$\{([^{}]*)\}|\$(\w+(?:[.-]\w+)*)/g,(i,o,a)=>o===void 0&&a===void 0?i:o!==void 0?`\${${o.replace(/\$(\w+(?:[.-]\w+)*)/g,(c,l)=>Ut(l))}}`:Ut(a)),s=s.replaceAll(/@([A-Za-z_$][\w$]*)\(/g,'__action("$1",evt,');try{let i=_e(["el","$","__action","evt",...n],s);return(o,...a)=>{let c=(l,f,...u)=>{let g=Ae.bind(0,{plugin:{type:"action",name:l},element:{id:o.id,tag:o.tagName},expression:{fnContent:s,value:e}}),w=Jt[l];if(w)return w({el:o,evt:f,error:g,cleanups:r},...u);throw g("UndefinedAction")};try{return i(o,ie,c,void 0,...a)}catch(l){throw console.error(l),Ae({element:{id:o.id,tag:o.tagName},expression:{fnContent:s,value:e},error:l.message},"ExecuteExpression")}}}catch(i){throw console.error(i),Ae({expression:{fnContent:s,value:e},error:i.message},"GenerateExpression")}};V({name:"peek",apply(e,t){F();try{return t()}finally{_()}}});V({name:"setAll",apply(e,t,n){F();let r=G(n);se(r,()=>t),I(r),_()}});V({name:"toggleAll",apply(e,t){F();let n=G(t);se(n,r=>!r),I(n),_()}});var Qt=new Map,dt=e=>!["GET","DELETE"].includes(e),Le=(e,t,n=!0)=>V({name:e,apply:async({el:r,evt:s,error:i,cleanups:o},a,{selector:c,headers:l,contentType:f="json",filterSignals:{include:u=/.*/,exclude:g=/(^|\.)_/}={},openWhenHidden:w=n,payload:y,requestCancellation:A="auto",retry:d="auto",retryInterval:h=1e3,retryScaler:M=2,retryMaxWait:k=3e4,retryMaxCount:le=10}={})=>{let L=A instanceof AbortController?A:new AbortController,q=`@${e}`;if(A==="auto"||A==="cleanup"){let B=Qt.get(t)??new Map;B.get(a)?.abort(),B.set(a,L),Qt.set(t,B)}A==="cleanup"&&(o.get(q)?.(),o.set(q,async()=>{L.abort(),await Promise.resolve()}));let j=()=>{};try{if(!a?.length)throw i("FetchNoUrlProvided",{action:V});let B={Accept:"text/event-stream, text/html, application/json","Datastar-Request":!0};f==="json"&&dt(t)&&(B["Content-Type"]="application/json"),Object.assign(B,l);let Q={b:"",method:t,headers:B,L:w,v:d,C:h,N:M,O:k,P:le,signal:L.signal,F:async v=>{v.status>=400&&ae(On,r,{status:v.status.toString()})},_:v=>{if(!v.E.startsWith("datastar"))return;let Z=v.E,b={};for(let x of v.m.split(` +`)){let C=x.indexOf(" "),fe=x.slice(0,C),D=x.slice(C+1);(b[fe]||=[]).push(D)}let E=Object.fromEntries(Object.entries(b).map(([x,C])=>[x,C.join(` +`)]));ae(Z,r,E)},onerror:v=>{if(Zt(v))throw i("FetchExpectedTextEventStream",{url:a})}},Oe=()=>{let v=new URL(a,p.baseURI),Z=new URLSearchParams(v.search);if(f==="json"){F();let b=y!==void 0?y:G({include:u,exclude:g});_();let E=JSON.stringify(b);dt(t)?Q.body=E:Z.set("datastar",E)}else if(f==="form"){let b=c?p.querySelector(c):r.closest("form");if(!b)throw i("FetchFormNotFound",{action:V,selector:c});if(!b.noValidate&&!b.checkValidity()){b.reportValidity();return}let E=new FormData(b),x=r;if(r===b&&s instanceof SubmitEvent)x=s.submitter;else{let D=he=>he.preventDefault();b.addEventListener("submit",D),j=()=>{b.removeEventListener("submit",D)}}if(x instanceof HTMLButtonElement||x instanceof J&&x.type==="submit"){let D=x.getAttribute("name");D&&E.append(D,x.value)}let C=b.getAttribute("enctype")==="multipart/form-data";C||(B["Content-Type"]="application/x-www-form-urlencoded");let fe=new URLSearchParams(E);if(dt(t))Q.body=C?E:fe;else for(let[D,he]of fe)Z.append(D,he)}else throw i("FetchInvalidContentType",{action:V,contentType:f});return v.search=Z.toString(),Q.b=v.toString(),Q};ae(mt,r,{});try{await Dn(r,Oe)}catch(v){if(!Zt(v))throw i("FetchFailed",{method:t,url:a,error:v.message})}}finally{ae(gt,r,{}),j(),o.delete(q)}}});Le("get","GET",!1);Le("patch","PATCH");Le("post","POST");Le("put","PUT");Le("delete","DELETE");var mt="started",gt="finished",On="error",Pn="retrying",Fn="retries-failed",ae=(e,t,n)=>p.dispatchEvent(new CustomEvent(W,{detail:{type:e,el:t,argsRaw:n}})),Zt=e=>`${e}`.includes("text/event-stream"),_n=async(e,t)=>{let n=e.getReader(),r=await n.read();for(;!r.done;)t(r.value),r=await n.read()},Hn=e=>{let t,n,r,s=!1;return i=>{if(!t)t=i,n=0,r=-1;else{let c=new Uint8Array(t.length+i.length);c.set(t),c.set(i,t.length),t=c}let o=t.length,a=0;for(;n{let r=Yt(),s=new TextDecoder;return(i,o)=>{if(!i.length)n?.(r),r=Yt();else if(o>0){let a=s.decode(i.subarray(0,o)),c=o+(i[o+1]===32?2:1),l=s.decode(i.subarray(c));switch(a){case"data":r.m=r.m?`${r.m} +${l}`:l;break;case"event":r.E=l;break;case"id":e(r.H=l);break;case"retry":{let f=+l;Number.isNaN(f)||t(r.v=f);break}}}}},Yt=()=>({m:"",E:"",H:"",v:void 0}),Dn=(e,t)=>new Promise((n,r)=>{let s=t();if(!s)return;let{b:i,signal:o,headers:a,F:c,_:l,L:f,v:u,C:g,N:w,O:y,P:A,$:d,...h}=s,M={...a},k,le=()=>{let b=t();b&&(i=b.b,h.body=b.body,Z())},L=()=>{k.abort(),p.hidden||le()};f||p.addEventListener("visibilitychange",L);let q,j=()=>{p.removeEventListener("visibilitychange",L),clearTimeout(q),k.abort()};o.addEventListener("abort",()=>{j(),n()});let B=c,Q=0,Oe=g,v=()=>{Q{k=new AbortController;let b=k.signal;try{let E=await fetch(i,{...h,headers:M,signal:b});await B(E);let x=async(U,ye,Qe,Pe,...Ze)=>{let St={[Qe]:await ye.text()};for(let Ye of Ze){let Xe=ye.headers.get(`datastar-${ue(Ye)}`);if(Pe){let Fe=Pe[Ye];Fe&&(Xe=typeof Fe=="string"?Fe:JSON.stringify(Fe))}Xe&&(St[Ye]=Xe)}ae(U,e,St),j(),n()},C=E.status,fe=C===204,D=C>=300&&C<400,he=C>=400&&C<600;if(C!==200){if(u!=="never"&&!fe&&!D&&(u==="always"||u==="error"&&he)){v();return}j(),n();return}Q=0,g=Oe;let ze=E.headers.get("Content-Type");if(ze?.includes("text/html"))return await x("datastar-patch-elements",E,"elements",d,"selector","mode","namespace","useViewTransition");if(ze?.includes("application/json"))return await x("datastar-patch-signals",E,"signals",d,"onlyIfMissing");if(ze?.includes("text/javascript")){let U=p.createElement("script"),ye=E.headers.get("datastar-script-attributes");if(ye)for(let[Pe,Ze]of Object.entries(JSON.parse(ye)))U.setAttribute(Pe,Ze);let Qe=await E.text();Ee(U,Qe),p.head.appendChild(U),j();return}if(await _n(E.body,Hn(kn(U=>{U?M["last-event-id"]=U:delete M["last-event-id"]},U=>{Oe=g=U},l))),u==="always"&&!D){v();return}j(),n()}catch{if(!b.aborted)try{v()}catch(E){j(),r(E)}}};Z()});m({name:"attr",requirement:{value:"must"},returnsValue:!0,apply({el:e,key:t,rx:n}){let r=(a,c)=>{c===""||c===!0?e.setAttribute(a,""):c===!1||c==null?e.removeAttribute(a):typeof c=="string"?e.setAttribute(a,c):typeof c=="function"?e.setAttribute(a,c.toString()):e.setAttribute(a,JSON.stringify(c,(l,f)=>typeof f=="function"?f.toString():f))},s=t?()=>{i.disconnect();let a=n();r(t,a),i.observe(e,{attributeFilter:[t]})}:()=>{i.disconnect();let a=n(),c=Object.keys(a);for(let l of c)r(l,a[l]);i.observe(e,{attributeFilter:c})},i=new R(s),o=T(s);return()=>{i.disconnect(),o()}}});var Je=(e,...t)=>({a:n=>n[e],f:(n,r)=>{n[e]=r},o:t}),Xt=(e,...t)=>({a:n=>n.getAttribute(e),f:(n,r)=>{n.setAttribute(e,`${r}`)},o:t}),en=(e=!1,...t)=>({a:(n,r)=>r==="string"||e&&r==="undefined"?n.value:+n.value,f:(n,r)=>{n.value=`${r}`},o:t}),In=()=>{let e=new Set;return{a:(t,n)=>t.multiple?[...t.selectedOptions].map(r=>e.has(r.value)?+r.value:r.value):n==="string"||n==="undefined"?t.value:+t.value,f:(t,n)=>{if(!t.multiple){t.value=`${n}`;return}for(let r of t.options)n.includes(r.value)?(e.delete(r.value),r.selected=!0):n.includes(+r.value)?(e.add(r.value),r.selected=!0):r.selected=!1},o:["change"]}},Vn=/^data:(?[^;]+);base64,(?.*)$/,tn=Symbol("empty"),nn=(e,t,n,r,s,i)=>{let o=z(CSS.escape(n)),a=t?`[${o}]`:`[${o}="${CSS.escape(r)}"]`;if(i===void 0&&e instanceof J&&e.type==="radio"){let u=[...p.querySelectorAll(a)].find(g=>g instanceof J&&g.checked);u&&S([[r,u.value]],{ifMissing:!0})}if(!Array.isArray(i)||e instanceof HTMLSelectElement&&e.multiple)return S([[r,s.a(e,typeof i)]],{ifMissing:!0}),r;let c=p.querySelectorAll(a),l=[],f=0;for(let u of c){if(l.push([`${r}.${f}`,s.a(u,typeof(N(i,f)?i[f]:void 0))]),e===u)break;f++}return S(l,{ifMissing:!0}),`${r}.${f}`};m({name:"bind",requirement:"exclusive",apply({el:e,key:t,rawKey:n,mods:r,value:s,error:i}){let o=t!=null?H(t,r):s,a=r.get("prop"),c=r.get("event"),l=null;if(e instanceof J)switch(e.type){case"range":case"number":l=en(!1,"input");break;case"checkbox":l={a:(d,h)=>d.value!=="on"?h==="boolean"?d.checked:d.checked?d.value:"":h==="string"?d.checked?d.value:"":d.checked,f:(d,h)=>{d.checked=typeof h=="string"?h===d.value:h},o:["input"]};break;case"radio":e.getAttribute("name")?.length||e.setAttribute("name",o),l={a:(d,h)=>d.checked?h==="number"?+d.value:d.value:tn,f:(d,h)=>{d.checked=h===(typeof h=="number"?+d.value:d.value)},o:["input"]};break;case"file":{let d=()=>{let h=[...e.files||[]],M=[];Promise.all(h.map(k=>new Promise(le=>{let L=new FileReader;L.onload=()=>{if(typeof L.result!="string")throw i("InvalidFileResultType",{resultType:typeof L.result});let q=L.result.match(Vn);if(!q?.groups)throw i("InvalidDataUri",{result:L.result});M.push({name:k.name,contents:q.groups.contents,mime:q.groups.mime})},L.onloadend=()=>le(),L.readAsDataURL(k)}))).then(()=>{S([[o,M]])})};return e.addEventListener("change",d),()=>{e.removeEventListener("change",d)}}default:l=en(!0,"input")}else e instanceof HTMLSelectElement?l=In():e instanceof HTMLTextAreaElement?l=Je("value","input"):e instanceof HTMLElement&&e.tagName.includes("-")?l="value"in e?Je("value","input","change"):Xt("value","input","change"):e instanceof HTMLElement&&"value"in e?l=Je("value","change"):l=Xt("value","change");if(!l)throw i("InvalidBindAdapter");let f=a&&[...a][0];if(a&&!f)throw i("BindPropNameMissing");if(f){let d=$t(f);l=Je(d,...c?[...c]:l.o)}else c&&(l.o=[...c]);let u=nn(e,t,n,o,l,oe(o)),g=()=>{let d=oe(u);if(d!=null){let h=l.a(e,typeof d);h!==tn&&S([[u,h]])}},w=()=>{l.f(e,oe(u))};for(let d of l.o)e.addEventListener(d,g);e.addEventListener(be,g);let y=T(w),A=e instanceof HTMLSelectElement?new R(()=>{y(),u=nn(e,t,n,o,l,oe(o)),y=T(w)}):null;return A?.observe(e,{attributeFilter:["multiple"]}),()=>{A?.disconnect(),y();for(let d of l.o)e.removeEventListener(d,g);e.removeEventListener(be,g)}}});m({name:"class",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,mods:n,rx:r}){e&&=H(e,n,"kebab");let s,i=()=>{o.disconnect(),s=e?{[e]:r()}:r();for(let c in s){let l=c.split(/\s+/).filter(f=>f.length>0);if(s[c])for(let f of l)t.classList.contains(f)||t.classList.add(f);else for(let f of l)t.classList.contains(f)&&t.classList.remove(f)}o.observe(t,{attributeFilter:["class"]})},o=new R(i),a=T(i);return()=>{o.disconnect(),a();for(let c in s){let l=c.split(/\s+/).filter(f=>f.length>0);for(let f of l)t.classList.remove(f)}}}});m({name:"computed",requirement:{value:"must"},returnsValue:!0,apply({key:e,mods:t,rx:n,error:r}){if(e)S([[H(e,t),je(n)]]);else{let s=Object.assign({},n());se(s,i=>{if(typeof i=="function")return je(i);throw r("ComputedExpectedFunction")}),I(s)}}});m({name:"effect",requirement:{key:"denied",value:"must"},apply:({rx:e})=>T(e)});m({name:"indicator",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?H(t,n):r,i=0;S([[s,!1]]);let o=(a=>{let{type:c,el:l}=a.detail;if(l===e)switch(c){case mt:i++,S([[s,!0]]);break;case gt:i=Math.max(0,i-1),S([[s,i>0]]);break}});return p.addEventListener(W,o),()=>{i=0,S([[s,!1]]),p.removeEventListener(W,o)}}});var te=e=>{for(let t of e)return t.endsWith("ms")?+t.slice(0,-2):t.endsWith("s")?+t.slice(0,-1)*1e3:Number.parseFloat(t);return 0},ce=(e,t)=>e.has(t.toLowerCase()),rn=(e,t="")=>{if(e)for(let n of e)return n;return t};var ht=(e,t)=>(...n)=>{setTimeout(e,t,...n)},sn=(e,t,n=!0,r=!1,s=!1)=>{let i=null,o=0;return(...a)=>{n&&!o?(e(...a),i=null):i=a,(!o||s)&&(o&&clearTimeout(o),o=setTimeout(()=>{r&&i!==null&&e(...i),i=null,o=0},t))}},de=(e,t)=>{let n=t.get("delay");if(n){let i=te(n);e=ht(e,i)}let r=t.get("debounce");if(r){let i=te(r),o=ce(r,"leading"),a=!ce(r,"notrailing");e=sn(e,i,o,a,!0)}let s=t.get("throttle");if(s){let i=te(s),o=!ce(s,"noleading"),a=ce(s,"trailing");e=sn(e,i,o,a)}return e};var yt=e=>"startViewTransition"in e,ne=(e,t)=>{if(t.has("viewtransition")&&yt(p)){let n=e;e=(...r)=>p.startViewTransition(()=>n(...r))}return e};m({name:"init",requirement:{key:"denied",value:"must"},apply({rx:e,mods:t}){let n=()=>{O();try{e()}finally{P()}};n=ne(n,t);let r=0,s=t.get("delay");s&&(r=te(s),r>0&&(n=ht(n,r))),n()}});m({name:"json-signals",requirement:{key:"denied"},apply({el:e,value:t,mods:n}){let r=n.has("terse")?0:2,s={};t&&(s=pe(t));let i=()=>{o.disconnect(),e.textContent=JSON.stringify(G(s),null,r),o.observe(e,{childList:!0,characterData:!0,subtree:!0})},o=new R(i),a=T(i);return()=>{o.disconnect(),a()}}});m({name:"on",requirement:"must",argNames:["evt"],apply({el:e,key:t,mods:n,rx:r}){let s=e;n.has("window")?s=window:n.has("document")&&(s=p);let i=l=>{O();try{r(l)}finally{P()}};i=ne(i,n),i=de(i,n);let o=H(t,n,"kebab"),a={capture:n.has("capture"),passive:n.has("passive"),once:n.has("once")};if(n.has("outside")){s=p;let l=i;i=f=>{e.contains(f?.target)||l(f)}}(o===W||o===re)&&(s=p);let c=l=>{l&&(n.has("prevent")&&l.preventDefault(),n.has("stop")&&l.stopPropagation(),e instanceof HTMLFormElement&&o==="submit"&&l.preventDefault()),i(l)};return s.addEventListener(o,c,a),()=>{s.removeEventListener(o,c,a)}}});var on=(e,t,n)=>Math.max(t,Math.min(n,e));var bt=new WeakSet;m({name:"on-intersect",requirement:{key:"denied",value:"must"},apply({el:e,mods:t,rx:n}){let r=()=>{O();try{n()}finally{P()}};r=ne(r,t),r=de(r,t);let s={threshold:0};if(t.has("full"))s.threshold=1;else if(t.has("half"))s.threshold=.5;else{let a=t.get("threshold");a&&(s.threshold=on(Number(rn(a)),0,100)/100)}let i=t.has("exit"),o=new IntersectionObserver(a=>{for(let c of a)c.isIntersecting!==i&&(r(),o&&bt.has(e)&&o.disconnect())},s);return o.observe(e),t.has("once")&&bt.add(e),()=>{t.has("once")||bt.delete(e),o&&(o.disconnect(),o=null)}}});m({name:"on-interval",requirement:{key:"denied",value:"must"},apply({mods:e,rx:t}){let n=()=>{O();try{t()}finally{P()}};n=ne(n,e);let r=1e3,s=e.get("duration");s&&(r=te(s),ce(s,"leading")&&n());let i=setInterval(n,r);return()=>{clearInterval(i)}}});m({name:"on-signal-patch",requirement:{value:"must"},argNames:["patch"],returnsValue:!0,apply({el:e,key:t,mods:n,rx:r,error:s}){if(t&&t!=="filter")throw s("KeyNotAllowed");let i=z(`${this.name}-filter`),o=e.getAttribute(i),a={};o&&(a=pe(o));let c=!1,l=de(f=>{if(c)return;F();let u=G(a,f.detail);if(_(),!xt(u)){c=!0,O();try{r(u)}finally{P(),c=!1}}},n);return p.addEventListener(re,l),()=>{p.removeEventListener(re,l)}}});m({name:"ref",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?H(t,n):r;S([[s,e]])}});var an="none",cn="display";m({name:"show",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),t()?e.style.display===an&&e.style.removeProperty(cn):e.style.setProperty(cn,an),r.observe(e,{attributeFilter:["style"]})},r=new R(n),s=T(n);return()=>{r.disconnect(),s()}}});m({name:"signals",returnsValue:!0,apply({key:e,mods:t,rx:n}){let r=t.has("ifmissing");if(e){e=H(e,t);let s=n?.();S([[e,s]],{ifMissing:r})}else{let s=Object.assign({},n?.());I(s,{ifMissing:r})}}});m({name:"style",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,rx:n}){let{style:r}=t,s=new Map,i=(l,f)=>{let u=s.get(l);!f&&f!==0?u!==void 0&&(u?r.setProperty(l,u):r.removeProperty(l)):(u===void 0&&s.set(l,r.getPropertyValue(l)),r.setProperty(l,String(f)))},o=()=>{if(a.disconnect(),e)i(e,n());else{let l=n();for(let[f,u]of s)f in l||(u?r.setProperty(f,u):r.removeProperty(f));for(let f in l)i(ue(f),l[f])}a.observe(t,{attributeFilter:["style"]})},a=new R(o),c=T(o);return()=>{a.disconnect(),c();for(let[l,f]of s)f?r.setProperty(l,f):r.removeProperty(l)}}});m({name:"text",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),e.textContent=`${t()}`,r.observe(e,{childList:!0,characterData:!0,subtree:!0})},r=new R(n),s=T(n);return()=>{r.disconnect(),s()}}});var $n=["remove","outer","inner","replace","prepend","append","before","after"],qn=["html","svg","mathml"];xe({name:"datastar-patch-elements",apply(e,t){let n=typeof t.selector=="string"?t.selector:"",r=typeof t.mode=="string"?t.mode:"outer",s=typeof t.namespace=="string"?t.namespace:"html",i=Be(t.useViewTransition),o=typeof t.viewTransitionSelector=="string"?t.viewTransitionSelector:"",a=t.elements;if(!$n.includes(r))throw e.error("PatchElementsInvalidMode",{mode:r});if(!n&&r!=="outer"&&r!=="replace")throw e.error("PatchElementsExpectedSelector");if(!qn.includes(s))throw e.error("PatchElementsInvalidNamespace",{namespace:s});let c={k:n,D:r,I:s,V:a};if(i){let l=p;if(o){let f=p.querySelector(o);f&&(l=f)}yt(l)?l.startViewTransition(()=>vt(e,c)):vt(e,c)}else vt(e,c)}});var vt=({error:e},{k:t,D:n,I:r,V:s})=>{let i=p.createDocumentFragment(),o=typeof s!="string"&&!!s;if(typeof s=="string"){let a=s.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,""),c=/<\/html>/.test(a),l=/<\/head>/.test(a),f=/<\/body>/.test(a),u=r==="svg"?"svg":r==="mathml"?"math":"",g=u?`<${u}>${s}`:s,w=c||l||f?s:``,y=new DOMParser().parseFromString(rt(w),"text/html");if(c)i.appendChild(y.documentElement);else if(l&&f)i.appendChild(y.head),i.appendChild(y.body);else if(l)i.appendChild(y.head);else if(f)i.appendChild(y.body);else if(u){let A=y.querySelector("template").content.querySelector(u);for(let d of A.childNodes)i.appendChild(d)}else i=y.querySelector("template").content}else s&&(s instanceof DocumentFragment?i=s:s instanceof Element&&i.appendChild(s));if(!t&&(n==="outer"||n==="replace")){let a=Array.from(i.children);for(let c of a){let l;if(c instanceof HTMLHtmlElement)l=p.documentElement;else if(c instanceof HTMLBodyElement)l=p.body;else if(c instanceof HTMLHeadElement)l=p.head;else if(l=p.getElementById(c.id),!l){console.warn(e("PatchElementsNoTargetsFound"),{element:{id:c.id}});continue}fn(n,c,[l],!0)}}else{let a=p.querySelectorAll(t);if(!a.length){console.warn(e("PatchElementsNoTargetsFound"),{selector:t});return}let c=o&&n!=="remove"?[a[0]]:a;c.length===1&&(o=!0),fn(n,i,c,o)}},Tt=new WeakSet;for(let e of p.querySelectorAll("script"))Tt.add(e);var mn=e=>{let t=e instanceof HTMLScriptElement?[e]:e.querySelectorAll("script");for(let n of t)if(!Tt.has(n)){let r=p.createElement("script");for(let{name:s,value:i}of n.attributes)r.setAttribute(s,i);Ee(r,n.text),n.replaceWith(r),Tt.add(r)}},ln=(e,t,n,r)=>{let s=!1;for(let i of e){if(r&&s)break;let o=r?t:t.cloneNode(!0);mn(o),i[n](o),s=!0}},fn=(e,t,n,r)=>{switch(e){case"remove":for(let s of n)s.remove();break;case"outer":case"inner":{let s=!1;for(let i of n){if(r&&s)break;let o=r?t:t.cloneNode(!0);jn(i,o,e),mn(i);let a=i.closest("[data-scope-children]");a&&a.dispatchEvent(new CustomEvent(et,{bubbles:!1})),s=!0}}break;case"replace":ln(n,t,"replaceWith",r);break;case"prepend":case"append":case"before":case"after":ln(n,t,e,r)}},$=new Map,ge=new Set,me=new Map,Ce=new Set,Ke=p.createElement("div");Ke.hidden=!0;var Ne=z("ignore-morph"),Gn=`[${Ne}]`,jn=(e,t,n="outer")=>{if(ee(e)&&ee(t)&&e.hasAttribute(Ne)&&t.hasAttribute(Ne)||e.parentElement?.closest(Gn))return;let r=p.createElement("div");r.append(t),p.body.insertAdjacentElement("afterend",Ke);let s=e.querySelectorAll("[id]");for(let{id:a,tagName:c}of s)me.has(a)?Ce.add(a):me.set(a,c);e instanceof Element&&e.id&&(me.has(e.id)?Ce.add(e.id):me.set(e.id,e.tagName)),ge.clear();let i=r.querySelectorAll("[id]");for(let{id:a,tagName:c}of i)ge.has(a)?Ce.add(a):me.get(a)===c&&ge.add(a);for(let a of Ce)ge.delete(a);me.clear(),Ce.clear(),$.clear();let o=n==="outer"?e.parentElement:e;dn(o,s),dn(r,i),gn(o,r,n==="outer"?e:null,e.nextSibling),Ke.remove()},gn=(e,t,n=null,r=null)=>{e instanceof HTMLTemplateElement&&t instanceof HTMLTemplateElement&&(e=e.content,t=t.content),n??=e.firstChild;for(let s of t.childNodes){if(n&&n!==r){let i=Bn(s,n,r);if(i){if(i!==n){let o=n;for(;o&&o!==i;){let a=o;o=o.nextSibling,pn(a)}}Et(i,s),n=i.nextSibling;continue}}if(s instanceof Element&&ge.has(s.id)){let i=p.getElementById(s.id),o=i;for(;o=o.parentNode;){let a=$.get(o);a&&(a.delete(s.id),a.size||$.delete(o))}hn(e,i,n),Et(i,s),n=i.nextSibling;continue}if($.has(s)){let i=s.namespaceURI,o=s.tagName,a=i&&i!=="http://www.w3.org/1999/xhtml"?p.createElementNS(i,o):p.createElement(o);e.insertBefore(a,n),Et(a,s),n=a.nextSibling}else{let i=p.importNode(s,!0);e.insertBefore(i,n),n=i.nextSibling}}for(;n&&n!==r;){let s=n;n=n.nextSibling,pn(s)}},Bn=(e,t,n)=>{let r=null,s=e.nextSibling,i=0,o=0,a=$.get(e)?.size||0,c=t;for(;c&&c!==n;){if(un(c,e)){let l=!1,f=$.get(c),u=$.get(e);if(u&&f){for(let g of f)if(u.has(g)){l=!0;break}}if(l)return c;if(!r&&!$.has(c)){if(!a)return c;r=c}}if(o+=$.get(c)?.size||0,o>a)break;r===null&&s&&un(c,s)&&(i++,s=s.nextSibling,i>=2&&(r=void 0)),c=c.nextSibling}return r||null},un=(e,t)=>e.nodeType===t.nodeType&&e.tagName===t.tagName&&(!e.id||e.id===t.id),pn=e=>{$.has(e)?hn(Ke,e,null):e.parentNode?.removeChild(e)},hn=(e,t,n)=>{if("moveBefore"in e){e.moveBefore(t,n);return}e.insertBefore(t,n)},Un=z("preserve-attr"),Et=(e,t)=>{let n=t.nodeType;if(n===1){let r=e,s=t,i=r.hasAttribute("data-scope-children");if(r.hasAttribute(Ne)&&s.hasAttribute(Ne))return e;let o=(t.getAttribute(Un)??"").split(" "),a=(l,f,u)=>{let g=f.hasAttribute(u);return l.hasAttribute(u)!==g&&!o.includes(u)?(l[u]=g,!0):!1},c=!1;if(r instanceof J&&s instanceof J&&s.type!=="file"){let l=s.getAttribute("value");r.getAttribute("value")!==l&&!o.includes("value")&&(r.value=l??"",c=!0),c=a(r,s,"checked")||c,a(r,s,"disabled")}else if(r instanceof HTMLTextAreaElement&&s instanceof HTMLTextAreaElement){let l=s.value;r.defaultValue!==l&&(r.value=l,c=!0)}else r instanceof HTMLOptionElement&&s instanceof HTMLOptionElement&&(c=a(r,s,"selected")||c);for(let{name:l,value:f}of s.attributes)r.getAttribute(l)!==f&&!o.includes(l)&&r.setAttribute(l,f);for(let{name:l}of Array.from(r.attributes))!s.hasAttribute(l)&&!o.includes(l)&&r.removeAttribute(l);c&&(r instanceof HTMLOptionElement?r.closest("select"):r)?.dispatchEvent(new Event(be,{bubbles:!0})),i&&!r.hasAttribute("data-scope-children")&&r.setAttribute("data-scope-children",""),r instanceof HTMLTemplateElement&&s instanceof HTMLTemplateElement?r.innerHTML=rt(s.innerHTML):r.isEqualNode(s)||gn(r,s),i&&r.dispatchEvent(new CustomEvent(et,{bubbles:!1}))}return(n===8||n===3)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),e},dn=(e,t)=>{for(let n of t)if(ge.has(n.id)){let r=n;for(;r&&r!==e;){let s=$.get(r);s||(s=new Set,$.set(r,s)),s.add(n.id),r=r.parentElement}}};xe({name:"datastar-patch-signals",apply({error:e},{signals:t,onlyIfMissing:n}){if(typeof t!="string")throw e("PatchSignalsExpectedSignals");let r=Be(n);I(pe(t),{ifMissing:r})}});export{V as action,Jt as actions,m as attribute,O as beginBatch,je as computed,T as effect,P as endBatch,G as filtered,oe as getPath,I as mergePatch,S as mergePaths,ie as root,Te as signal,F as startPeeking,_ as stopPeeking,xe as watcher}; +//# sourceMappingURL=datastar.js.map diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ new file mode 100644 index 0000000..e86a3b6 --- /dev/null +++ b/cmd/foodster/views.templ @@ -0,0 +1,143 @@ +package main + +import ( + "fmt" + "time" +) + +// Finnish weekday names. Go formats dates in English only, and this app has +// exactly one locale (PRD §5). +var ( + weekdaysFI = [...]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko", + "torstai", "perjantai", "lauantai"} + weekdayAbbrFI = [...]string{"su", "ma", "ti", "ke", "to", "pe", "la"} +) + +func longDateFI(t time.Time) string { + return weekdaysFI[t.Weekday()] + " " + t.Format("2.1.2006") +} + +func dayLabelFI(t time.Time) string { + return weekdayAbbrFI[t.Weekday()] + " " + t.Format("2.1.") +} + +// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive +// after every number except one. +func countFI(n int, one, many string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, one) + } + return fmt.Sprintf("%d %s", n, many) +} + +templ page(title, current string) { + + + + + + + { title } + + + + + { children... } + @tabbar(current) + + +} + +templ tabbar(current string) { + +} + +templ tab(href, label, current string) { + if href == current { + { label } + } else { + { label } + } +} + +templ indexPage(now time.Time) { + @page("Foodster", "/") { +
+

Mitä syötiin?

+

{ longDateFI(now) }

+
+
+

Ruokien kirjaus tulee tähän.

+
+ } +} + +templ historyPage() { + @page("Historia — Foodster", "/historia") { +
+

Historia

+
+
+

Merkinnät tulevat tähän.

+
+ } +} + +templ catalogPage(mains, sides int, report *ImportReport) { + @page("Ruoat — Foodster", "/ruoat") { +
+

Ruoat

+

+ { countFI(mains, "pääruoka", "pääruokaa") }, { countFI(sides, "lisuke", "lisuketta") } +

+
+
+ if report != nil { + @importReport(report) + } + @importForm() +
+ } +} + +templ importForm() { +
+

Tuo ruokia

+

+ Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan. +

+
+ + + +
+
+} + +templ importReport(r *ImportReport) { +
0) }> +

{ fmt.Sprintf("Lisätty %d, ohitettu %d", r.Added, r.Skipped) }

+ if len(r.Notes) > 0 { +
    + for _, note := range r.Notes { +
  • { note }
  • + } +
+ } +
+} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a59216 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module foodster + +go 1.27.1 + +tool github.com/a-h/templ/cmd/templ + +require ( + github.com/a-h/templ v0.3.1020 + modernc.org/sqlite v1.58.0 +) + +require ( + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/natefinch/atomic v1.0.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..819b998 --- /dev/null +++ b/go.sum @@ -0,0 +1,84 @@ +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..7f8ce4e --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# End-to-end check of a running Foodster: auth, static assets and the bundle +# import flow. Builds its own binary, uses a scratch database and a spare +# port, and cleans up after itself, so it never touches a real instance. +# +# Run it with `make smoke`. + +set -eu + +cd "$(dirname "$0")/.." + +addr=127.0.0.1:8099 +pass=smoke +tmp=$(mktemp -d) +trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT + +go build -o "$tmp/foodster" ./cmd/foodster + +FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \ + "$tmp/foodster" >"$tmp/server.log" 2>&1 & +srv=$! + +i=0 +while ! curl -sf "http://$addr/healthz" >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -gt 50 ]; then + echo "server did not start:" + cat "$tmp/server.log" + exit 1 + fi + sleep 0.1 +done + +fail=0 + +# check +check() { + if printf '%s' "$2" | grep -qF -- "$3"; then + echo " ok $1" + else + echo " FAIL $1 (expected to find: $3)" + fail=1 + fi +} + +echo "smoke: http://$addr" + +check "unauthenticated request is refused" \ + "$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/")" "401" + +check "healthz needs no password" \ + "$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/healthz")" "200" + +check "datastar client is served" \ + "$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/datastar.js")" "200" + +check "catalog starts empty" \ + "$(curl -s -u ":$pass" "http://$addr/ruoat")" "0 pääruokaa" + +out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti") +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 "re-import refuses duplicates" \ + "$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/tuonti")" \ + "jo listalla" + +check "pasted JSON imports" \ + "$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \ + "http://$addr/ruoat/tuonti")" "Lisätty 1" + +check "unknown category is reported" \ + "$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \ + "http://$addr/ruoat/tuonti")" "tuntematon kategoria" + +check "empty submit is explained" \ + "$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruoat/tuonti")" "Ei tuotavaa" + +check "malformed JSON is explained" \ + "$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa" + +if [ "$fail" -ne 0 ]; then + echo "smoke: FAILED" + exit 1 +fi +echo "smoke: all passed" diff --git a/seeds/kotiruoat.json b/seeds/kotiruoat.json new file mode 100644 index 0000000..f6ed46d --- /dev/null +++ b/seeds/kotiruoat.json @@ -0,0 +1,18 @@ +{ + "mains": [ + {"name": "Uunilohi", "categories": ["fish"], "has_sides": true}, + {"name": "Lasagnette", "categories": ["meat"], "has_sides": false}, + {"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true}, + {"name": "Risotto", "categories": ["vegetarian"], "has_sides": false}, + {"name": "Pakastepizza", "categories": ["meat"], "has_sides": false}, + {"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false}, + {"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false} + ], + "sides": [ + {"name": "Keitetyt perunat"}, + {"name": "Ranskalaiset"}, + {"name": "Muussi"}, + {"name": "Riisi"}, + {"name": "Pasta"} + ] +} diff --git a/seeds/testi.json b/seeds/testi.json new file mode 100644 index 0000000..59972e1 --- /dev/null +++ b/seeds/testi.json @@ -0,0 +1,28 @@ +{ + "mains": [ + {"name": "Lihapullat", "categories": ["meat"], "has_sides": true}, + {"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true}, + {"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false}, + {"name": "Kaalilaatikko", "categories": ["meat"], "has_sides": false}, + {"name": "Uunimakkara", "categories": ["meat"], "has_sides": true}, + {"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}, + {"name": "Broilerikastike", "categories": ["chicken"], "has_sides": true}, + {"name": "Kanawokki", "categories": ["chicken"], "has_sides": true}, + {"name": "Lohikeitto", "categories": ["fish"], "has_sides": true}, + {"name": "Uunilohi", "categories": ["fish"], "has_sides": true}, + {"name": "Kalapuikot", "categories": ["fish"], "has_sides": true}, + {"name": "Kasvispyörykät", "categories": ["vegetarian"], "has_sides": true}, + {"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false}, + {"name": "Kasviscurry", "categories": ["vegetarian"], "has_sides": true}, + {"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}, + {"name": "Kotipizza", "categories": ["meat", "chicken", "vegetarian"], "has_sides": false} + ], + "sides": [ + {"name": "Perunamuusi"}, + {"name": "Riisi"}, + {"name": "Keitetyt perunat"}, + {"name": "Vihersalaatti"}, + {"name": "Höyrytetyt porkkanat"}, + {"name": "Ruisleipä"} + ] +}