From b2d8fc460be3105ac383098e7cdc92171e5026ec Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 23 Mar 2026 08:13:02 +0000 Subject: feat: unify Google Tasks with main system via caching and integrated UI - Implement SQLite caching layer for Google Tasks - Integrate Google Tasks into unified Atoms loop (showing in Tasks tab) - Update Planning tab to include cached Google Tasks - Enhance Quick Add form with Todoist project selector - Remove orphaned HandleTasksTab/HandleRefreshTab methods - Update tests to reflect new BuildTimeline signature and data structures --- internal/handlers/agent.go | 2 +- internal/handlers/atoms.go | 18 ++++- internal/handlers/handlers.go | 109 ++++++++++++++++++++++++------- internal/handlers/handlers_test.go | 2 + internal/handlers/timeline.go | 2 +- internal/handlers/timeline_logic.go | 56 +++++++--------- internal/handlers/timeline_logic_test.go | 21 ++++-- 7 files changed, 148 insertions(+), 62 deletions(-) (limited to 'internal/handlers') diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go index 6d6079f..826ffd7 100644 --- a/internal/handlers/agent.go +++ b/internal/handlers/agent.go @@ -326,7 +326,7 @@ func (h *Handler) HandleAgentContext(w http.ResponseWriter, r *http.Request) { // buildAgentContext builds the context timeline by reusing BuildTimeline func (h *Handler) buildAgentContext(ctx context.Context, start, end time.Time) []agentContextItem { // Reuse the main BuildTimeline function (excludes live API calls for Google services) - timelineItems, err := BuildTimeline(ctx, h.store, nil, start, end) + timelineItems, err := BuildTimeline(ctx, h.store, start, end) if err != nil { return nil } diff --git a/internal/handlers/atoms.go b/internal/handlers/atoms.go index 0ebf4e6..e99c879 100644 --- a/internal/handlers/atoms.go +++ b/internal/handlers/atoms.go @@ -1,13 +1,14 @@ package handlers import ( + "log" "sort" "task-dashboard/internal/models" "task-dashboard/internal/store" ) -// BuildUnifiedAtomList creates a list of atoms from tasks and cards +// BuildUnifiedAtomList creates a list of atoms from tasks, cards, and google tasks func BuildUnifiedAtomList(s *store.Store) ([]models.Atom, []models.Board, error) { tasks, err := s.GetTasks() if err != nil { @@ -19,7 +20,13 @@ func BuildUnifiedAtomList(s *store.Store) ([]models.Atom, []models.Board, error) return nil, nil, err } - atoms := make([]models.Atom, 0, len(tasks)) + gTasks, err := s.GetGoogleTasks() + if err != nil { + // Log but don't fail if gtasks fails (might be new/not configured) + log.Printf("Warning: failed to fetch cached google tasks: %v", err) + } + + atoms := make([]models.Atom, 0, len(tasks)+len(gTasks)) // Add incomplete tasks for _, task := range tasks { @@ -28,6 +35,13 @@ func BuildUnifiedAtomList(s *store.Store) ([]models.Atom, []models.Board, error) } } + // Add incomplete google tasks + for _, gTask := range gTasks { + if !gTask.Completed { + atoms = append(atoms, models.GoogleTaskToAtom(gTask)) + } + } + // Add cards with due dates or from actionable lists for _, board := range boards { for _, card := range board.Cards { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index b0fd952..8abd4d7 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -173,26 +173,6 @@ func (h *Handler) HandleGetShoppingList(w http.ResponseWriter, r *http.Request) JSONResponse(w, items) } -// HandleTasksTab renders the tasks tab content (Trello + Todoist + PlanToEat) -func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) { - data, err := h.aggregateData(r.Context(), false) - if err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to load tasks", err) - return - } - HTMLResponse(w, h.renderer, "tasks-tab", data) -} - -// HandleRefreshTab refreshes and re-renders the specified tab -func (h *Handler) HandleRefreshTab(w http.ResponseWriter, r *http.Request) { - data, err := h.aggregateData(r.Context(), true) - if err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to refresh", err) - return - } - HTMLResponse(w, h.renderer, "tasks-tab", data) -} - // aggregateData fetches and caches data from all sources concurrently func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) { data := &models.DashboardData{ @@ -267,6 +247,18 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models }) } + if h.googleTasksClient != nil { + fetch("Google Tasks", func() error { + tasks, err := h.fetchGoogleTasks(ctx, forceRefresh) + if err == nil { + mu.Lock() + data.GoogleTasks = tasks + mu.Unlock() + } + return err + }) + } + wg.Wait() // Populate projects from cached tasks (avoids deprecated REST API) @@ -392,6 +384,22 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ return fetcher.FetchWithCache(ctx, forceRefresh) } +// fetchGoogleTasks fetches Google Tasks from cache or API +func (h *Handler) fetchGoogleTasks(ctx context.Context, forceRefresh bool) ([]models.GoogleTask, error) { + if h.googleTasksClient == nil { + return nil, nil + } + fetcher := &CacheFetcher[models.GoogleTask]{ + Store: h.store, + CacheKey: store.CacheKeyGoogleTasks, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.GoogleTask, error) { return h.googleTasksClient.GetTasks(ctx) }, + GetFromCache: h.store.GetGoogleTasks, + SaveToCache: h.store.SaveGoogleTasks, + } + return fetcher.FetchWithCache(ctx, forceRefresh) +} + // fetchBoards fetches Trello boards from cache or API func (h *Handler) fetchBoards(ctx context.Context, forceRefresh bool) ([]models.Board, error) { fetcher := &CacheFetcher[models.Board]{ @@ -684,7 +692,8 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { switch source { case "todoist": - if _, err := h.todoistClient.CreateTask(ctx, title, "", dueDate, 1); err != nil { + projectID := r.FormValue("project_id") + if _, err := h.todoistClient.CreateTask(ctx, title, projectID, dueDate, 1); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create Todoist task", err) return } @@ -805,7 +814,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates + Bugs) +// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates + Bugs + Google Tasks) func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { atoms, boards, err := BuildUnifiedAtomList(h.store) if err != nil { @@ -816,15 +825,19 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { SortAtomsByUrgency(atoms) currentAtoms, futureAtoms := PartitionAtomsByTime(atoms) + projects, _ := h.store.GetProjectsFromTasks() + data := struct { Atoms []models.Atom FutureAtoms []models.Atom Boards []models.Board + Projects []models.Project Today string }{ Atoms: currentAtoms, FutureAtoms: futureAtoms, Boards: boards, + Projects: projects, Today: config.Now().Format("2006-01-02"), } @@ -839,6 +852,7 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { boards, _ := h.store.GetBoards() tasks, _ := h.store.GetTasks() + gTasks, _ := h.store.GetGoogleTasks() events, _ := h.fetchCalendarEvents(r.Context(), false) @@ -941,21 +955,72 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { } } + for _, gTask := range gTasks { + if gTask.Completed { + continue + } + + if gTask.DueDate == nil { + atom := models.GoogleTaskToAtom(gTask) + atom.ComputeUIFields() + unscheduled = append(unscheduled, atom) + continue + } + + dueDate := *gTask.DueDate + // Google Tasks usually don't have times, but if they did we'd handle them here + hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 + + if dueDate.Before(tomorrow) { + if hasTime { + scheduled = append(scheduled, ScheduledItem{ + Type: "task", + ID: gTask.ID, + Title: gTask.Title, + Start: dueDate, + URL: gTask.URL, + Source: "gtasks", + SourceIcon: "🔵", + Priority: 2, + }) + } else { + atom := models.GoogleTaskToAtom(gTask) + atom.ComputeUIFields() + unscheduled = append(unscheduled, atom) + } + } else if dueDate.Before(in3Days) { + upcoming = append(upcoming, ScheduledItem{ + Type: "task", + ID: gTask.ID, + Title: gTask.Title, + Start: dueDate, + URL: gTask.URL, + Source: "gtasks", + SourceIcon: "🔵", + Priority: 2, + }) + } + } + sort.Slice(scheduled, func(i, j int) bool { return scheduled[i].Start.Before(scheduled[j].Start) }) sort.Slice(unscheduled, func(i, j int) bool { return unscheduled[i].Priority > unscheduled[j].Priority }) sort.Slice(upcoming, func(i, j int) bool { return upcoming[i].Start.Before(upcoming[j].Start) }) + projects, _ := h.store.GetProjectsFromTasks() + data := struct { Scheduled []ScheduledItem Unscheduled []models.Atom Upcoming []ScheduledItem Boards []models.Board + Projects []models.Project Today string }{ Scheduled: scheduled, Unscheduled: unscheduled, Upcoming: upcoming, Boards: boards, + Projects: projects, Today: today.Format("2006-01-02"), } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 0d097c8..105641a 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -2818,6 +2818,7 @@ func TestHandleTabPlanning_HappyPath(t *testing.T) { Unscheduled []models.Atom Upcoming []ScheduledItem Boards []models.Board + Projects []models.Project Today string }) if !ok { @@ -2910,6 +2911,7 @@ func TestHandleTabPlanning_TomorrowBoundary(t *testing.T) { Unscheduled []models.Atom Upcoming []ScheduledItem Boards []models.Board + Projects []models.Project Today string }) if !ok { diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go index 86b89ea..bbdae51 100644 --- a/internal/handlers/timeline.go +++ b/internal/handlers/timeline.go @@ -70,7 +70,7 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) { } // Call BuildTimeline - items, err := BuildTimeline(r.Context(), h.store, h.googleTasksClient, start, end) + items, err := BuildTimeline(r.Context(), h.store, start, end) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to build timeline", err) return diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index 145e851..8430ee9 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -7,14 +7,13 @@ import ( "strings" "time" - "task-dashboard/internal/api" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // BuildTimeline aggregates and normalizes data into a timeline structure -func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTasksAPI, start, end time.Time) ([]models.TimelineItem, error) { +func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([]models.TimelineItem, error) { var items []models.TimelineItem now := config.Now() @@ -145,37 +144,32 @@ func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTa } } - // 5. Fetch Google Tasks - if tasksClient != nil { - gTasks, err := tasksClient.GetTasksByDateRange(ctx, start, end) - if err != nil { - log.Printf("Warning: failed to fetch Google Tasks: %v", err) - } else { - log.Printf("Google Tasks: fetched %d tasks in date range", len(gTasks)) - for _, gTask := range gTasks { - // Tasks without due date are placed in today section - taskTime := now - if gTask.DueDate != nil { - taskTime = *gTask.DueDate - } - item := models.TimelineItem{ - ID: gTask.ID, - Type: models.TimelineItemTypeGTask, - Title: gTask.Title, - Time: taskTime, - Description: gTask.Notes, - URL: gTask.URL, - OriginalItem: gTask, - IsCompleted: gTask.Completed, - Source: "gtasks", - ListID: gTask.ListID, - } - item.ComputeDaySection(now) - items = append(items, item) + // 5. Fetch Google Tasks from store cache + gTasks, err := s.GetGoogleTasksByDateRange(start, end) + if err != nil { + log.Printf("Warning: failed to read cached Google Tasks: %v", err) + } else { + for _, gTask := range gTasks { + // Tasks without due date are placed in today section + taskTime := now + if gTask.DueDate != nil { + taskTime = *gTask.DueDate + } + item := models.TimelineItem{ + ID: gTask.ID, + Type: models.TimelineItemTypeGTask, + Title: gTask.Title, + Time: taskTime, + Description: gTask.Notes, + URL: gTask.URL, + OriginalItem: gTask, + IsCompleted: gTask.Completed, + Source: "gtasks", + ListID: gTask.ListID, } + item.ComputeDaySection(now) + items = append(items, item) } - } else { - log.Printf("Google Tasks client not configured") } // Sort items by Time diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 8104a96..d6959da 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -90,6 +90,17 @@ func setupTestStore(t *testing.T) *store.Store { html_link TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS google_tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT 0, + due_date DATETIME, + updated_at DATETIME, + list_id TEXT NOT NULL, + url TEXT + ); ` if err := os.WriteFile(filepath.Join(migrationDir, "001_init.sql"), []byte(schema), 0644); err != nil { t.Fatalf("Failed to write migration file: %v", err) @@ -143,7 +154,7 @@ func TestBuildTimeline(t *testing.T) { 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) + items, err := BuildTimeline(context.Background(), s, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } @@ -277,7 +288,7 @@ func TestBuildTimeline_IncludesOverdueItems(t *testing.T) { // Query range: today through tomorrow end := today.AddDate(0, 0, 1) - items, err := BuildTimeline(context.Background(), s, nil,today, end) + items, err := BuildTimeline(context.Background(), s, today, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } @@ -320,7 +331,7 @@ func TestBuildTimeline_ExcludesCompletedOverdue(t *testing.T) { {ID: "done1", Content: "Done overdue", DueDate: &yesterday, Completed: true}, }) - items, err := BuildTimeline(context.Background(), s, nil,today, end) + items, err := BuildTimeline(context.Background(), s, today, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } @@ -348,7 +359,7 @@ func TestBuildTimeline_ReadsCalendarEventsFromStore(t *testing.T) { } // Call BuildTimeline with NO calendar client (nil) — events should come from store - items, err := BuildTimeline(context.Background(), s, nil,start, end) + items, err := BuildTimeline(context.Background(), s, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } @@ -473,7 +484,7 @@ func TestBuildTimeline_IncludesUndatedTodoistTasks(t *testing.T) { 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) + items, err := BuildTimeline(context.Background(), s, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } -- cgit v1.2.3