summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-26 20:55:50 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-26 20:55:50 -1000
commita3156a2f399ea03c645ee23b0099d9d722ce7e1e (patch)
tree03c813717e77ae27d8aee9e676f1b75a6a01648c
parent70e6e51b6781a3986c51e3496b81c88665286872 (diff)
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 <noreply@anthropic.com>
-rw-r--r--cmd/dashboard/main.go17
-rw-r--r--internal/api/google_tasks.go173
-rw-r--r--internal/api/interfaces.go9
-rw-r--r--internal/config/config.go11
-rw-r--r--internal/handlers/handlers.go20
-rw-r--r--internal/handlers/tab_state_test.go2
-rw-r--r--internal/handlers/timeline.go2
-rw-r--r--internal/handlers/timeline_logic.go29
-rw-r--r--internal/handlers/timeline_logic_test.go2
-rw-r--r--internal/models/timeline.go14
-rw-r--r--internal/models/types.go13
-rw-r--r--test/acceptance_test.go2
-rw-r--r--web/templates/partials/timeline-tab.html8
13 files changed, 285 insertions, 17 deletions
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 @@
<div class="bg-card bg-card-hover transition-colors rounded-lg border border-white/5 relative {{if .IsCompleted}}opacity-60{{end}}">
<!-- Time Indicator -->
<div class="absolute -left-[21px] top-4 w-2.5 h-2.5 rounded-full
- {{if eq .Type "event"}}bg-blue-500{{else if eq .Type "meal"}}bg-orange-500{{else if eq .Type "task"}}bg-green-500{{else}}bg-purple-500{{end}}">
+ {{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}}">
</div>
<div class="flex items-start gap-3 p-3">
- <!-- Checkbox for tasks/cards -->
- {{if or (eq .Type "task") (eq .Type "card")}}
+ <!-- Checkbox for tasks/cards/gtasks -->
+ {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}}
<input type="checkbox"
{{if .IsCompleted}}checked{{end}}
hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}"
- hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}'
+ hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}'
hx-target="closest div.rounded-lg"
hx-swap="outerHTML"
class="mt-1 h-5 w-5 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">