diff --git a/go.mod b/go.mod index 9b7b049..ba08b47 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/ryanlewis/things-cli go 1.26.0 -toolchain go1.26.9 +toolchain go1.26.10 require ( charm.land/lipgloss/v2 v2.0.6 diff --git a/internal/model/model.go b/internal/model/model.go index 4983c6b..f6a5d1d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -203,7 +203,19 @@ func (d ThingsDate) Date() (year int, month time.Month, day int) { func (d ThingsDate) ToTime() time.Time { year, month, day := d.Date() - return time.Date(year, month, day, 0, 0, 0, 0, time.Local) + return DayStart(year, month, day) +} + +// DayStart is the first instant of the given local day. That is midnight, +// except where a daylight-saving change skips it (Santiago, Havana, Beirut): +// time.Date then answers with the evening of the day before, so it steps +// forward to the first hour that is on the day. +func DayStart(year int, month time.Month, day int) time.Time { + t := time.Date(year, month, day, 0, 0, 0, 0, time.Local) + for h := 1; t.Day() != day && h < 24; h++ { + t = time.Date(year, month, day, h, 0, 0, 0, time.Local) + } + return t } func ThingsDateFromTime(t time.Time) ThingsDate { diff --git a/internal/model/model_test.go b/internal/model/model_test.go index b12488d..be04545 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -571,3 +571,30 @@ func TestReminderText(t *testing.T) { } } } + +// Santiago's clocks go from 23:59 on 5 Sep 2026 straight to 01:00 on 6 Sep, so +// local midnight on the 6th does not exist. time.Date then answers with the +// evening of the 5th, and a date built from it printed a day early. +func TestThingsDateSkippedMidnight(t *testing.T) { + loc, err := time.LoadLocation("America/Santiago") + if err != nil { + t.Skipf("no tzdata: %v", err) + } + old := time.Local + time.Local = loc + t.Cleanup(func() { time.Local = old }) + + d := ThingsDateFromTime(time.Date(2026, 9, 6, 12, 0, 0, 0, loc)) + if got := d.String(); got != "2026-09-06" { + t.Errorf("String() = %q, want 2026-09-06", got) + } + if got := d.AddDays(1).String(); got != "2026-09-07" { + t.Errorf("AddDays(1) = %q, want 2026-09-07", got) + } + if got := d.AddDays(-1).String(); got != "2026-09-05" { + t.Errorf("AddDays(-1) = %q, want 2026-09-05", got) + } + if y, m, day := d.ToTime().Date(); y != 2026 || m != time.September || day != 6 { + t.Errorf("ToTime() = %v, want a time on 2026-09-06", d.ToTime()) + } +} diff --git a/internal/things/dates.go b/internal/things/dates.go index 84d78de..8969b7f 100644 --- a/internal/things/dates.go +++ b/internal/things/dates.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" "time" + + "github.com/ryanlewis/things-cli/internal/model" ) var whenKeywords = []string{"today", "tomorrow", "evening", "anytime", "someday"} @@ -366,13 +368,13 @@ func ParseListDate(flag, s string) (time.Time, error) { return time.Time{}, fmt.Errorf("--%s: date is empty", flag) } if t, ok := parseISO8601(v); ok { - return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), nil + return model.DayStart(t.Year(), t.Month(), t.Day()), nil } - t, err := time.ParseInLocation("2006-01-02", v, time.Local) + t, err := time.Parse("2006-01-02", v) if err != nil { return time.Time{}, fmt.Errorf("--%s: invalid date %q (expected YYYY-MM-DD)", flag, v) } - return t, nil + return model.DayStart(t.Year(), t.Month(), t.Day()), nil } // NormalizeDeadline validates and canonicalises a --deadline value. The URL diff --git a/internal/things/dates_test.go b/internal/things/dates_test.go index 7a1a2b2..0f620f2 100644 --- a/internal/things/dates_test.go +++ b/internal/things/dates_test.go @@ -300,3 +300,25 @@ func TestResolveWhen(t *testing.T) { } } } + +// Local midnight on 6 Sep 2026 does not exist in Santiago, so a --on date +// built at midnight came out as the 5th and the filter matched the wrong day. +func TestParseListDateSkippedMidnight(t *testing.T) { + loc, err := time.LoadLocation("America/Santiago") + if err != nil { + t.Skipf("no tzdata: %v", err) + } + old := time.Local + time.Local = loc + t.Cleanup(func() { time.Local = old }) + + for _, in := range []string{"2026-09-06", "2026-09-06T10:00:00Z"} { + got, err := ParseListDate("on", in) + if err != nil { + t.Fatalf("ParseListDate(%q): %v", in, err) + } + if y, m, d := got.Date(); y != 2026 || m != time.September || d != 6 { + t.Errorf("ParseListDate(%q) = %v, want a time on 2026-09-06", in, got) + } + } +}