From e64c4aa21262fbe6a8d027a8c737af6c7cda7280 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 21:31:23 +0300 Subject: [PATCH] Refuse to log meals in the future MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log 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. A future date is now clamped to today. The clamp lives in the one function every read and write already goes through, so ?pvm=, the date picker, saving and deleting are all covered. The picker also gets max=today, which avoids offering the dead end at all. Fixes a latent bug found alongside it: today() returned the current instant with its time of day, while dates parsed from ?pvm= are midnight, so the two never compared equal. After saving today's dinner the redirect landed on /?pvm=... and the card then read "la 5.9. kirjattu" instead of "Tänään kirjattu", and "Tänään" stopped highlighting whenever the date was spelled out. today() now truncates to midnight in the configured location. --- cmd/foodster/handlers.go | 11 +++++++++- cmd/foodster/main.go | 14 ++++++++---- cmd/foodster/main_test.go | 45 +++++++++++++++++++++++++++++++++++++++ cmd/foodster/views.templ | 4 +++- scripts/smoke.sh | 15 +++++++++++++ 5 files changed, 83 insertions(+), 6 deletions(-) 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")