From a3156a2f399ea03c645ee23b0099d9d722ce7e1e Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 26 Jan 2026 20:55:50 -1000 Subject: Add Google Tasks integration (#43) - New GoogleTasksClient for fetching and managing Google Tasks - Tasks appear in Timeline view with yellow indicator dot - Tap checkbox to complete/uncomplete tasks via Google API - Shares credentials file with Google Calendar (GOOGLE_CREDENTIALS_FILE) - Configure task list via GOOGLE_TASKS_LIST_ID env var (default: @default) - Supports comma-separated list IDs for multiple lists New files: - internal/api/google_tasks.go - Google Tasks API client Co-Authored-By: Claude Opus 4.5 --- cmd/dashboard/main.go | 17 ++- internal/api/google_tasks.go | 173 +++++++++++++++++++++++++++++++ internal/api/interfaces.go | 9 ++ internal/config/config.go | 11 ++ internal/handlers/handlers.go | 20 +++- internal/handlers/tab_state_test.go | 2 +- internal/handlers/timeline.go | 2 +- internal/handlers/timeline_logic.go | 29 +++++- internal/handlers/timeline_logic_test.go | 2 +- internal/models/timeline.go | 14 ++- internal/models/types.go | 13 +++ test/acceptance_test.go | 2 +- web/templates/partials/timeline-tab.html | 8 +- 13 files changed, 285 insertions(+), 17 deletions(-) create mode 100644 internal/api/google_tasks.go diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index a02ece4..920ccf2 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -106,8 +106,21 @@ func main() { } } - // Initia lize handlers - h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, cfg) + var googleTasksClient api.GoogleTasksAPI + if cfg.HasGoogleTasks() { + initCtx, cancel := context.WithTimeout(context.Background(), config.GoogleCalendarInitTimeout) + var err error + googleTasksClient, err = api.NewGoogleTasksClient(initCtx, cfg.GoogleCredentialsFile, cfg.GoogleTasksListID, cfg.Timezone) + cancel() + if err != nil { + log.Printf("Warning: failed to initialize Google Tasks client: %v", err) + } else { + log.Printf("Google Tasks client initialized for list: %s", cfg.GoogleTasksListID) + } + } + + // Initialize handlers + h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, googleTasksClient, cfg) // Set up router r := chi.NewRouter() diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go new file mode 100644 index 0000000..0b4d7c2 --- /dev/null +++ b/internal/api/google_tasks.go @@ -0,0 +1,173 @@ +package api + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "task-dashboard/internal/models" + + "google.golang.org/api/option" + "google.golang.org/api/tasks/v1" +) + +// GoogleTasksClient handles interactions with Google Tasks API +type GoogleTasksClient struct { + srv *tasks.Service + tasklistID string + displayTZ *time.Location +} + +// NewGoogleTasksClient creates a client for Google Tasks. +// tasklistID can be "@default" for the primary list, or a specific list ID. +// Multiple lists can be comma-separated. +func NewGoogleTasksClient(ctx context.Context, credentialsFile, tasklistID, timezone string) (*GoogleTasksClient, error) { + srv, err := tasks.NewService(ctx, option.WithCredentialsFile(credentialsFile)) + if err != nil { + return nil, fmt.Errorf("unable to create Tasks client: %v", err) + } + + if tasklistID == "" { + tasklistID = "@default" + } + + // Load display timezone + displayTZ, err := time.LoadLocation(timezone) + if err != nil { + log.Printf("Warning: invalid timezone %q, using UTC: %v", timezone, err) + displayTZ = time.UTC + } + + return &GoogleTasksClient{ + srv: srv, + tasklistID: tasklistID, + displayTZ: displayTZ, + }, nil +} + +// GetTasks fetches all incomplete tasks from the configured task list(s) +func (c *GoogleTasksClient) GetTasks(ctx context.Context) ([]models.GoogleTask, error) { + var allTasks []models.GoogleTask + + // Support comma-separated list IDs + listIDs := strings.Split(c.tasklistID, ",") + for _, listID := range listIDs { + listID = strings.TrimSpace(listID) + if listID == "" { + continue + } + + tasks, err := c.getTasksFromList(ctx, listID) + if err != nil { + log.Printf("Warning: failed to fetch tasks from list %s: %v", listID, err) + continue + } + allTasks = append(allTasks, tasks...) + } + + return allTasks, nil +} + +// getTasksFromList fetches tasks from a specific list +func (c *GoogleTasksClient) getTasksFromList(ctx context.Context, listID string) ([]models.GoogleTask, error) { + call := c.srv.Tasks.List(listID). + ShowCompleted(false). + ShowHidden(false). + MaxResults(100) + + taskList, err := call.Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("failed to list tasks: %v", err) + } + + var result []models.GoogleTask + for _, item := range taskList.Items { + task := models.GoogleTask{ + ID: item.Id, + Title: item.Title, + Notes: item.Notes, + Status: item.Status, + Completed: item.Status == "completed", + ListID: listID, + } + + // Parse due date if present (RFC3339 format, but date-only) + if item.Due != "" { + // Google Tasks due dates are in RFC3339 format but typically just the date part + dueDate, err := time.Parse(time.RFC3339, item.Due) + if err != nil { + // Try date-only format + dueDate, err = time.ParseInLocation("2006-01-02", item.Due[:10], c.displayTZ) + } + if err == nil { + dueInTZ := dueDate.In(c.displayTZ) + task.DueDate = &dueInTZ + } + } + + // Parse updated time + if item.Updated != "" { + if updated, err := time.Parse(time.RFC3339, item.Updated); err == nil { + task.UpdatedAt = updated.In(c.displayTZ) + } + } + + // Build URL to Google Tasks + task.URL = fmt.Sprintf("https://tasks.google.com/embed/?origin=https://mail.google.com&fullWidth=1") + + result = append(result, task) + } + + return result, nil +} + +// GetTasksByDateRange fetches tasks with due dates in the specified range +func (c *GoogleTasksClient) GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error) { + allTasks, err := c.GetTasks(ctx) + if err != nil { + return nil, err + } + + // Filter by date range + var filtered []models.GoogleTask + for _, task := range allTasks { + if task.DueDate == nil { + continue + } + dueDay := time.Date(task.DueDate.Year(), task.DueDate.Month(), task.DueDate.Day(), 0, 0, 0, 0, c.displayTZ) + startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, c.displayTZ) + endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, c.displayTZ) + + if !dueDay.Before(startDay) && dueDay.Before(endDay) { + filtered = append(filtered, task) + } + } + + return filtered, nil +} + +// CompleteTask marks a task as completed +func (c *GoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID string) error { + task := &tasks.Task{ + Status: "completed", + } + _, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to complete task: %v", err) + } + return nil +} + +// UncompleteTask marks a task as not completed +func (c *GoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error { + task := &tasks.Task{ + Status: "needsAction", + } + _, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to uncomplete task: %v", err) + } + return nil +} diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 70bba1f..aa351ab 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -42,10 +42,19 @@ type GoogleCalendarAPI interface { GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) } +// GoogleTasksAPI defines the interface for Google Tasks operations +type GoogleTasksAPI interface { + GetTasks(ctx context.Context) ([]models.GoogleTask, error) + GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error) + CompleteTask(ctx context.Context, listID, taskID string) error + UncompleteTask(ctx context.Context, listID, taskID string) error +} + // Ensure concrete types implement interfaces var ( _ TodoistAPI = (*TodoistClient)(nil) _ TrelloAPI = (*TrelloClient)(nil) _ PlanToEatAPI = (*PlanToEatClient)(nil) _ GoogleCalendarAPI = (*GoogleCalendarClient)(nil) + _ GoogleTasksAPI = (*GoogleTasksClient)(nil) ) diff --git a/internal/config/config.go b/internal/config/config.go index cf3af49..2d77025 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,9 @@ type Config struct { GoogleCredentialsFile string GoogleCalendarID string + // Google Tasks + GoogleTasksListID string + // Paths DatabasePath string MigrationDir string @@ -48,6 +51,9 @@ func Load() (*Config, error) { GoogleCredentialsFile: os.Getenv("GOOGLE_CREDENTIALS_FILE"), GoogleCalendarID: getEnvWithDefault("GOOGLE_CALENDAR_ID", "primary"), + // Google Tasks + GoogleTasksListID: getEnvWithDefault("GOOGLE_TASKS_LIST_ID", "@default"), + // Paths DatabasePath: getEnvWithDefault("DATABASE_PATH", "./dashboard.db"), MigrationDir: getEnvWithDefault("MIGRATION_DIR", "migrations"), @@ -109,6 +115,11 @@ func (c *Config) HasGoogleCalendar() bool { return c.GoogleCredentialsFile != "" } +// HasGoogleTasks checks if Google Tasks is configured +func (c *Config) HasGoogleTasks() bool { + return c.GoogleCredentialsFile != "" && c.GoogleTasksListID != "" +} + // getEnvWithDefault returns environment variable value or default if not set func getEnvWithDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 115d903..0424e40 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -29,12 +29,13 @@ type Handler struct { trelloClient api.TrelloAPI planToEatClient api.PlanToEatAPI googleCalendarClient api.GoogleCalendarAPI + googleTasksClient api.GoogleTasksAPI config *config.Config templates *template.Template } // New creates a new Handler instance -func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, cfg *config.Config) *Handler { +func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, cfg *config.Config) *Handler { // Parse templates including partials tmpl, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) if err != nil { @@ -53,6 +54,7 @@ func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat trelloClient: trello, planToEatClient: planToEat, googleCalendarClient: googleCalendar, + googleTasksClient: googleTasks, config: cfg, templates: tmpl, } @@ -640,6 +642,22 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl } else { err = h.store.UnresolveBug(bugID) } + case "gtasks": + // Google Tasks - need list ID from form or use default + listID := r.FormValue("listId") + if listID == "" { + listID = "@default" + } + if h.googleTasksClient != nil { + if complete { + err = h.googleTasksClient.CompleteTask(ctx, listID, id) + } else { + err = h.googleTasksClient.UncompleteTask(ctx, listID, id) + } + } else { + JSONError(w, http.StatusServiceUnavailable, "Google Tasks not configured", nil) + return + } default: JSONError(w, http.StatusBadRequest, "Unknown source: "+source, nil) return diff --git a/internal/handlers/tab_state_test.go b/internal/handlers/tab_state_test.go index 71c6ed8..b95843e 100644 --- a/internal/handlers/tab_state_test.go +++ b/internal/handlers/tab_state_test.go @@ -30,7 +30,7 @@ func TestHandleDashboard_TabState(t *testing.T) { } // Create handler - h := New(db, todoistClient, trelloClient, nil, nil, cfg) + h := New(db, todoistClient, trelloClient, nil, nil, nil, cfg) // Skip if templates are not loaded (test environment issue) if h.templates == nil { diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go index 37e688f..5e583d6 100644 --- a/internal/handlers/timeline.go +++ b/internal/handlers/timeline.go @@ -51,7 +51,7 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) { end := start.AddDate(0, 0, days) // Call BuildTimeline - items, err := BuildTimeline(r.Context(), h.store, h.googleCalendarClient, start, end) + items, err := BuildTimeline(r.Context(), h.store, h.googleCalendarClient, h.googleTasksClient, 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 553593d..5ea44b5 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -13,7 +13,7 @@ import ( ) // BuildTimeline aggregates and normalizes data into a timeline structure -func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.GoogleCalendarAPI, start, end time.Time) ([]models.TimelineItem, error) { +func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.GoogleCalendarAPI, tasksClient api.GoogleTasksAPI, start, end time.Time) ([]models.TimelineItem, error) { var items []models.TimelineItem now := config.Now() @@ -151,6 +151,33 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl } } + // 5. Fetch Google Tasks + if tasksClient != nil { + gTasks, err := tasksClient.GetTasksByDateRange(ctx, start, end) + if err == nil { + for _, gTask := range gTasks { + taskTime := start // Default to start of range if no due date + 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) + } + } + } + // Sort items by Time sort.Slice(items, func(i, j int) bool { return items[i].Time.Before(items[j].Time) diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 038f836..5d0a425 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -130,7 +130,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, mockCal, start, end) + items, err := BuildTimeline(context.Background(), s, mockCal, nil, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } diff --git a/internal/models/timeline.go b/internal/models/timeline.go index 3475696..0968d41 100644 --- a/internal/models/timeline.go +++ b/internal/models/timeline.go @@ -9,10 +9,11 @@ import ( type TimelineItemType string const ( - TimelineItemTypeTask TimelineItemType = "task" - TimelineItemTypeMeal TimelineItemType = "meal" - TimelineItemTypeCard TimelineItemType = "card" - TimelineItemTypeEvent TimelineItemType = "event" + TimelineItemTypeTask TimelineItemType = "task" + TimelineItemTypeMeal TimelineItemType = "meal" + TimelineItemTypeCard TimelineItemType = "card" + TimelineItemTypeEvent TimelineItemType = "event" + TimelineItemTypeGTask TimelineItemType = "gtask" // Google Tasks ) type DaySection string @@ -36,7 +37,10 @@ type TimelineItem struct { // UI enhancement fields IsCompleted bool `json:"is_completed"` DaySection DaySection `json:"day_section"` - Source string `json:"source"` // "todoist", "trello", "plantoeat", "calendar" + Source string `json:"source"` // "todoist", "trello", "plantoeat", "calendar", "gtasks" + + // Source-specific metadata + ListID string `json:"list_id,omitempty"` // For Google Tasks } // ComputeDaySection sets the DaySection based on the item's time diff --git a/internal/models/types.go b/internal/models/types.go index 6dc8716..4bf8462 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -101,6 +101,19 @@ type CalendarEvent struct { HTMLLink string `json:"html_link"` } +// GoogleTask represents a task from Google Tasks +type GoogleTask struct { + ID string `json:"id"` + Title string `json:"title"` + Notes string `json:"notes,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + Status string `json:"status"` // "needsAction" or "completed" + Completed bool `json:"completed"` + ListID string `json:"list_id"` + URL string `json:"url"` + UpdatedAt time.Time `json:"updated_at"` +} + // Bug represents a bug report type Bug struct { ID int64 `json:"id"` diff --git a/test/acceptance_test.go b/test/acceptance_test.go index 5d5b09f..c93090b 100644 --- a/test/acceptance_test.go +++ b/test/acceptance_test.go @@ -82,7 +82,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *store.Store, *http.Client } // Initialize handlers - h := handlers.New(db, todoistClient, trelloClient, nil, nil, cfg) + h := handlers.New(db, todoistClient, trelloClient, nil, nil, nil, cfg) // Set up router (same as main.go) r := chi.NewRouter() diff --git a/web/templates/partials/timeline-tab.html b/web/templates/partials/timeline-tab.html index 8486ab7..22cdd38 100644 --- a/web/templates/partials/timeline-tab.html +++ b/web/templates/partials/timeline-tab.html @@ -68,16 +68,16 @@
+ {{if eq .Type "event"}}bg-blue-500{{else if eq .Type "meal"}}bg-orange-500{{else if eq .Type "task"}}bg-green-500{{else if eq .Type "gtask"}}bg-yellow-500{{else}}bg-purple-500{{end}}">
- - {{if or (eq .Type "task") (eq .Type "card")}} + + {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} -- cgit v1.2.3