summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt1
-rw-r--r--cmd/dashboard/main.go5
-rw-r--r--internal/handlers/handlers.go60
-rw-r--r--internal/handlers/timeline_logic.go45
-rw-r--r--internal/handlers/widget.go37
-rw-r--r--internal/store/native_tasks.go135
-rw-r--r--migrations/020_native_tasks.sql17
7 files changed, 283 insertions, 17 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
index 72fcccc..5d27fb8 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
@@ -29,6 +29,7 @@ import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
fun sourceColor(source: String): Color = when (source) {
+ "doot" -> Color(0xFF14B8A6)
"todoist" -> Color(0xFFE44332)
"trello" -> Color(0xFF0079BF)
"calendar" -> Color(0xFF9B59B6)
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 92a149b..c154d9b 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -342,6 +342,9 @@ func main() {
r.Post("/passkeys/register/finish", authHandlers.HandlePasskeyRegisterFinish)
r.Delete("/passkeys/{id}", authHandlers.HandleDeletePasskey)
+ // Native task management
+ r.Post("/settings/import-from-todoist", h.HandleImportFromTodoist)
+
// Settings
r.Get("/settings", h.HandleSettingsPage)
r.Post("/settings/sync", h.HandleSyncSources)
@@ -362,7 +365,7 @@ func main() {
return handlers.WidgetAuthMiddleware(cfg.WidgetToken, next)
}
r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet)
- r.With(widgetAuth).Post("/api/widget/complete", handlers.HandleWidgetComplete(todoistClient))
+ r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete)
} else {
log.Println("WIDGET_TOKEN not set — /api/widget disabled")
}
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()
+}
diff --git a/migrations/020_native_tasks.sql b/migrations/020_native_tasks.sql
new file mode 100644
index 0000000..9abf3ad
--- /dev/null
+++ b/migrations/020_native_tasks.sql
@@ -0,0 +1,17 @@
+-- Native tasks table: doot-owned task storage, not a sync cache.
+-- Populated by importing from Todoist, then managed entirely within doot.
+CREATE TABLE IF NOT EXISTS native_tasks (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ project_name TEXT DEFAULT '',
+ due_date DATETIME,
+ priority INTEGER DEFAULT 1,
+ completed BOOLEAN DEFAULT 0,
+ labels TEXT DEFAULT '[]',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_native_tasks_due_date ON native_tasks(due_date);
+CREATE INDEX IF NOT EXISTS idx_native_tasks_completed ON native_tasks(completed);