package api import ( "context" "fmt" "strings" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" ) const ( todoistBaseURL = "https://api.todoist.com/api/v1" ) // TodoistClient handles interactions with the Todoist API type TodoistClient struct { BaseClient apiKey string } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ BaseClient: NewBaseClient(todoistBaseURL), apiKey: apiKey, } } func (c *TodoistClient) authHeaders() map[string]string { return map[string]string{"Authorization": "Bearer " + c.apiKey} } // todoistTaskResponse represents the API response structure type todoistTaskResponse struct { ID string `json:"id"` Content string `json:"content"` Description string `json:"description"` ProjectID string `json:"project_id"` Priority int `json:"priority"` Labels []string `json:"labels"` Due *dueInfo `json:"due"` URL string `json:"url"` CreatedAt string `json:"created_at"` } // todoistProjectResponse represents the project API response type todoistProjectResponse struct { ID string `json:"id"` Name string `json:"name"` } // todoistTasksPage represents the paginated response from the Todoist REST API v1 type todoistTasksPage struct { Results []todoistTaskResponse `json:"results"` NextCursor *string `json:"next_cursor"` } // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { var allTasks []todoistTaskResponse cursor := "" for { path := "/tasks" if cursor != "" { path = "/tasks?cursor=" + cursor } var page todoistTasksPage if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { return nil, fmt.Errorf("failed to fetch tasks: %w", err) } allTasks = append(allTasks, page.Results...) if page.NextCursor == nil || *page.NextCursor == "" { break } cursor = *page.NextCursor } apiTasks := allTasks // Fetch projects to get project names projects, err := c.GetProjects(ctx) projectMap := make(map[string]string) if err == nil { for _, proj := range projects { projectMap[proj.ID] = proj.Name } } // Convert to our model tasks := make([]models.Task, 0, len(apiTasks)) for _, apiTask := range apiTasks { task := models.Task{ ID: apiTask.ID, Content: apiTask.Content, Description: apiTask.Description, ProjectID: apiTask.ProjectID, ProjectName: projectMap[apiTask.ProjectID], Priority: apiTask.Priority, Completed: false, Labels: apiTask.Labels, URL: apiTask.URL, } if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } task.DueDate = parseDueDate(apiTask.Due) if apiTask.Due != nil { task.IsRecurring = apiTask.Due.IsRecurring } tasks = append(tasks, task) } return tasks, nil } // todoistProjectsPage represents the paginated response for projects type todoistProjectsPage struct { Results []todoistProjectResponse `json:"results"` NextCursor *string `json:"next_cursor"` } // GetProjects fetches all projects func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { var allProjects []todoistProjectResponse cursor := "" for { path := "/projects" if cursor != "" { path = "/projects?cursor=" + cursor } var page todoistProjectsPage if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { return nil, fmt.Errorf("failed to fetch projects: %w", err) } allProjects = append(allProjects, page.Results...) if page.NextCursor == nil || *page.NextCursor == "" { break } cursor = *page.NextCursor } apiProjects := allProjects projects := make([]models.Project, 0, len(apiProjects)) for _, apiProj := range apiProjects { projects = append(projects, models.Project{ ID: apiProj.ID, Name: apiProj.Name, }) } return projects, nil } // CreateTask creates a new task in Todoist func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) { payload := map[string]interface{}{"content": content} if projectID != "" { payload["project_id"] = projectID } if dueDate != nil { payload["due_date"] = dueDate.Format("2006-01-02") } if priority > 0 { payload["priority"] = priority } var apiTask todoistTaskResponse if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil { return nil, fmt.Errorf("failed to create task: %w", err) } task := &models.Task{ ID: apiTask.ID, Content: apiTask.Content, Description: apiTask.Description, ProjectID: apiTask.ProjectID, Priority: apiTask.Priority, Completed: false, Labels: apiTask.Labels, URL: apiTask.URL, } if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } task.DueDate = parseDueDate(apiTask.Due) return task, nil } // UpdateTask updates a task with the specified changes func (c *TodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { path := fmt.Sprintf("/tasks/%s", taskID) if err := c.Post(ctx, path, c.authHeaders(), updates, nil); err != nil { return fmt.Errorf("failed to update task: %w", err) } return nil } // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { path := fmt.Sprintf("/tasks/%s/close", taskID) if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { return fmt.Errorf("failed to complete task: %w", err) } return nil } // ReopenTask marks a completed task as active in Todoist func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { path := fmt.Sprintf("/tasks/%s/reopen", taskID) if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { return fmt.Errorf("failed to reopen task: %w", err) } return nil } // parseDueDate parses due date from API response // dueInfo represents the due date structure from Todoist API type dueInfo struct { Date string `json:"date"` Datetime string `json:"datetime"` IsRecurring bool `json:"is_recurring"` } func parseDueDate(due *dueInfo) *time.Time { if due == nil { return nil } var dueDate time.Time var err error if due.Datetime != "" { // RFC3339 includes timezone, then convert to display timezone dueDate, err = time.Parse(time.RFC3339, due.Datetime) if err == nil { dueDate = config.ToDisplayTZ(dueDate) } } else if 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 } return &dueDate }