diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index 317d4d4..767b974 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -38,13 +38,22 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) { // date reads the ?pvm= parameter, falling back to today. An unparseable value // is treated as today rather than an error: a mangled URL should not be a // dead end. +// +// A future date is clamped to today. This is a record of what was eaten, so +// there is nothing to write down for a dinner that has not happened, and a +// stray entry dated next year would sit at the top of the history forever. +// Every read and write goes through here, so the clamp covers them all. func (a *app) date(r *http.Request) time.Time { + now := today(a.loc) if raw := r.FormValue("pvm"); raw != "" { if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil { + if d.After(now) { + return now + } return d } } - return today(a.loc) + return now } // logView is everything the log screen needs. Logging and history are one diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 0fe374e..fabe8af 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -212,11 +212,17 @@ func challenge(w http.ResponseWriter) { http.Error(w, "Unauthorized", http.StatusUnauthorized) } -// 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. +// today is the current calendar day in the configured location, truncated to +// midnight. 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. +// +// The truncation matters: dates parsed from ?pvm= are midnight, so a today +// carrying a time of day would never compare equal to one of them, and the UI +// would stop recognising today as today the moment the date was explicit. func today(loc *time.Location) time.Time { - return time.Now().In(loc) + now := time.Now().In(loc) + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) } // normalizeName collapses whitespace and capitalises the first letter for diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go index a335ff6..3d5a6da 100644 --- a/cmd/foodster/main_test.go +++ b/cmd/foodster/main_test.go @@ -30,6 +30,51 @@ func TestNormalizeName(t *testing.T) { } } +func TestTodayIsMidnight(t *testing.T) { + now := today(time.UTC) + if h, m, s := now.Clock(); h != 0 || m != 0 || s != 0 { + t.Errorf("today() = %s, want midnight", now) + } + // A date parsed from a URL must compare equal to it, or the UI stops + // recognising today as today whenever the date is spelled out. + parsed, err := time.ParseInLocation(dateLayout, now.Format(dateLayout), time.UTC) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !parsed.Equal(now) { + t.Errorf("parsed %s != today %s", parsed, now) + } +} + +func TestDateRejectsTheFuture(t *testing.T) { + a := &app{loc: time.UTC} + now := today(time.UTC) + + cases := []struct { + name string + pvm string + want time.Time + }{ + {"no parameter", "", now}, + {"today", now.Format(dateLayout), now}, + {"yesterday", now.AddDate(0, 0, -1).Format(dateLayout), now.AddDate(0, 0, -1)}, + // Nothing was eaten tomorrow, and a stray entry dated next year would + // sit at the top of the history forever. + {"tomorrow", now.AddDate(0, 0, 1).Format(dateLayout), now}, + {"next year", now.AddDate(1, 0, 0).Format(dateLayout), now}, + {"nonsense", "eilen", now}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/?pvm="+c.pvm, nil) + if got := a.date(r); !got.Equal(c.want) { + t.Errorf("date = %s, want %s", got.Format(dateLayout), c.want.Format(dateLayout)) + } + }) + } +} + func TestMigrateCreatesSchema(t *testing.T) { db, err := openDB(t.TempDir() + "/test.db") if err != nil { diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index 15b0c55..7ec0db5 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -218,7 +218,9 @@ templ daySwitch(v logView) { @dayButton("Tänään", v.Today, v.Date, v.Today) @dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
- + // max stops the picker offering days that have not happened yet; + // the server clamps anyway, this just avoids the dead end. +
diff --git a/scripts/smoke.sh b/scripts/smoke.sh index f02f85f..336c87e 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -135,6 +135,21 @@ check "the day is empty again" \ check "search filters the board" \ "$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto" +# Nothing was eaten tomorrow. A future date is clamped rather than logged. +future=$(date -d '+30 days' +%Y-%m-%d) +check "a future date falls back to today" \ + "$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)" + +check "saving a future date is clamped too" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/" + +check "tomorrow was not written to the log" \ + "$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)" + +# Clean up the entry that clamped onto today. +curl -s -o /dev/null -u ":$pass" -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista" + # ---- adding a dish without leaving Kirjaa -------------------------------- miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste")