Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions internal/model/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
8 changes: 5 additions & 3 deletions internal/things/dates.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"

"github.com/ryanlewis/things-cli/internal/model"
)

var whenKeywords = []string{"today", "tomorrow", "evening", "anytime", "someday"}
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions internal/things/dates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading