summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-23 16:10:52 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-23 16:10:52 -1000
commit7828e19501b3ca8b2e86ca7297f580c659e5c9b8 (patch)
treee3113af67fcecc459d81b213d7f043630dccae98 /internal/handlers
parentd11334c0999efb670a8eab93527a50f644fdfceb (diff)
Fix bugs #24-27: calendar dedup, uncomplete tasks, planning view
Bug fixes: - #24: Deduplicate calendar events across multiple calendars using summary + start time as key - #25: Change event icon from calendar to clock to avoid confusion with date display - #26: Add task uncomplete functionality via ReopenTask API for Todoist and closed=false for Trello - #27: Restructure planning view with sections for Scheduled (timed events/tasks), Today (unscheduled), Quick Add, and Upcoming (3 days) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/handlers.go69
-rw-r--r--internal/handlers/handlers_test.go4
-rw-r--r--internal/handlers/tabs.go178
3 files changed, 229 insertions, 22 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index b8fc574..c364188 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -827,21 +827,78 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) {
}
}
- // Return completed task HTML (stays visible with strikethrough until refresh)
+ // Return completed task HTML with uncomplete option
w.Header().Set("Content-Type", "text/html")
- completedHTML := fmt.Sprintf(`<div class="task-item bg-gray-100 rounded-lg shadow-sm border-l-4 border-gray-300 opacity-60">
+ completedHTML := fmt.Sprintf(`<div class="bg-white/5 rounded-lg border border-white/5 opacity-60">
<div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4">
- <input type="checkbox" checked disabled class="mt-1 h-5 w-5 rounded border-gray-300 text-green-600 cursor-not-allowed flex-shrink-0">
+ <input type="checkbox" checked
+ hx-post="/uncomplete-atom"
+ hx-vals='{"id": "%s", "source": "%s"}'
+ hx-target="closest div.rounded-lg"
+ hx-swap="outerHTML"
+ class="mt-1 h-5 w-5 rounded bg-black/40 border-white/30 text-green-600 cursor-pointer flex-shrink-0">
<span class="text-lg flex-shrink-0">✓</span>
<div class="flex-1 min-w-0">
- <h3 class="text-sm font-medium text-gray-400 line-through break-words">%s</h3>
- <div class="text-xs text-green-600 mt-1">Completed</div>
+ <h3 class="text-sm font-medium text-white/40 line-through break-words">%s</h3>
+ <div class="text-xs text-green-400/70 mt-1">Completed - click to undo</div>
</div>
</div>
- </div>`, template.HTMLEscapeString(title))
+ </div>`, template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(title))
w.Write([]byte(completedHTML))
}
+// HandleUncompleteAtom handles reopening a completed task
+func (h *Handler) HandleUncompleteAtom(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Failed to parse form", http.StatusBadRequest)
+ log.Printf("Error parsing form: %v", err)
+ return
+ }
+
+ id := r.FormValue("id")
+ source := r.FormValue("source")
+
+ if id == "" || source == "" {
+ http.Error(w, "Missing id or source", http.StatusBadRequest)
+ return
+ }
+
+ var err error
+ switch source {
+ case "todoist":
+ err = h.todoistClient.ReopenTask(ctx, id)
+ case "trello":
+ // Reopen the card (closed = false)
+ updates := map[string]interface{}{
+ "closed": false,
+ }
+ err = h.trelloClient.UpdateCard(ctx, id, updates)
+ default:
+ http.Error(w, "Unknown source: "+source, http.StatusBadRequest)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, "Failed to reopen task", http.StatusInternalServerError)
+ log.Printf("Error reopening atom (source=%s, id=%s): %v", source, id, err)
+ return
+ }
+
+ // Invalidate cache to force refresh
+ switch source {
+ case "todoist":
+ h.store.InvalidateCache(store.CacheKeyTodoistTasks)
+ case "trello":
+ h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ }
+
+ // Trigger refresh
+ w.Header().Set("HX-Trigger", "refresh-tasks")
+ w.WriteHeader(http.StatusOK)
+}
+
// HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form
func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 5628237..d14f71b 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -87,6 +87,10 @@ func (m *mockTodoistClient) CompleteTask(ctx context.Context, taskID string) err
return nil
}
+func (m *mockTodoistClient) ReopenTask(ctx context.Context, taskID string) error {
+ return nil
+}
+
func (m *mockTodoistClient) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) {
if m.err != nil {
return nil, m.err
diff --git a/internal/handlers/tabs.go b/internal/handlers/tabs.go
index 986222a..87be344 100644
--- a/internal/handlers/tabs.go
+++ b/internal/handlers/tabs.go
@@ -171,39 +171,185 @@ func (h *TabsHandler) HandleTasks(w http.ResponseWriter, r *http.Request) {
}
}
-// HandlePlanning renders the Planning tab (Trello boards)
+// ScheduledItem represents a scheduled event or task for the planning view
+type ScheduledItem struct {
+ Type string // "event" or "task"
+ ID string
+ Title string
+ Description string
+ Start time.Time
+ End time.Time
+ URL string
+ Source string // "todoist", "trello", "calendar"
+ SourceIcon string
+ Priority int
+}
+
+// HandlePlanning renders the Planning tab with structured sections
func (h *TabsHandler) HandlePlanning(w http.ResponseWriter, r *http.Request) {
+ now := time.Now()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ tomorrow := today.AddDate(0, 0, 1)
+ in3Days := today.AddDate(0, 0, 4) // End of 3rd day
+
// Fetch Trello boards
boards, err := h.store.GetBoards()
if err != nil {
- http.Error(w, "Failed to fetch boards", http.StatusInternalServerError)
log.Printf("Error fetching boards: %v", err)
- return
+ boards = []models.Board{}
+ }
+
+ // Fetch Todoist tasks
+ tasks, err := h.store.GetTasks()
+ if err != nil {
+ log.Printf("Error fetching tasks: %v", err)
+ tasks = []models.Task{}
}
// Fetch Google Calendar events
var events []models.CalendarEvent
if h.googleCalendarClient != nil {
- var err error
- events, err = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 10)
+ events, err = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 20)
if err != nil {
log.Printf("Error fetching calendar events: %v", err)
- // Don't fail the whole request, just show empty events
- } else {
- log.Printf("Fetched %d calendar events", len(events))
}
- } else {
- log.Printf("Google Calendar client not configured")
}
+ // Categorize into sections
+ var scheduled []ScheduledItem // Events and timed tasks for today
+ var unscheduled []models.Atom // Tasks due today without specific time
+ var upcoming []ScheduledItem // Events and tasks for next 3 days
+
+ // Process calendar events
+ for _, event := range events {
+ item := ScheduledItem{
+ Type: "event",
+ ID: event.ID,
+ Title: event.Summary,
+ Description: event.Description,
+ Start: event.Start,
+ End: event.End,
+ URL: event.HTMLLink,
+ Source: "calendar",
+ SourceIcon: "📅",
+ }
+
+ if event.Start.Before(tomorrow) {
+ scheduled = append(scheduled, item)
+ } else if event.Start.Before(in3Days) {
+ upcoming = append(upcoming, item)
+ }
+ }
+
+ // Process Todoist tasks
+ for _, task := range tasks {
+ if task.Completed || task.DueDate == nil {
+ continue
+ }
+ dueDate := *task.DueDate
+
+ // Check if task has a specific time (not midnight)
+ hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0
+
+ if dueDate.Before(tomorrow) {
+ if hasTime {
+ // Timed task for today -> scheduled
+ scheduled = append(scheduled, ScheduledItem{
+ Type: "task",
+ ID: task.ID,
+ Title: task.Content,
+ Start: dueDate,
+ URL: task.URL,
+ Source: "todoist",
+ SourceIcon: "✓",
+ Priority: task.Priority,
+ })
+ } else {
+ // All-day task for today -> unscheduled
+ atom := models.TaskToAtom(task)
+ atom.ComputeUIFields()
+ unscheduled = append(unscheduled, atom)
+ }
+ } else if dueDate.Before(in3Days) {
+ upcoming = append(upcoming, ScheduledItem{
+ Type: "task",
+ ID: task.ID,
+ Title: task.Content,
+ Start: dueDate,
+ URL: task.URL,
+ Source: "todoist",
+ SourceIcon: "✓",
+ Priority: task.Priority,
+ })
+ }
+ }
+
+ // Process Trello cards with due dates
+ for _, board := range boards {
+ for _, card := range board.Cards {
+ if card.DueDate == nil {
+ continue
+ }
+ dueDate := *card.DueDate
+ hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0
+
+ if dueDate.Before(tomorrow) {
+ if hasTime {
+ scheduled = append(scheduled, ScheduledItem{
+ Type: "task",
+ ID: card.ID,
+ Title: card.Name,
+ Start: dueDate,
+ URL: card.URL,
+ Source: "trello",
+ SourceIcon: "📋",
+ })
+ } else {
+ atom := models.CardToAtom(card)
+ atom.ComputeUIFields()
+ unscheduled = append(unscheduled, atom)
+ }
+ } else if dueDate.Before(in3Days) {
+ upcoming = append(upcoming, ScheduledItem{
+ Type: "task",
+ ID: card.ID,
+ Title: card.Name,
+ Start: dueDate,
+ URL: card.URL,
+ Source: "trello",
+ SourceIcon: "📋",
+ })
+ }
+ }
+ }
+
+ // Sort scheduled by start time
+ sort.Slice(scheduled, func(i, j int) bool {
+ return scheduled[i].Start.Before(scheduled[j].Start)
+ })
+
+ // Sort unscheduled by priority (higher first)
+ sort.Slice(unscheduled, func(i, j int) bool {
+ return unscheduled[i].Priority > unscheduled[j].Priority
+ })
+
+ // Sort upcoming by date
+ sort.Slice(upcoming, func(i, j int) bool {
+ return upcoming[i].Start.Before(upcoming[j].Start)
+ })
+
data := struct {
- Boards []models.Board
- Projects []models.Project
- Events []models.CalendarEvent
+ Scheduled []ScheduledItem
+ Unscheduled []models.Atom
+ Upcoming []ScheduledItem
+ Boards []models.Board
+ Today string
}{
- Boards: boards,
- Projects: []models.Project{}, // Empty for now
- Events: events,
+ Scheduled: scheduled,
+ Unscheduled: unscheduled,
+ Upcoming: upcoming,
+ Boards: boards,
+ Today: today.Format("2006-01-02"),
}
if err := h.templates.ExecuteTemplate(w, "planning-tab", data); err != nil {