summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 22:30:03 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 22:30:03 +0300
commit9fc53c1970f61360e2d8169fad4cbe11e23edac6 (patch)
tree4a052a69cabef0dd5ba5b494904e246a62b263ae
parent81a4d4f67e02c14bfa51c71feac2ceb9625e0c8d (diff)
Fix Taskwarrior date-only due labels (oq0)
-rw-r--r--internal/ui/helpers.go10
-rw-r--r--internal/ui/helpers_test.go47
-rw-r--r--internal/ui/table_test.go32
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)
}