Scaffold the Go app: auth, migrations, bundle import
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="fi">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<meta name="color-scheme" content="light dark"/>
|
||||
<title>{ title }</title>
|
||||
<link rel="stylesheet" href="/static/app.css"/>
|
||||
<script type="module" src="/static/datastar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{ children... }
|
||||
@tabbar(current)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ tabbar(current string) {
|
||||
<nav class="tabbar">
|
||||
@tab("/", "Kirjaa", current)
|
||||
@tab("/historia", "Historia", current)
|
||||
@tab("/ruoat", "Ruoat", current)
|
||||
</nav>
|
||||
}
|
||||
|
||||
templ tab(href, label, current string) {
|
||||
if href == current {
|
||||
<a href={ templ.SafeURL(href) } aria-current="page">{ label }</a>
|
||||
} else {
|
||||
<a href={ templ.SafeURL(href) }>{ label }</a>
|
||||
}
|
||||
}
|
||||
|
||||
templ indexPage(now time.Time) {
|
||||
@page("Foodster", "/") {
|
||||
<header class="appbar">
|
||||
<h2>Mitä syötiin?</h2>
|
||||
<p class="meta">{ longDateFI(now) }</p>
|
||||
</header>
|
||||
<main class="pad">
|
||||
<p class="muted">Ruokien kirjaus tulee tähän.</p>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
templ historyPage() {
|
||||
@page("Historia — Foodster", "/historia") {
|
||||
<header class="appbar">
|
||||
<h2>Historia</h2>
|
||||
</header>
|
||||
<main class="pad">
|
||||
<p class="muted">Merkinnät tulevat tähän.</p>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
templ catalogPage(mains, sides int, report *ImportReport) {
|
||||
@page("Ruoat — Foodster", "/ruoat") {
|
||||
<header class="appbar">
|
||||
<h2>Ruoat</h2>
|
||||
<p class="meta">
|
||||
{ countFI(mains, "pääruoka", "pääruokaa") }, { countFI(sides, "lisuke", "lisuketta") }
|
||||
</p>
|
||||
</header>
|
||||
<main class="pad">
|
||||
if report != nil {
|
||||
@importReport(report)
|
||||
}
|
||||
@importForm()
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
templ importForm() {
|
||||
<section class="card">
|
||||
<h3>Tuo ruokia</h3>
|
||||
<p class="muted small">
|
||||
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
|
||||
</p>
|
||||
<form method="post" action="/ruoat/tuonti" enctype="multipart/form-data">
|
||||
<label class="field">
|
||||
<span>JSON</span>
|
||||
<textarea
|
||||
name="json"
|
||||
rows="8"
|
||||
spellcheck="false"
|
||||
placeholder={ `{"mains": [{"name": "Kanacurry", "categories": ["chicken"]}], "sides": [{"name": "Riisi"}]}` }
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>tai tiedosto</span>
|
||||
<input type="file" name="tiedosto" accept="application/json,.json"/>
|
||||
</label>
|
||||
<button class="primary" type="submit">Tuo</button>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
templ importReport(r *ImportReport) {
|
||||
<section class={ "card", "report", templ.KV("bad", r.Added == 0 && r.Skipped > 0) }>
|
||||
<p class="tally">{ fmt.Sprintf("Lisätty %d, ohitettu %d", r.Added, r.Skipped) }</p>
|
||||
if len(r.Notes) > 0 {
|
||||
<ul class="notes">
|
||||
for _, note := range r.Notes {
|
||||
<li>{ note }</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
Reference in New Issue
Block a user