diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-29 08:53:48 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-29 08:53:48 +0000 |
| commit | 3fbfdd1f9441299eb146bf215449f238977deb66 (patch) | |
| tree | a01d0cd35309afbab6cc8989e26e2e824442c898 /internal | |
| parent | de3e37cca4f34a30f9c0beef05f67c3d515d64c2 (diff) | |
feat: native task management + Todoist migration
Adds doot-owned task storage (native_tasks table) so tasks can be
managed without Todoist. CompleteTask for 'doot' source just updates
the DB — no external API call, no token dependency.
Migration path:
POST /settings/import-from-todoist — copies Todoist cache → native_tasks
Then remove TODOIST_TOKEN from .env to disable Todoist
Changes:
- migration 020: native_tasks table
- store: GetNativeTasks, GetNativeTasksByDateRange, GetUndatedNativeTasks,
CreateNativeTask, CompleteNativeTask, UncompleteNativeTask,
UpdateNativeTask, ImportFromTodoist
- timeline: native tasks appear as source="doot" (teal)
- handleAtomToggle: "doot" case — no external API needed
- HandleWidgetComplete: method on Handler, handles "doot" natively
- HandleUnifiedAdd: "doot" source creates in native_tasks
- widget: "doot" tasks are completable, teal color indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/handlers/handlers.go | 60 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 45 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 37 | ||||
| -rw-r--r-- | internal/store/native_tasks.go | 135 |
4 files changed, 261 insertions, 16 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 0903cf3..877293a 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "crypto/rand" "fmt" "html/template" "log" @@ -19,6 +20,13 @@ import ( "task-dashboard/internal/store" ) +// newID generates a random hex ID for native tasks. +func newID() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return fmt.Sprintf("%x", b) +} + // Handler holds dependencies for HTTP handlers type Handler struct { store *store.Store @@ -597,6 +605,12 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl var err error switch source { + case "doot": + if complete { + err = h.store.CompleteNativeTask(id) + } else { + err = h.store.UncompleteNativeTask(id) + } case "todoist": if complete { err = h.todoistClient.CompleteTask(ctx, id) @@ -648,6 +662,8 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl _ = h.store.DeleteTask(id) case "trello": _ = h.store.DeleteCard(id) + case "doot": + // native tasks stay in DB marked completed; no cache to invalidate } // Return completed task HTML with uncomplete option @@ -675,6 +691,14 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl // getAtomDetails retrieves title and due date for a task/card/bug from the store func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { switch source { + case "doot": + if tasks, err := h.store.GetNativeTasks(); err == nil { + for _, t := range tasks { + if t.ID == id { + return t.Content, t.DueDate + } + } + } case "todoist": if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { @@ -694,7 +718,6 @@ func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { } } case "gtasks": - // Google Tasks don't have local cache, return generic title return "Google Task", nil } return "Task", nil @@ -726,6 +749,19 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { } switch source { + case "doot": + task := models.Task{ + ID: newID(), + Content: title, + DueDate: dueDate, + Priority: 1, + Labels: nil, + } + if err := h.store.CreateNativeTask(task); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create task", err) + return + } + case "todoist": projectID := r.FormValue("project_id") if _, err := h.todoistClient.CreateTask(ctx, title, projectID, dueDate, 1); err != nil { @@ -826,6 +862,15 @@ func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) { var title, description string switch source { + case "doot": + if tasks, err := h.store.GetNativeTasks(); err == nil { + for _, t := range tasks { + if t.ID == id { + title, description = t.Content, t.Description + break + } + } + } case "todoist": if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { @@ -884,6 +929,8 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { var err error switch source { + case "doot": + err = h.store.UpdateNativeTask(id, "", description) case "todoist": err = h.todoistClient.UpdateTask(r.Context(), id, map[string]interface{}{"description": description}) case "trello": @@ -905,6 +952,17 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { } } +// HandleImportFromTodoist copies the current Todoist task cache into native_tasks. +// One-time migration action. Safe to run multiple times (INSERT OR IGNORE by ID). +func (h *Handler) HandleImportFromTodoist(w http.ResponseWriter, r *http.Request) { + count, err := h.store.ImportFromTodoist() + if err != nil { + http.Error(w, "Import failed: "+err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "Imported %d tasks from Todoist. You can now remove TODOIST_TOKEN from your .env to disable Todoist.", count) +} + // 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, h.claudomatorClient) diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index 8430ee9..c9510bf 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -172,6 +172,51 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ } } + // 6. Fetch native (doot-owned) tasks + nativeDated, err := s.GetNativeTasksByDateRange(start, end) + if err != nil { + log.Printf("Warning: failed to read native tasks: %v", err) + } else { + for _, task := range nativeDated { + if task.DueDate == nil { + continue + } + item := models.TimelineItem{ + ID: task.ID, + Type: models.TimelineItemTypeTask, + Title: task.Content, + Time: *task.DueDate, + Description: task.Description, + OriginalItem: task, + IsCompleted: task.Completed, + Source: "doot", + } + item.ComputeDaySection(now) + items = append(items, item) + } + } + + nativeUndated, err := s.GetUndatedNativeTasks() + if err != nil { + log.Printf("Warning: failed to read undated native tasks: %v", err) + } else { + for _, task := range nativeUndated { + item := models.TimelineItem{ + ID: task.ID, + Type: models.TimelineItemTypeTask, + Title: task.Content, + Time: now, + Description: task.Description, + OriginalItem: task, + IsCompleted: task.Completed, + Source: "doot", + IsAllDay: true, + } + 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/widget.go b/internal/handlers/widget.go index 00efc75..360ccc5 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "task-dashboard/internal/api" "task-dashboard/internal/config" "task-dashboard/internal/models" ) @@ -48,7 +47,7 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { // not completable via widget API default: wi.Type = "task" - wi.Completable = item.Source == "todoist" + wi.Completable = item.Source == "todoist" || item.Source == "doot" } // Only populate Start/End for items with a real time (non-all-day, non-zero) @@ -102,26 +101,34 @@ type widgetCompleteRequest struct { Source string `json:"source"` } -// HandleWidgetComplete proxies a task completion to the source API. -func HandleWidgetComplete(todoistClient api.TodoistAPI) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req widgetCompleteRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.Source != "todoist" { - http.Error(w, "source not completable", http.StatusBadRequest) +// HandleWidgetComplete proxies a task completion to the source API or native store. +func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { + var req widgetCompleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + switch req.Source { + case "doot": + if err := h.store.CompleteNativeTask(req.ID); err != nil { + http.Error(w, "failed to complete task", http.StatusInternalServerError) return } - if todoistClient == nil { + _ = h.store.SaveCompletedTask("doot", req.ID, "", nil) + case "todoist": + if h.todoistClient == nil { http.Error(w, "todoist not configured", http.StatusServiceUnavailable) return } - if err := todoistClient.CompleteTask(r.Context(), req.ID); err != nil { + if err := h.todoistClient.CompleteTask(r.Context(), req.ID); err != nil { http.Error(w, "failed to complete task", http.StatusInternalServerError) return } - w.WriteHeader(http.StatusOK) + default: + http.Error(w, "source not completable", http.StatusBadRequest) + return } + + w.WriteHeader(http.StatusOK) } diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go new file mode 100644 index 0000000..3ec043b --- /dev/null +++ b/internal/store/native_tasks.go @@ -0,0 +1,135 @@ +package store + +import ( + "encoding/json" + "log" + "time" + + "task-dashboard/internal/models" +) + +// GetNativeTasks returns all non-completed native tasks. +func (s *Store) GetNativeTasks() ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at + FROM native_tasks + WHERE completed = 0 + ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, priority DESC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanNativeTasks(rows) +} + +// GetNativeTasksByDateRange returns non-completed native tasks due within the given range. +func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at + FROM native_tasks + WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? + ORDER BY due_date ASC, priority DESC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanNativeTasks(rows) +} + +// GetUndatedNativeTasks returns non-completed native tasks with no due date. +func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at + FROM native_tasks + WHERE completed = 0 AND due_date IS NULL + ORDER BY priority DESC, created_at ASC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanNativeTasks(rows) +} + +// CreateNativeTask inserts a new native task. +func (s *Store) CreateNativeTask(task models.Task) error { + labelsJSON, _ := json.Marshal(task.Labels) + _, err := s.db.Exec(` + INSERT INTO native_tasks (id, content, description, project_name, due_date, priority, labels, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, task.ID, task.Content, task.Description, task.ProjectName, task.DueDate, task.Priority, string(labelsJSON)) + return err +} + +// UpdateNativeTask updates a native task's content and description. +func (s *Store) UpdateNativeTask(id, content, description string) error { + _, err := s.db.Exec(` + UPDATE native_tasks SET content = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, content, description, id) + return err +} + +// CompleteNativeTask marks a task as completed. +func (s *Store) CompleteNativeTask(id string) error { + _, err := s.db.Exec(` + UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, id) + return err +} + +// UncompleteNativeTask marks a task as not completed. +func (s *Store) UncompleteNativeTask(id string) error { + _, err := s.db.Exec(` + UPDATE native_tasks SET completed = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, id) + return err +} + +// ImportFromTodoist copies all incomplete tasks from the Todoist cache into native_tasks. +// Skips tasks already present (by ID). Returns the count of newly imported tasks. +func (s *Store) ImportFromTodoist() (int, error) { + result, err := s.db.Exec(` + INSERT OR IGNORE INTO native_tasks + (id, content, description, project_name, due_date, priority, completed, labels, created_at, updated_at) + SELECT + id, content, COALESCE(description, ''), COALESCE(project_name, ''), + due_date, priority, 0, COALESCE(labels, '[]'), created_at, CURRENT_TIMESTAMP + FROM tasks + WHERE completed = 0 + `) + if err != nil { + return 0, err + } + n, _ := result.RowsAffected() + log.Printf("ImportFromTodoist: imported %d tasks", n) + return int(n), nil +} + +func scanNativeTasks(rows interface{ Next() bool; Scan(...interface{}) error; Err() error }) ([]models.Task, error) { + var tasks []models.Task + for rows.Next() { + var t models.Task + var labelsJSON string + var dueDateStr *string + if err := rows.Scan(&t.ID, &t.Content, &t.Description, &t.ProjectName, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt); err != nil { + return nil, err + } + if dueDateStr != nil { + if parsed, err := time.Parse(time.RFC3339, *dueDateStr); err == nil { + t.DueDate = &parsed + } else if parsed, err := time.Parse("2006-01-02 15:04:05", *dueDateStr); err == nil { + t.DueDate = &parsed + } else if parsed, err := time.Parse("2006-01-02", *dueDateStr); err == nil { + t.DueDate = &parsed + } + } + if err := json.Unmarshal([]byte(labelsJSON), &t.Labels); err != nil { + t.Labels = nil + } + tasks = append(tasks, t) + } + return tasks, rows.Err() +} |
