Refuse to log meals in the future

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.
This commit is contained in:
Esa Kataja
2026-09-05 21:31:23 +03:00
parent 4ce189d1e4
commit e64c4aa212
5 changed files with 83 additions and 6 deletions
+10 -1
View File
@@ -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
+10 -4
View File
@@ -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
+45
View File
@@ -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 {
+3 -1
View File
@@ -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)
<form method="get" action="/" class="daypick">
<input type="date" name="pvm" value={ isoDate(v.Date) } aria-label="Muu päivä"/>
// max stops the picker offering days that have not happened yet;
// the server clamps anyway, this just avoids the dead end.
<input type="date" name="pvm" value={ isoDate(v.Date) } max={ isoDate(v.Today) } aria-label="Muu päivä"/>
<button type="submit">Näytä</button>
</form>
</div>