summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/todoist.go11
-rw-r--r--internal/api/todoist_test.go47
-rw-r--r--internal/handlers/timeline_logic.go25
-rw-r--r--internal/handlers/timeline_logic_test.go30
-rw-r--r--internal/models/atom.go6
-rw-r--r--internal/models/atom_test.go34
-rw-r--r--internal/store/sqlite.go15
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)