diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-21 22:53:37 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-21 22:53:37 -1000 |
| commit | 583f90c5dedf0235fa45557359b0e6e7dd62b0f0 (patch) | |
| tree | 304e4527b6668669197fc9ffdf2ffc87566478f0 /internal/handlers | |
| parent | dd4689a71de8f1c0b5a2d483827411a9645ad66a (diff) | |
Implement 10 UI/UX improvements and bug fixes
- Fix outdated Todoist task URL format (showTask -> app/task)
- Fix quick-add date defaulting to tomorrow in evening (client-side JS)
- Add tap-to-expand for task descriptions with checkbox completion
- Add visual differentiation: overdue (red), future (gray), today (normal)
- Sort tasks by urgency: overdue > today-timed > today-allday > future
- Keep completed tasks visible with strikethrough until refresh
- Add random Unsplash landscape background with content overlay
- Hide future tasks behind collapsible fold with count badge
- Unified modal menu for Quick Add + Bug Report (Ctrl+K shortcut)
- Click task title to edit description in modal
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/handlers.go | 150 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 4 | ||||
| -rw-r--r-- | internal/handlers/tabs.go | 87 |
3 files changed, 203 insertions, 38 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index e4d6457..73a05f0 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -78,15 +78,21 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { return } + // Generate random background URL (Unsplash Source API) + // Add timestamp to prevent caching + backgroundURL := fmt.Sprintf("https://source.unsplash.com/1920x1080/?landscape,nature&t=%d", time.Now().UnixNano()) + // Wrap dashboard data with active tab for template data := struct { *models.DashboardData - ActiveTab string - CSRFToken string + ActiveTab string + CSRFToken string + BackgroundURL string }{ DashboardData: dashboardData, ActiveTab: tab, CSRFToken: auth.GetCSRFTokenFromContext(ctx), + BackgroundURL: backgroundURL, } if err := h.templates.ExecuteTemplate(w, "index.html", data); err != nil { @@ -411,7 +417,7 @@ func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap ma Priority: item.Priority, Completed: false, Labels: item.Labels, - URL: fmt.Sprintf("https://todoist.com/showTask?id=%s", item.ID), + URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), } if item.AddedAt != "" { @@ -728,6 +734,34 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { return } + // Get task title before removing from cache + var title string + switch source { + case "todoist": + if tasks, err := h.store.GetTasks(); err == nil { + for _, t := range tasks { + if t.ID == id { + title = t.Content + break + } + } + } + case "trello": + if boards, err := h.store.GetBoards(); err == nil { + for _, b := range boards { + for _, c := range b.Cards { + if c.ID == id { + title = c.Name + break + } + } + } + } + } + if title == "" { + title = "Task" + } + // Remove from local cache switch source { case "todoist": @@ -740,8 +774,19 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { } } - // Return 200 OK with empty body to remove the element from DOM - w.WriteHeader(http.StatusOK) + // Return completed task HTML (stays visible with strikethrough until refresh) + 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"> + <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"> + <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> + </div> + </div> + </div>`, template.HTMLEscapeString(title)) + w.Write([]byte(completedHTML)) } // HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form @@ -875,3 +920,98 @@ func (h *Handler) HandleReportBug(w http.ResponseWriter, r *http.Request) { // Return updated bug list h.HandleGetBugs(w, r) } + +// HandleGetTaskDetail returns task details as HTML for modal +func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + source := r.URL.Query().Get("source") + + if id == "" || source == "" { + http.Error(w, "Missing id or source", http.StatusBadRequest) + return + } + + var title, description string + switch source { + case "todoist": + tasks, err := h.store.GetTasks() + if err == nil { + for _, t := range tasks { + if t.ID == id { + title = t.Content + description = t.Description + break + } + } + } + case "trello": + boards, err := h.store.GetBoards() + if err == nil { + for _, b := range boards { + for _, c := range b.Cards { + if c.ID == id { + title = c.Name + // Card model doesn't store description, leave empty + description = "" + break + } + } + } + } + } + + w.Header().Set("Content-Type", "text/html") + html := fmt.Sprintf(` + <div class="p-4"> + <h3 class="font-semibold text-gray-900 mb-3">%s</h3> + <form hx-post="/tasks/update" hx-swap="none" hx-on::after-request="if(event.detail.successful) { closeTaskModal(); htmx.trigger(document.body, 'refresh-tasks'); }"> + <input type="hidden" name="id" value="%s"> + <input type="hidden" name="source" value="%s"> + <label class="block text-sm font-medium text-gray-700 mb-1">Description</label> + <textarea name="description" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-3 h-32">%s</textarea> + <button type="submit" class="w-full bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Save</button> + </form> + </div> + `, template.HTMLEscapeString(title), template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(description)) + w.Write([]byte(html)) +} + +// HandleUpdateTask updates a task description +func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + + id := r.FormValue("id") + source := r.FormValue("source") + description := r.FormValue("description") + + if id == "" || source == "" { + http.Error(w, "Missing id or source", http.StatusBadRequest) + return + } + + var err error + switch source { + case "todoist": + updates := map[string]interface{}{"description": description} + err = h.todoistClient.UpdateTask(ctx, id, updates) + case "trello": + updates := map[string]interface{}{"desc": description} + err = h.trelloClient.UpdateCard(ctx, id, updates) + default: + http.Error(w, "Unknown source", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, "Failed to update task", http.StatusInternalServerError) + log.Printf("Error updating task: %v", err) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 6e9346a..e4a9f05 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -79,6 +79,10 @@ func (m *mockTodoistClient) CreateTask(ctx context.Context, content, projectID s return nil, nil } +func (m *mockTodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { + return m.err +} + func (m *mockTodoistClient) CompleteTask(ctx context.Context, taskID string) error { return nil } diff --git a/internal/handlers/tabs.go b/internal/handlers/tabs.go index bd15710..2f22c44 100644 --- a/internal/handlers/tabs.go +++ b/internal/handlers/tabs.go @@ -25,6 +25,25 @@ func isActionableList(name string) bool { strings.Contains(lower, "today") } +// atomUrgencyTier returns the urgency tier for sorting: +// 0: Overdue, 1: Today with time, 2: Today all-day, 3: Future, 4: No due date +func atomUrgencyTier(a models.Atom) int { + if a.DueDate == nil { + return 4 // No due date + } + if a.IsOverdue { + return 0 // Overdue + } + if a.IsFuture { + return 3 // Future + } + // Due today + if a.HasSetTime { + return 1 // Today with specific time + } + return 2 // Today all-day +} + // TabsHandler handles tab-specific rendering with Atom model type TabsHandler struct { store *store.Store @@ -88,41 +107,31 @@ func (h *TabsHandler) HandleTasks(w http.ResponseWriter, r *http.Request) { } } - // Compute UI fields (IsOverdue, HasSetTime) + // Compute UI fields (IsOverdue, IsFuture, HasSetTime) for i := range atoms { atoms[i].ComputeUIFields() } - // Sort atoms: by DueDate (earliest first), then by HasSetTime, then by Priority + // Sort atoms by urgency tiers: + // 1. Overdue (before today) + // 2. Today with specific time + // 3. Today all-day (midnight) + // 4. Future + // 5. No due date + // Within each tier: sort by due date/time, then by priority sort.SliceStable(atoms, func(i, j int) bool { - // Handle nil due dates (push to end) - if atoms[i].DueDate == nil && atoms[j].DueDate != nil { - return false - } - if atoms[i].DueDate != nil && atoms[j].DueDate == nil { - return true + // Compute urgency tier (lower = more urgent) + tierI := atomUrgencyTier(atoms[i]) + tierJ := atomUrgencyTier(atoms[j]) + + if tierI != tierJ { + return tierI < tierJ } - // Both have due dates + // Same tier: sort by due date/time if both have dates if atoms[i].DueDate != nil && atoms[j].DueDate != nil { - // Compare by date only (ignore time) - dateI := atoms[i].DueDate.Truncate(24 * time.Hour) - dateJ := atoms[j].DueDate.Truncate(24 * time.Hour) - - if !dateI.Equal(dateJ) { - return dateI.Before(dateJ) - } - - // Same day: tasks with set times come before midnight tasks - if atoms[i].HasSetTime != atoms[j].HasSetTime { - return atoms[i].HasSetTime - } - - // Both have set times or both are midnight, sort by actual time - if atoms[i].HasSetTime && atoms[j].HasSetTime { - if !atoms[i].DueDate.Equal(*atoms[j].DueDate) { - return atoms[i].DueDate.Before(*atoms[j].DueDate) - } + if !atoms[i].DueDate.Equal(*atoms[j].DueDate) { + return atoms[i].DueDate.Before(*atoms[j].DueDate) } } @@ -130,15 +139,27 @@ func (h *TabsHandler) HandleTasks(w http.ResponseWriter, r *http.Request) { return atoms[i].Priority > atoms[j].Priority }) + // Partition atoms into current (overdue + today) and future + var currentAtoms, futureAtoms []models.Atom + for _, a := range atoms { + if a.IsFuture { + futureAtoms = append(futureAtoms, a) + } else { + currentAtoms = append(currentAtoms, a) + } + } + // Render template data := struct { - Atoms []models.Atom - Boards []models.Board - Today string + Atoms []models.Atom // Current tasks (overdue + today) + FutureAtoms []models.Atom // Future tasks (hidden by default) + Boards []models.Board + Today string }{ - Atoms: atoms, - Boards: boards, - Today: time.Now().Format("2006-01-02"), + Atoms: currentAtoms, + FutureAtoms: futureAtoms, + Boards: boards, + Today: time.Now().Format("2006-01-02"), } if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil { |
