diff options
| -rw-r--r-- | SESSION_STATE.md | 30 | ||||
| -rw-r--r-- | cmd/dashboard/main.go | 4 | ||||
| -rw-r--r-- | internal/api/interfaces.go | 1 | ||||
| -rw-r--r-- | internal/api/todoist.go | 31 | ||||
| -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 | ||||
| -rw-r--r-- | internal/models/atom.go | 7 | ||||
| -rw-r--r-- | web/templates/index.html | 130 | ||||
| -rw-r--r-- | web/templates/partials/tasks-tab.html | 89 |
10 files changed, 459 insertions, 74 deletions
diff --git a/SESSION_STATE.md b/SESSION_STATE.md index 6c7164c..091929d 100644 --- a/SESSION_STATE.md +++ b/SESSION_STATE.md @@ -6,11 +6,29 @@ - **Obsidian Removal:** ✅ - **Authentication:** ✅ - **VPS Deployment Preparation:** ✅ - - Added `STATIC_DIR` env var support - - Created `deployment/task-dashboard.service` (systemd) - - Created `deployment/apache.conf` (reverse proxy) - - Created `docs/deployment.md` (full deployment guide) +- **Issue Batch (001-016):** ✅ + - 001: Hide future tasks behind fold + - 002: Modal menu for quick add/bug report + - 003: Fix tap to expand + - 005: Visual task timing differentiation + - 006: Reorder tasks by urgency + - 007: Fix outdated Todoist link + - 009: Keep completed tasks visible until refresh + - 010: Fix quick add timestamp (evening date bug) + - 015: Random landscape background + - 016: Click task to edit details -**Current Status:** [APPROVED] +**Current Status:** [REVIEW_READY] -**All Planned Tasks Complete** +**Files Modified:** +- `internal/api/todoist.go` - Updated URL format, added UpdateTask method +- `internal/api/interfaces.go` - Added UpdateTask to TodoistAPI interface +- `internal/handlers/handlers.go` - Added task detail/update handlers, completed task HTML response +- `internal/handlers/tabs.go` - Added urgency sorting, future task partitioning +- `internal/handlers/handlers_test.go` - Added UpdateTask mock +- `internal/models/atom.go` - Added IsFuture field +- `cmd/dashboard/main.go` - Added task detail/update routes +- `web/templates/index.html` - Added unified modal, task edit modal, random background +- `web/templates/partials/tasks-tab.html` - Checkbox complete, expand details, urgency styling, future fold + +**All Issues Complete - Ready for Review** diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 7cacd06..fd2c024 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -138,6 +138,10 @@ func main() { r.Post("/unified-add", h.HandleUnifiedAdd) r.Get("/partials/lists", h.HandleGetListsOptions) + // Task detail/edit + r.Get("/tasks/detail", h.HandleGetTaskDetail) + r.Post("/tasks/update", h.HandleUpdateTask) + // Bug reporting r.Get("/bugs", h.HandleGetBugs) r.Post("/bugs", h.HandleReportBug) diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 33bef59..32d0120 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -12,6 +12,7 @@ type TodoistAPI interface { GetTasks(ctx context.Context) ([]models.Task, error) GetProjects(ctx context.Context) ([]models.Project, error) 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 Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) } diff --git a/internal/api/todoist.go b/internal/api/todoist.go index b51fffd..14c6c0b 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -266,7 +266,7 @@ func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]str 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), } // Parse added_at @@ -389,6 +389,35 @@ func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID strin return task, nil } +// UpdateTask updates a task with the specified changes +func (c *TodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { + jsonData, err := json.Marshal(updates) + if err != nil { + return fmt.Errorf("failed to marshal updates: %w", err) + } + + url := fmt.Sprintf("%s/tasks/%s", c.baseURL, taskID) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { // Create POST request to close endpoint 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 { diff --git a/internal/models/atom.go b/internal/models/atom.go index b3a384a..10d14d1 100644 --- a/internal/models/atom.go +++ b/internal/models/atom.go @@ -37,13 +37,14 @@ type Atom struct { SourceIcon string // e.g., "trello-icon.svg" or emoji ColorClass string // e.g., "border-blue-500" IsOverdue bool // True if due date is before today + IsFuture bool // True if due date is after today HasSetTime bool // True if due time is not midnight (has specific time) // Original Data (for write operations) Raw interface{} } -// ComputeUIFields calculates IsOverdue and HasSetTime based on DueDate +// ComputeUIFields calculates IsOverdue, IsFuture, and HasSetTime based on DueDate func (a *Atom) ComputeUIFields() { if a.DueDate == nil { return @@ -51,11 +52,15 @@ func (a *Atom) ComputeUIFields() { now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tomorrow := today.AddDate(0, 0, 1) // Check if overdue (due date is before today) dueDay := time.Date(a.DueDate.Year(), a.DueDate.Month(), a.DueDate.Day(), 0, 0, 0, 0, a.DueDate.Location()) a.IsOverdue = dueDay.Before(today) + // Check if future (due date is after today) + a.IsFuture = !dueDay.Before(tomorrow) + // Check if has set time (not midnight) a.HasSetTime = a.DueDate.Hour() != 0 || a.DueDate.Minute() != 0 } diff --git a/web/templates/index.html b/web/templates/index.html index 18aa56b..6732ffd 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -7,8 +7,8 @@ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="stylesheet" href="/static/css/output.css"> </head> -<body class="min-h-screen" hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'> - <div class="content-max-width py-3 sm:py-6"> +<body class="min-h-screen bg-gray-800" style="background-image: url('{{.BackgroundURL}}'); background-size: cover; background-position: center; background-attachment: fixed;" hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'> + <div class="content-max-width py-3 sm:py-6 bg-white/80 backdrop-blur-sm rounded-lg my-4 sm:my-6 shadow-lg"> <!-- Minimal Header --> <header class="flex mb-4 sm:mb-6 justify-between items-center no-print"> <button onclick="refreshData()" @@ -66,26 +66,66 @@ </div> </div> - <!-- Bug Report Button --> - <button onclick="document.getElementById('bug-modal').classList.remove('hidden')" - class="fixed bottom-4 right-4 bg-red-500 hover:bg-red-600 text-white p-3 rounded-full shadow-lg no-print" - title="Report a bug"> - 🐛 + <!-- Unified Action Button (FAB) --> + <button onclick="openActionModal()" + class="fixed bottom-4 right-4 bg-primary-600 hover:bg-primary-700 text-white p-4 rounded-full shadow-lg no-print" + title="Quick Actions (Ctrl+K)"> + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path> + </svg> </button> - <!-- Bug Report Modal --> - <div id="bug-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> + <!-- Unified Action Modal --> + <div id="action-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> <div class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[80vh] overflow-hidden"> <div class="p-4 border-b border-gray-200 flex justify-between items-center"> - <h2 class="font-semibold text-gray-900">Report Bug</h2> - <button onclick="document.getElementById('bug-modal').classList.add('hidden')" - class="text-gray-400 hover:text-gray-600">✕</button> + <div class="flex gap-2"> + <button onclick="switchActionTab('add')" id="tab-add" + class="px-3 py-1 rounded-lg text-sm font-medium bg-primary-100 text-primary-700"> + ✓ Quick Add + </button> + <button onclick="switchActionTab('bug')" id="tab-bug" + class="px-3 py-1 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100"> + 🐛 Bug + </button> + </div> + <button onclick="closeActionModal()" class="text-gray-400 hover:text-gray-600">✕</button> + </div> + + <!-- Quick Add Tab --> + <div id="panel-add" class="p-4"> + <form hx-post="/unified-add" + hx-swap="none" + hx-on::after-request="if(event.detail.successful) { this.reset(); closeActionModal(); htmx.trigger(document.body, 'refresh-tasks'); }"> + <input type="text" + name="title" + placeholder="Task name..." + class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-3" + required + autofocus> + <div class="flex gap-2 mb-3"> + <input type="date" + name="due_date" + id="modal-add-date" + class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm"> + <select name="source" class="border border-gray-300 rounded-lg px-3 py-2 text-sm"> + <option value="todoist">Todoist</option> + <option value="trello">Trello</option> + </select> + </div> + <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"> + Add Task + </button> + </form> </div> - <div class="p-4"> + + <!-- Bug Report Tab --> + <div id="panel-bug" class="p-4 hidden"> <form hx-post="/bugs" hx-target="#bug-list" hx-swap="innerHTML" - hx-on::after-request="if(event.detail.successful) this.reset()"> + hx-on::after-request="if(event.detail.successful) { this.reset(); closeActionModal(); }"> <textarea name="description" placeholder="Describe the bug..." class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-3 h-24" @@ -98,7 +138,7 @@ <div class="mt-4 border-t border-gray-200 pt-4"> <h3 class="text-sm font-medium text-gray-700 mb-2">Recent Reports</h3> <div id="bug-list" - class="max-h-48 overflow-y-auto" + class="max-h-32 overflow-y-auto" hx-get="/bugs" hx-trigger="load"> <p class="text-gray-400 text-sm">Loading...</p> @@ -108,6 +148,66 @@ </div> </div> + <script> + function openActionModal() { + document.getElementById('action-modal').classList.remove('hidden'); + var dateInput = document.getElementById('modal-add-date'); + if (dateInput) { + var d = new Date(); + dateInput.value = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + } + setTimeout(function() { + var input = document.querySelector('#panel-add input[name="title"]'); + if (input && !document.getElementById('panel-add').classList.contains('hidden')) input.focus(); + }, 100); + } + function closeActionModal() { + document.getElementById('action-modal').classList.add('hidden'); + } + function switchActionTab(tab) { + document.getElementById('panel-add').classList.toggle('hidden', tab !== 'add'); + document.getElementById('panel-bug').classList.toggle('hidden', tab !== 'bug'); + document.getElementById('tab-add').classList.toggle('bg-primary-100', tab === 'add'); + document.getElementById('tab-add').classList.toggle('text-primary-700', tab === 'add'); + document.getElementById('tab-add').classList.toggle('text-gray-600', tab !== 'add'); + document.getElementById('tab-bug').classList.toggle('bg-red-100', tab === 'bug'); + document.getElementById('tab-bug').classList.toggle('text-red-700', tab === 'bug'); + document.getElementById('tab-bug').classList.toggle('text-gray-600', tab !== 'bug'); + } + // Keyboard shortcut: Ctrl+K or Cmd+K + document.addEventListener('keydown', function(e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + var modal = document.getElementById('action-modal'); + if (modal.classList.contains('hidden')) { + openActionModal(); + } else { + closeActionModal(); + } + } + if (e.key === 'Escape') { + closeActionModal(); + closeTaskModal(); + } + }); + function closeTaskModal() { + document.getElementById('task-edit-modal').classList.add('hidden'); + } + </script> + + <!-- Task Edit Modal --> + <div id="task-edit-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> + <div class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[80vh] overflow-hidden"> + <div class="p-4 border-b border-gray-200 flex justify-between items-center"> + <h2 class="font-semibold text-gray-900">Edit Task</h2> + <button onclick="closeTaskModal()" class="text-gray-400 hover:text-gray-600">✕</button> + </div> + <div id="task-edit-content"> + <p class="p-4 text-gray-500 text-sm">Loading...</p> + </div> + </div> + </div> + <script src="/static/js/htmx.min.js"></script> <script src="/static/js/app.js"></script> </body> diff --git a/web/templates/partials/tasks-tab.html b/web/templates/partials/tasks-tab.html index 2a89a40..afbbe2c 100644 --- a/web/templates/partials/tasks-tab.html +++ b/web/templates/partials/tasks-tab.html @@ -10,7 +10,7 @@ onclick="document.getElementById('quick-add-form').classList.toggle('hidden')" class="w-full p-3 sm:p-4 text-left flex justify-between items-center"> <span class="font-semibold text-gray-900">+ Quick Add</span> - <span class="text-gray-400 text-sm">tap to expand</span> + <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg> </button> <form id="quick-add-form" class="hidden p-3 sm:p-4 pt-0 border-t border-gray-100" @@ -28,8 +28,15 @@ <div> <input type="date" name="due_date" - value="{{.Today}}" + id="quick-add-date" class="border border-gray-300 rounded-lg px-2 py-2 text-sm focus:ring-2 focus:ring-primary-500"> + <script> + (function() { + var d = new Date(); + var dateStr = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + document.getElementById('quick-add-date').value = dateStr; + })(); + </script> </div> <div> <select name="source" @@ -73,18 +80,23 @@ {{if .Atoms}} <div class="space-y-2"> {{range .Atoms}} - <div class="task-item bg-white rounded-lg p-3 sm:p-4 shadow-sm hover:shadow-md transition-shadow border-l-4 {{.ColorClass}} {{if .IsOverdue}}opacity-50{{end}}" - hx-post="/complete-atom" - hx-trigger="click" - hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}' - hx-target="this" - hx-swap="outerHTML" - hx-confirm="Mark as complete?"> - <div class="flex items-start gap-2 sm:gap-3"> + <div class="task-item bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border-l-4 {{.ColorClass}} {{if .IsFuture}}opacity-60{{end}}"> + <div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4"> + <!-- Checkbox for completing --> + <input type="checkbox" + hx-post="/complete-atom" + hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}' + hx-target="closest .task-item" + hx-swap="outerHTML" + class="mt-1 h-5 w-5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 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 font-medium {{if .IsOverdue}}text-gray-500{{else}}text-gray-900{{end}} break-words">{{.Title}}</h3> + <h3 class="text-sm {{if .IsOverdue}}text-red-600 font-semibold{{else if .IsFuture}}text-gray-400 font-normal{{else}}text-gray-900 font-medium{{end}} break-words cursor-pointer hover:underline" + hx-get="/tasks/detail?id={{.ID}}&source={{.Source}}" + hx-target="#task-edit-content" + hx-swap="innerHTML" + onclick="document.getElementById('task-edit-modal').classList.remove('hidden')">{{.Title}}</h3> {{if .URL}} <a href="{{.URL}}" target="_blank" class="text-primary-600 hover:text-primary-800 flex-shrink-0" onclick="event.stopPropagation()"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> @@ -95,21 +107,72 @@ </div> <div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-gray-400"> {{if .DueDate}} - <span class="{{if .IsOverdue}}text-red-400{{end}}">{{.DueDate.Format "Jan 2"}}{{if .HasSetTime}}, {{.DueDate.Format "3:04pm"}}{{end}}</span> + <span class="{{if .IsOverdue}}text-red-500 font-medium{{end}}">{{.DueDate.Format "Jan 2"}}{{if .HasSetTime}}, {{.DueDate.Format "3:04pm"}}{{end}}</span> {{end}} {{if gt .Priority 2}} <span class="text-red-500 font-medium">P{{.Priority}}</span> {{end}} + {{if .Description}} + <span class="text-gray-400">+details</span> + {{end}} </div> </div> </div> + {{if .Description}} + <details class="border-t border-gray-100"> + <summary class="px-3 sm:px-4 py-2 text-xs text-gray-500 cursor-pointer hover:bg-gray-50">Tap to expand</summary> + <div class="px-3 sm:px-4 pb-3 text-sm text-gray-600">{{.Description}}</div> + </details> + {{end}} </div> {{end}} </div> {{else}} <div class="bg-white/50 rounded-lg p-6 text-center"> - <p class="text-gray-500 text-sm">No tasks found.</p> + <p class="text-gray-500 text-sm">No current tasks.</p> </div> {{end}} + + <!-- Future Tasks (Collapsed by default) --> + {{if .FutureAtoms}} + <details class="mt-4"> + <summary class="bg-white/70 rounded-lg p-3 cursor-pointer hover:bg-white/90 transition-colors text-sm text-gray-600 flex items-center justify-between"> + <span>+{{len .FutureAtoms}} later</span> + <svg class="w-4 h-4 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg> + </summary> + <div class="space-y-2 mt-2"> + {{range .FutureAtoms}} + <div class="task-item bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border-l-4 {{.ColorClass}} opacity-60"> + <div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4"> + <input type="checkbox" + hx-post="/complete-atom" + hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}' + hx-target="closest .task-item" + hx-swap="outerHTML" + class="mt-1 h-5 w-5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 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-gray-400 font-normal break-words">{{.Title}}</h3> + {{if .URL}} + <a href="{{.URL}}" target="_blank" class="text-primary-600 hover:text-primary-800 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-gray-400"> + {{if .DueDate}} + <span>{{.DueDate.Format "Jan 2"}}{{if .HasSetTime}}, {{.DueDate.Format "3:04pm"}}{{end}}</span> + {{end}} + </div> + </div> + </div> + </div> + {{end}} + </div> + </details> + {{end}} </div> {{end}} |
