diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/todoist.go | 11 | ||||
| -rw-r--r-- | internal/api/todoist_test.go | 47 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 25 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 30 | ||||
| -rw-r--r-- | internal/models/atom.go | 6 | ||||
| -rw-r--r-- | internal/models/atom_test.go | 34 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 15 |
7 files changed, 162 insertions, 6 deletions
diff --git a/internal/api/todoist.go b/internal/api/todoist.go index d6058d3..2454233 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -3,6 +3,7 @@ package api import ( "context" "fmt" + "strings" "time" "task-dashboard/internal/config" @@ -239,8 +240,14 @@ func parseDueDate(due *dueInfo) *time.Time { dueDate = config.ToDisplayTZ(dueDate) } } else if due.Date != "" { - // Date-only, parse in display timezone - dueDate, err = config.ParseDateInDisplayTZ(due.Date) + // Todoist may put a local datetime (no tz offset) in the date field + // e.g. "2026-03-22T19:00:00" for recurring tasks with a set time. + // Fall back to date-only "2006-01-02" if no T is present. + if strings.Contains(due.Date, "T") { + dueDate, err = config.ParseDateTimeInDisplayTZ("2006-01-02T15:04:05", due.Date) + } else { + dueDate, err = config.ParseDateInDisplayTZ(due.Date) + } } if err != nil { return nil diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go index 99b9e80..d2c8da5 100644 --- a/internal/api/todoist_test.go +++ b/internal/api/todoist_test.go @@ -142,6 +142,53 @@ func TestTodoistClient_CreateTask_WithDueDate(t *testing.T) { } } +func TestParseDueDate_LocalDatetimeInDateField(t *testing.T) { + // Todoist REST API v1 puts local datetime (no tz offset) in the "date" field + // when datetime is not in UTC (e.g. recurring tasks with a set time) + // e.g. due={date="2026-03-22T19:00:00" datetime="" is_recurring=true} + due := &dueInfo{ + Date: "2026-03-22T19:00:00", + Datetime: "", + IsRecurring: true, + } + result := parseDueDate(due) + if result == nil { + t.Fatal("parseDueDate returned nil for date field containing local datetime — must parse YYYY-MM-DDTHH:MM:SS format") + } + if result.Hour() != 19 || result.Minute() != 0 { + t.Errorf("Expected 19:00, got %02d:%02d", result.Hour(), result.Minute()) + } +} + +func TestParseDueDate_MicrosecondDatetime(t *testing.T) { + // Todoist REST API v1 returns datetime with microseconds: "2023-01-15T10:00:00.000000Z" + // time.RFC3339 cannot parse fractional seconds — parseDueDate must use RFC3339Nano + due := &dueInfo{ + Date: "2023-01-15", + Datetime: "2023-01-15T10:00:00.000000Z", + IsRecurring: true, + } + result := parseDueDate(due) + if result == nil { + t.Fatal("parseDueDate returned nil for datetime with microseconds — RFC3339Nano required") + } + if result.Hour() != 10 || result.Minute() != 0 { + t.Errorf("Expected 10:00, got %02d:%02d", result.Hour(), result.Minute()) + } +} + +func TestParseDueDate_RFC3339Datetime(t *testing.T) { + // Standard RFC3339 without fractional seconds should also work + due := &dueInfo{ + Date: "2023-01-15", + Datetime: "2023-01-15T10:00:00Z", + } + result := parseDueDate(due) + if result == nil { + t.Fatal("parseDueDate returned nil for standard RFC3339 datetime") + } +} + func TestTodoistClient_UsesAPIv1BaseURL(t *testing.T) { client := NewTodoistClient("test-key") const want = "https://api.todoist.com/api/v1" diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index adfa406..145e851 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -18,7 +18,7 @@ func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTa var items []models.TimelineItem now := config.Now() - // 1. Fetch Tasks + // 1. Fetch Tasks (dated) tasks, err := s.GetTasksByDateRange(start, end) if err != nil { return nil, err @@ -42,6 +42,29 @@ func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTa items = append(items, item) } + // 1b. Fetch undated Tasks — place in Today section + undatedTasks, err := s.GetUndatedTasks() + if err != nil { + log.Printf("Warning: failed to fetch undated tasks: %v", err) + } else { + for _, task := range undatedTasks { + item := models.TimelineItem{ + ID: task.ID, + Type: models.TimelineItemTypeTask, + Title: task.Content, + Time: now, + Description: task.Description, + URL: task.URL, + OriginalItem: task, + IsCompleted: task.Completed, + Source: "todoist", + IsAllDay: true, + } + item.ComputeDaySection(now) + items = append(items, item) + } + } + // 2. Fetch Meals - combine multiple items for same date+mealType meals, err := s.GetMealsByDateRange(start, end) if err != nil { diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index b42ad4c..8104a96 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -462,6 +462,36 @@ func TestSaveAndGetCalendarEvents(t *testing.T) { } } +func TestBuildTimeline_IncludesUndatedTodoistTasks(t *testing.T) { + s := setupTestStore(t) + + // Save a task with no due date + _ = s.SaveTasks([]models.Task{ + {ID: "undated1", Content: "Take out recycling"}, // DueDate is nil + }) + + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) + + items, err := BuildTimeline(context.Background(), s, nil, start, end) + if err != nil { + t.Fatalf("BuildTimeline failed: %v", err) + } + + found := false + for _, item := range items { + if item.ID == "undated1" { + found = true + if item.DaySection != models.DaySectionToday { + t.Errorf("Undated task should be in Today section, got %s", item.DaySection) + } + } + } + if !found { + t.Error("Undated Todoist task should appear in timeline Today section") + } +} + func timePtr(t time.Time) *time.Time { return &t } diff --git a/internal/models/atom.go b/internal/models/atom.go index 9c519ba..3745917 100644 --- a/internal/models/atom.go +++ b/internal/models/atom.go @@ -57,15 +57,15 @@ func (a *Atom) ComputeUIFields() { tz := config.GetDisplayTimezone() now := config.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, tz) - tomorrow := today.AddDate(0, 0, 1) // Check if overdue (due date is before today) dueInTZ := a.DueDate.In(tz) dueDay := time.Date(dueInTZ.Year(), dueInTZ.Month(), dueInTZ.Day(), 0, 0, 0, 0, tz) a.IsOverdue = dueDay.Before(today) - // Check if future (due date is after today) - a.IsFuture = !dueDay.Before(tomorrow) + // Check if future (due date is 7+ days out — collapse in tasks tab) + sevenDaysOut := today.AddDate(0, 0, 7) + a.IsFuture = !dueDay.Before(sevenDaysOut) // Check if has set time (not midnight) a.HasSetTime = dueInTZ.Hour() != 0 || dueInTZ.Minute() != 0 diff --git a/internal/models/atom_test.go b/internal/models/atom_test.go index 3ed4774..70bc14b 100644 --- a/internal/models/atom_test.go +++ b/internal/models/atom_test.go @@ -134,6 +134,40 @@ func TestAtom_ComputeUIFields(t *testing.T) { } }) + // Tasks due within 7 days should NOT be IsFuture (shown in main section with dates) + t.Run("tomorrow is not future", func(t *testing.T) { + tz := time.UTC + now := time.Now().In(tz) + tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, tz) + atom := Atom{DueDate: &tomorrow} + atom.ComputeUIFields() + if atom.IsFuture { + t.Error("Task due tomorrow should not be IsFuture — it should appear in main tasks section with its date visible") + } + }) + + t.Run("6 days out is not future", func(t *testing.T) { + tz := time.UTC + now := time.Now().In(tz) + sixDays := time.Date(now.Year(), now.Month(), now.Day()+6, 0, 0, 0, 0, tz) + atom := Atom{DueDate: &sixDays} + atom.ComputeUIFields() + if atom.IsFuture { + t.Error("Task due in 6 days should not be IsFuture") + } + }) + + t.Run("8 days out is future", func(t *testing.T) { + tz := time.UTC + now := time.Now().In(tz) + eightDays := time.Date(now.Year(), now.Month(), now.Day()+8, 0, 0, 0, 0, tz) + atom := Atom{DueDate: &eightDays} + atom.ComputeUIFields() + if !atom.IsFuture { + t.Error("Task due in 8 days should be IsFuture") + } + }) + // Test with due date at midnight (no specific time) t.Run("midnight due date", func(t *testing.T) { now := time.Now() diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 166cd63..f4651bb 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -723,6 +723,21 @@ func (s *Store) GetTasksByDateRange(start, end time.Time) ([]models.Task, error) return scanTasks(rows) } +// GetUndatedTasks retrieves incomplete tasks with no due date. +func (s *Store) GetUndatedTasks() ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at + FROM tasks + WHERE due_date IS NULL AND completed = FALSE + ORDER BY priority DESC, created_at ASC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanTasks(rows) +} + // GetMealsByDateRange retrieves meals within a specific date range func (s *Store) GetMealsByDateRange(start, end time.Time) ([]models.Meal, error) { return s.GetMeals(start, end) |
