diff options
| -rw-r--r-- | cmd/dashboard/main.go | 1 | ||||
| -rw-r--r-- | internal/api/google_calendar.go | 22 | ||||
| -rw-r--r-- | internal/api/interfaces.go | 1 | ||||
| -rw-r--r-- | internal/api/todoist.go | 23 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 69 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 4 | ||||
| -rw-r--r-- | internal/handlers/tabs.go | 178 | ||||
| -rw-r--r-- | web/templates/partials/planning-tab.html | 166 |
8 files changed, 413 insertions, 51 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index ef94427..050c8d0 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -148,6 +148,7 @@ func main() { // Unified task completion (for Tasks tab Atoms) r.Post("/complete-atom", h.HandleCompleteAtom) + r.Post("/uncomplete-atom", h.HandleUncompleteAtom) // Unified Quick Add (for Tasks tab) r.Post("/unified-add", h.HandleUnifiedAdd) diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go index 8dd48f0..2154351 100644 --- a/internal/api/google_calendar.go +++ b/internal/api/google_calendar.go @@ -76,15 +76,27 @@ func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults } } + // Deduplicate events (same event may appear in multiple calendars) + seen := make(map[string]bool) + var uniqueEvents []models.CalendarEvent + for _, event := range allEvents { + // Use summary + start time as dedup key + key := event.Summary + event.Start.Format(time.RFC3339) + if !seen[key] { + seen[key] = true + uniqueEvents = append(uniqueEvents, event) + } + } + // Sort all events by start time - sort.Slice(allEvents, func(i, j int) bool { - return allEvents[i].Start.Before(allEvents[j].Start) + sort.Slice(uniqueEvents, func(i, j int) bool { + return uniqueEvents[i].Start.Before(uniqueEvents[j].Start) }) // Limit to maxResults - if len(allEvents) > maxResults { - allEvents = allEvents[:maxResults] + if len(uniqueEvents) > maxResults { + uniqueEvents = uniqueEvents[:maxResults] } - return allEvents, nil + return uniqueEvents, nil } diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index e2521f4..842814c 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -14,6 +14,7 @@ type TodoistAPI interface { CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error CompleteTask(ctx context.Context, taskID string) error + ReopenTask(ctx context.Context, taskID string) error Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) } diff --git a/internal/api/todoist.go b/internal/api/todoist.go index 689bf10..b3d4579 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -442,3 +442,26 @@ func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { return nil } + +// ReopenTask marks a completed task as active in Todoist +func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { + url := fmt.Sprintf("%s/tasks/%s/reopen", c.baseURL, taskID) + req, err := http.NewRequestWithContext(ctx, "POST", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to reopen task: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} 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 { diff --git a/web/templates/partials/planning-tab.html b/web/templates/partials/planning-tab.html index 77bd3d8..bfb3eee 100644 --- a/web/templates/partials/planning-tab.html +++ b/web/templates/partials/planning-tab.html @@ -1,43 +1,161 @@ {{define "planning-tab"}} -<div class="space-y-6"> - <!-- Google Calendar Events --> - {{if .Events}} - <div class="bg-white/10 backdrop-blur-sm rounded-xl p-4 shadow-sm border border-white/10"> - <h2 class="text-xl font-semibold mb-4 flex items-center gap-2 text-shadow-sm"> - <span>📅</span> Upcoming Events +<div class="space-y-6 text-shadow-sm" + hx-get="/tabs/planning" + hx-trigger="refresh-tasks from:body" + hx-target="#tab-content" + hx-swap="innerHTML"> + + <!-- Scheduled Section (Events + Timed Tasks) --> + {{if .Scheduled}} + <div> + <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90"> + <span>⏰</span> Scheduled </h2> <div class="space-y-2"> - {{range .Events}} - <a href="{{.HTMLLink}}" target="_blank" class="task-item border-l-4 border-blue-500 block group"> - <div class="flex items-start gap-3 p-3 sm:p-4"> - <span class="text-lg flex-shrink-0">📅</span> + {{range .Scheduled}} + <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 border-l-4 {{if eq .Type "event"}}border-l-blue-500{{else}}border-l-green-500{{end}}"> + <div class="flex items-start gap-3 p-3"> + <span class="text-lg flex-shrink-0 mt-0.5">{{.SourceIcon}}</span> <div class="flex-1 min-w-0"> <div class="flex items-start justify-between gap-2"> - <h3 class="text-sm text-white font-medium break-words group-hover:underline">{{.Summary}}</h3> - <svg class="w-4 h-4 text-white/50 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> - </svg> + <h3 class="text-sm text-white font-medium break-words">{{.Title}}</h3> + {{if .URL}} + <a href="{{.URL}}" target="_blank" class="text-white/50 hover:text-white flex-shrink-0"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> + </svg> + </a> + {{end}} </div> - <div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-white/50"> - <span>{{.Start.Format "Jan 2"}}</span> - {{if eq (.Start.Format "15:04") "00:00"}} - <span>All Day</span> + <div class="text-xs text-white/50 mt-1"> + {{if eq .Type "event"}} + {{if eq (.Start.Format "15:04") "00:00"}} + All Day + {{else}} + {{.Start.Format "3:04 PM"}} - {{.End.Format "3:04 PM"}} + {{end}} {{else}} - <span>{{.Start.Format "3:04 PM"}} - {{.End.Format "3:04 PM"}}</span> + {{.Start.Format "3:04 PM"}} + {{end}} + </div> + </div> + </div> + </div> + {{end}} + </div> + </div> + {{end}} + + <!-- Unscheduled Section (Tasks due today without time) --> + {{if .Unscheduled}} + <div> + <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90"> + <span>📝</span> Today + </h2> + <div class="grid gap-2 sm:grid-cols-2"> + {{range .Unscheduled}} + <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5"> + <div class="flex items-start gap-2 p-3"> + <input type="checkbox" + hx-post="/complete-atom" + hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}' + hx-target="closest div.rounded-lg" + hx-swap="outerHTML" + class="mt-1 h-5 w-5 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> + <span class="text-lg flex-shrink-0">{{.SourceIcon}}</span> + <div class="flex-1 min-w-0"> + <div class="flex items-start justify-between gap-2"> + <h3 class="text-sm text-white font-medium break-words {{if .IsOverdue}}text-amber-300{{end}}">{{.Title}}</h3> + {{if .URL}} + <a href="{{.URL}}" target="_blank" class="text-white/50 hover:text-white flex-shrink-0"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> + </svg> + </a> {{end}} </div> - {{if .Description}} - <p class="text-xs text-white/40 mt-1 line-clamp-2">{{.Description}}</p> + {{if gt .Priority 2}} + <span class="text-xs text-amber-300/80">P{{.Priority}}</span> {{end}} </div> </div> - </a> + </div> {{end}} </div> </div> {{end}} - <!-- Trello Boards Section --> - {{template "trello-boards" .}} + <!-- Quick Add Section --> + <div class="bg-white/5 backdrop-blur-sm rounded-lg p-4 border border-white/10"> + <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90"> + <span>+</span> Quick Add + </h2> + <form hx-post="/unified-add" + hx-swap="none" + hx-on::after-request="if(event.detail.successful) { this.reset(); htmx.trigger(document.body, 'refresh-tasks'); }"> + <div class="flex gap-2"> + <input type="text" + name="title" + placeholder="Add a task for today..." + class="flex-1 bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50" + required> + <input type="hidden" name="due_date" value="{{.Today}}"> + <input type="hidden" name="source" value="todoist"> + <button type="submit" + class="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg text-sm font-medium"> + Add + </button> + </div> + </form> + </div> + + <!-- Upcoming Section (Next 3 days) --> + {{if .Upcoming}} + <div> + <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90"> + <span>📆</span> Upcoming + </h2> + <div class="space-y-2"> + {{range .Upcoming}} + <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 opacity-70"> + <div class="flex items-start gap-3 p-3"> + <span class="text-lg flex-shrink-0 mt-0.5">{{.SourceIcon}}</span> + <div class="flex-1 min-w-0"> + <div class="flex items-start justify-between gap-2"> + <h3 class="text-sm text-white/80 font-medium break-words">{{.Title}}</h3> + {{if .URL}} + <a href="{{.URL}}" target="_blank" class="text-white/40 hover:text-white/70 flex-shrink-0"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> + </svg> + </a> + {{end}} + </div> + <div class="text-xs text-white/40 mt-1"> + {{.Start.Format "Mon, Jan 2"}} + {{if eq .Type "event"}} + {{if ne (.Start.Format "15:04") "00:00"}} + at {{.Start.Format "3:04 PM"}} + {{end}} + {{else}} + {{if or (ne .Start.Hour 0) (ne .Start.Minute 0)}} + at {{.Start.Format "3:04 PM"}} + {{end}} + {{end}} + </div> + </div> + </div> + </div> + {{end}} + </div> + </div> + {{end}} + + <!-- Empty state if nothing scheduled --> + {{if and (not .Scheduled) (not .Unscheduled) (not .Upcoming)}} + <div class="text-center py-8 text-white/50"> + <p>No scheduled items for the next few days.</p> + </div> + {{end}} </div> {{end}} |
