summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-21 22:53:37 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-21 22:53:37 -1000
commit583f90c5dedf0235fa45557359b0e6e7dd62b0f0 (patch)
tree304e4527b6668669197fc9ffdf2ffc87566478f0
parentdd4689a71de8f1c0b5a2d483827411a9645ad66a (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>
-rw-r--r--SESSION_STATE.md30
-rw-r--r--cmd/dashboard/main.go4
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/api/todoist.go31
-rw-r--r--internal/handlers/handlers.go150
-rw-r--r--internal/handlers/handlers_test.go4
-rw-r--r--internal/handlers/tabs.go87
-rw-r--r--internal/models/atom.go7
-rw-r--r--web/templates/index.html130
-rw-r--r--web/templates/partials/tasks-tab.html89
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}}