diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-22 22:30:03 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-22 22:30:03 +0300 |
| commit | 9fc53c1970f61360e2d8169fad4cbe11e23edac6 (patch) | |
| tree | 4a052a69cabef0dd5ba5b494904e246a62b263ae | |
| parent | 81a4d4f67e02c14bfa51c71feac2ceb9625e0c8d (diff) | |
Fix Taskwarrior date-only due labels (oq0)
| -rw-r--r-- | internal/ui/helpers.go | 10 | ||||
| -rw-r--r-- | internal/ui/helpers_test.go | 47 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 32 |
3 files changed, 58 insertions, 31 deletions
diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index 89e771a..0358e86 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -26,10 +26,14 @@ func parseTaskDate(dateStr string) (time.Time, error) { // daysUntil returns the number of days until the given time func daysUntil(t time.Time) int { - now := time.Now() - // Normalize both times to midnight UTC to avoid timezone and fractional day issues + now := time.Now().In(time.Local) + targetLocal := t.In(time.Local) + + // Taskwarrior exports date-only due values as the local midnight instant in + // UTC. Compare local calendar dates, then use UTC midnights for stable + // whole-day arithmetic across DST transitions. today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - target := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + target := time.Date(targetLocal.Year(), targetLocal.Month(), targetLocal.Day(), 0, 0, 0, 0, time.UTC) return int(target.Sub(today) / (24 * time.Hour)) } diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go index 41efe66..f4afc11 100644 --- a/internal/ui/helpers_test.go +++ b/internal/ui/helpers_test.go @@ -11,8 +11,42 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + + "codeberg.org/snonux/tasksamurai/internal/task" ) +func useSofiaLocalTime(t *testing.T) (*time.Location, time.Time) { + t.Helper() + + loc, err := time.LoadLocation("Europe/Sofia") + if err != nil { + t.Skipf("load Europe/Sofia location: %v", err) + } + + oldLocal := time.Local + time.Local = loc + t.Cleanup(func() { + time.Local = oldLocal + }) + + return loc, time.Now().In(loc) +} + +func taskwarriorDateOnlyDue(base time.Time, loc *time.Location, dayOffset int) string { + day := base.AddDate(0, 0, dayOffset) + due := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) + return due.UTC().Format(task.DateFormat) +} + +func skipIfLocalDateChanged(t *testing.T, loc *time.Location, base time.Time) { + t.Helper() + + later := time.Now().In(loc) + if later.Year() != base.Year() || later.YearDay() != base.YearDay() { + t.Skip("local date changed during due-date test") + } +} + func TestParseTaskDate(t *testing.T) { tests := []struct { name string @@ -47,7 +81,7 @@ func TestParseTaskDate(t *testing.T) { } func TestFormatDueText(t *testing.T) { - now := time.Now() + loc, now := useSofiaLocalTime(t) tests := []struct { name string input string @@ -60,27 +94,27 @@ func TestFormatDueText(t *testing.T) { }, { name: "today", - input: now.UTC().Format("20060102T150405Z"), + input: taskwarriorDateOnlyDue(now, loc, 0), expected: "today", }, { name: "tomorrow", - input: now.Add(24 * time.Hour).UTC().Format("20060102T150405Z"), + input: taskwarriorDateOnlyDue(now, loc, 1), expected: "tomorrow", }, { name: "yesterday", - input: now.Add(-24 * time.Hour).UTC().Format("20060102T150405Z"), + input: taskwarriorDateOnlyDue(now, loc, -1), expected: "yesterday", }, { name: "future", - input: now.Add(5 * 24 * time.Hour).UTC().Format("20060102T150405Z"), + input: taskwarriorDateOnlyDue(now, loc, 5), expected: "5d", }, { name: "past", - input: now.Add(-3 * 24 * time.Hour).UTC().Format("20060102T150405Z"), + input: taskwarriorDateOnlyDue(now, loc, -3), expected: "-3d", }, { @@ -93,6 +127,7 @@ func TestFormatDueText(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatDueText(tt.input) + skipIfLocalDateChanged(t, loc, now) if got != tt.expected { t.Errorf("formatDueText() = %v, want %v", got, tt.expected) } diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 412a271..1120d27 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -75,19 +75,7 @@ func TestAnnotateHotkey(t *testing.T) { } func TestFormatDueUsesCalendarDayLabels(t *testing.T) { - now := time.Now() - dueOn := func(dayOffset int, hour, minute int) string { - day := now.AddDate(0, 0, dayOffset) - due := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, time.UTC) - return due.Format(task.DateFormat) - } - skipIfDateChanged := func() { - t.Helper() - later := time.Now() - if later.Year() != now.Year() || later.YearDay() != now.YearDay() { - t.Skip("local date changed during due-date boundary test") - } - } + loc, now := useSofiaLocalTime(t) m := Model{theme: DefaultTheme()} tests := []struct { @@ -97,24 +85,24 @@ func TestFormatDueUsesCalendarDayLabels(t *testing.T) { overdue bool }{ { - name: "late yesterday", - due: dueOn(-1, 23, 59), + name: "taskwarrior date-only yesterday", + due: taskwarriorDateOnlyDue(now, loc, -1), want: "yesterday", overdue: true, }, { - name: "early today", - due: dueOn(0, 0, 1), + name: "taskwarrior date-only today", + due: taskwarriorDateOnlyDue(now, loc, 0), want: "today", }, { - name: "late tomorrow", - due: dueOn(1, 23, 59), + name: "taskwarrior date-only tomorrow", + due: taskwarriorDateOnlyDue(now, loc, 1), want: "tomorrow", }, { - name: "future day count", - due: dueOn(3, 23, 59), + name: "taskwarrior date-only future day count", + due: taskwarriorDateOnlyDue(now, loc, 3), want: "3d", }, } @@ -123,7 +111,7 @@ func TestFormatDueUsesCalendarDayLabels(t *testing.T) { t.Run(tt.name, func(t *testing.T) { rendered := m.formatDue(tt.due, 12) got := strings.TrimSpace(ansi.Strip(rendered)) - skipIfDateChanged() + skipIfLocalDateChanged(t, loc, now) if got != tt.want { t.Fatalf("formatDue() = %q, want %q", got, tt.want) } |
