From 7828e19501b3ca8b2e86ca7297f580c659e5c9b8 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 23 Jan 2026 16:10:52 -1000 Subject: 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 --- internal/handlers/handlers.go | 69 ++++++++++++-- internal/handlers/handlers_test.go | 4 + internal/handlers/tabs.go | 178 +++++++++++++++++++++++++++++++++---- 3 files changed, 229 insertions(+), 22 deletions(-) (limited to 'internal/handlers') 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(`
+ completedHTML := fmt.Sprintf(`
- +
-

%s

-
Completed
+

%s

+
Completed - click to undo
-
`, template.HTMLEscapeString(title)) +
`, 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 { -- cgit v1.2.3