diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-12 23:35:47 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-12 23:35:47 +0000 |
| commit | 2509dde6aa372a505b186657706f4d21bd391807 (patch) | |
| tree | e4147991747b8dd14d0b8654b2b8e50717b666bd | |
| parent | 3e8ad60431d6cc783f9f7c555bfde5db54ebec75 (diff) | |
Add task title editing/deletion, timeline click-to-open, widget app launch
Task-detail modal was description-only with no delete affordance;
HandleUpdateTask now saves the title too and a Delete button hits a new
DELETE /tasks/{id} route backed by store.DeleteNativeTask, which repairs
chain_position/unlocks the successor when the deleted task belongs to a
chain. Timeline tab task/card/gtask rows now open the same detail modal
as the Tasks tab. Android widget's "TODAY" header is now a tap target
that launches DashboardActivity, since nothing previously opened the
full app from the widget.
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt | 11 | ||||
| -rw-r--r-- | cmd/dashboard/main.go | 1 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 43 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 101 | ||||
| -rw-r--r-- | internal/store/chains_test.go | 114 | ||||
| -rw-r--r-- | internal/store/native_tasks.go | 61 | ||||
| -rw-r--r-- | internal/store/native_tasks_test.go | 20 | ||||
| -rw-r--r-- | web/templates/partials/task-detail.html | 15 | ||||
| -rw-r--r-- | web/templates/partials/timeline-tab.html | 32 |
9 files changed, 390 insertions, 8 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt index 9793eb9..a11a7e3 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt @@ -1,12 +1,15 @@ package org.terst.doot.widget.ui import android.content.Context +import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.datastore.preferences.core.Preferences import androidx.glance.* +import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.provideContent import androidx.glance.layout.* @@ -146,10 +149,16 @@ fun WidgetRoot( .padding(horizontal = 8.dp, vertical = 4.dp) ) { item { + val context = LocalContext.current + val openAppIntent = Intent(context, DashboardActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } Row( modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically ) { + // "TODAY" is the widget's only unclaimed tap target (every task/event row + // already opens something of its own) so it doubles as "open the app". ShadowedText( "${moonPhaseEmoji(todayDate)} TODAY", style = TextStyle( @@ -158,7 +167,7 @@ fun WidgetRoot( fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold) ), haloColor = palette.haloColor, - modifier = GlanceModifier.defaultWeight() + modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity(openAppIntent)) ) budgetStatus?.today?.let { today -> if (today.scheduledMinutes > 0) { diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 6d1e34e..24fb8a6 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -361,6 +361,7 @@ func main() { r.Get("/task", h.HandleTaskDetailPage) r.Post("/tasks/update", h.HandleUpdateTask) r.Post("/tasks/recurrence", h.HandleSetTaskRecurrence) + r.Delete("/tasks/{id}", h.HandleDeleteTask) // Shopping quick-add r.Post("/shopping/add", h.HandleShoppingQuickAdd) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index ed15e89..30aaf67 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -15,6 +15,8 @@ import ( "sync" "time" + "github.com/go-chi/chi/v5" + "task-dashboard/internal/api" "task-dashboard/internal/auth" "task-dashboard/internal/config" @@ -951,6 +953,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { id := r.FormValue("id") source := r.FormValue("source") + title := r.FormValue("title") description := r.FormValue("description") if id == "" || source == "" { @@ -961,9 +964,17 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { var err error switch source { case "doot": - err = h.store.UpdateNativeTaskDescription(id, description) + if title != "" { + err = h.store.UpdateNativeTask(id, title, description) + } else { + err = h.store.UpdateNativeTaskDescription(id, description) + } case "trello": - err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description}) + updates := map[string]interface{}{"desc": description} + if title != "" { + updates["name"] = title + } + err = h.trelloClient.UpdateCard(r.Context(), id, updates) default: JSONError(w, http.StatusBadRequest, "Unknown source", nil) return @@ -981,6 +992,34 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { } } +// HandleDeleteTask permanently deletes a doot-native task. Other sources +// (Trello cards, Google Tasks, calendar events) aren't deletable through +// doot -- the task-detail modal only shows the Delete button for source == +// "doot" (task-detail.html's IsDoot flag), matching this scoping. +func (h *Handler) HandleDeleteTask(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + source := r.URL.Query().Get("source") + if id == "" { + JSONError(w, http.StatusBadRequest, "Missing id", nil) + return + } + if source != "doot" { + JSONError(w, http.StatusBadRequest, "Delete is only supported for doot-native tasks", nil) + return + } + + if err := h.store.DeleteNativeTask(id); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + JSONError(w, http.StatusNotFound, "task not found", err) + return + } + JSONError(w, http.StatusInternalServerError, "Failed to delete task", err) + return + } + + w.WriteHeader(http.StatusOK) +} + // HandleTabTasks renders the unified Tasks tab (native tasks + Trello cards with due dates + Google Tasks) func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { atoms, boards, err := BuildUnifiedAtomList(h.store, h.claudomatorClient) diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index a43dec2..b618b71 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -1910,6 +1911,106 @@ func TestHandleGetTaskDetail_DootSource_LoadsRealTaskFields(t *testing.T) { } } +func TestHandleUpdateTask_DootSource_UpdatesTitleAndDescription(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Old title", Description: "Old desc", Priority: 1}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("POST", "/tasks/update", strings.NewReader("id=task-1&source=doot&title=New+title&description=New+desc")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + h.HandleUpdateTask(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) + } + task, err := h.store.GetNativeTaskByID("task-1") + if err != nil { + t.Fatal(err) + } + if task.Content != "New title" { + t.Errorf("Content = %q, want %q", task.Content, "New title") + } + if task.Description != "New desc" { + t.Errorf("Description = %q, want %q", task.Description, "New desc") + } +} + +func TestHandleUpdateTask_DootSource_NoTitle_LeavesContentUntouched(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Keep me", Description: "Old desc", Priority: 1}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("POST", "/tasks/update", strings.NewReader("id=task-1&source=doot&description=New+desc")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.HandleUpdateTask(w, req) + + if w.Code != http.StatusOK && w.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 200 or 303, body=%s", w.Code, w.Body.String()) + } + task, err := h.store.GetNativeTaskByID("task-1") + if err != nil { + t.Fatal(err) + } + if task.Content != "Keep me" { + t.Errorf("Content = %q, want unchanged %q (backward compat with task-detail-page.html, which has no title field)", task.Content, "Keep me") + } +} + +func TestHandleDeleteTask_DootSource_RemovesTask(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Delete me", Priority: 1}); err != nil { + t.Fatal(err) + } + + req := withURLParam(httptest.NewRequest("DELETE", "/tasks/task-1?source=doot", nil), "id", "task-1") + w := httptest.NewRecorder() + h.HandleDeleteTask(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) + } + if _, err := h.store.GetNativeTaskByID("task-1"); !errors.Is(err, store.ErrNativeTaskNotFound) { + t.Errorf("err = %v, want ErrNativeTaskNotFound", err) + } +} + +func TestHandleDeleteTask_NonDootSource_Rejected(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + req := withURLParam(httptest.NewRequest("DELETE", "/tasks/card-1?source=trello", nil), "id", "card-1") + w := httptest.NewRecorder() + h.HandleDeleteTask(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHandleDeleteTask_UnknownID_ReturnsNotFound(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + req := withURLParam(httptest.NewRequest("DELETE", "/tasks/does-not-exist?source=doot", nil), "id", "does-not-exist") + w := httptest.NewRecorder() + h.HandleDeleteTask(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + func TestHandleSetTaskRecurrence_SetsAndClears(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go index 6359d11..bcfc09e 100644 --- a/internal/store/chains_test.go +++ b/internal/store/chains_test.go @@ -263,3 +263,117 @@ func TestGetChain_UnknownID_ReturnsErrNotFound(t *testing.T) { t.Errorf("err = %v, want ErrNativeTaskNotFound", err) } } + +// TestDeleteNativeTask_LockedChainTask_ClosesPositionGap proves deleting a +// not-yet-reached chain step doesn't strand the chain: positions after the +// deleted one shift down by one and stay a contiguous 0..N-1 sequence, since +// advanceChain's chain_position+1 lookup depends on that contiguity. +func TestDeleteNativeTask_LockedChainTask_ClosesPositionGap(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2", "Step 3")) + if err != nil { + t.Fatal(err) + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + + if err := s.DeleteNativeTask(tasks[1].ID); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + after, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + if len(after) != 2 { + t.Fatalf("len(after) = %d, want 2", len(after)) + } + if after[0].Content != "Step 1" || after[1].Content != "Step 3" { + t.Errorf("unexpected content order: %q, %q", after[0].Content, after[1].Content) + } + if after[1].ChainPosition != 1 { + t.Errorf("Step 3 chain_position = %d, want 1 (gap closed)", after[1].ChainPosition) + } + if !after[0].ChainUnlocked { + t.Errorf("position 0 should still be unlocked") + } + if after[1].ChainUnlocked { + t.Errorf("position 1 (formerly locked position 2) should still be locked") + } + + // Completing position 0 should now correctly advance to the + // renumbered position 1 (Step 3), proving advanceChain's + // chain_position+1 lookup still works post-deletion. + if err := s.CompleteNativeTask(after[0].ID); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + final, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + if !final[1].ChainUnlocked { + t.Errorf("Step 3 should be unlocked after completing Step 1") + } +} + +// TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor proves deleting +// the currently-actionable (unlocked) step unlocks whatever now sits at its +// position, rather than leaving the chain with nothing unlocked. +func TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) + if err != nil { + t.Fatal(err) + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + + if err := s.DeleteNativeTask(tasks[0].ID); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + after, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + if len(after) != 1 { + t.Fatalf("len(after) = %d, want 1", len(after)) + } + if !after[0].ChainUnlocked || after[0].DueDate == nil { + t.Errorf("Step 2 should be promoted to unlocked with a due date, got ChainUnlocked=%v DueDate=%v", after[0].ChainUnlocked, after[0].DueDate) + } +} + +// TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted proves +// deleting the sole unlocked task with nothing left to promote finishes the +// chain instead of leaving it active with zero unlocked tasks forever. +func TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Track", chainTasks("Only step")) + if err != nil { + t.Fatal(err) + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + + if err := s.DeleteNativeTask(tasks[0].ID); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + updatedChain, err := s.GetChain(chain.ID) + if err != nil { + t.Fatal(err) + } + if updatedChain.Status != "completed" { + t.Errorf("chain.Status = %q, want completed", updatedChain.Status) + } +} diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 11a9197..9d9af2c 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -161,6 +161,67 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error { return err } +// DeleteNativeTask permanently removes a task. Returns ErrNativeTaskNotFound +// if id doesn't match any row. If the task belongs to a chain, this also +// closes the resulting gap in chain_position (advanceChain looks up +// chain_position+1, so a gap would either strand the chain mid-sequence or, +// if the deleted task was the unlocked one, silently stop it from ever +// advancing) and, if the deleted task was itself the unlocked position, +// unlocks whatever now occupies that position -- or marks the chain +// completed if nothing does (the deleted task was the last one left). +func (s *Store) DeleteNativeTask(id string) error { + task, err := s.GetNativeTaskByID(id) + if err != nil { + return err + } + + if task.ChainID == "" { + result, err := s.db.Exec(`DELETE FROM native_tasks WHERE id = ?`, id) + if err != nil { + return err + } + return checkRowsAffected(result) + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM native_tasks WHERE id = ?`, id); err != nil { + return err + } + if _, err := tx.Exec(` + UPDATE native_tasks SET chain_position = chain_position - 1 + WHERE chain_id = ? AND chain_position > ? + `, task.ChainID, task.ChainPosition); err != nil { + return err + } + + if task.ChainUnlocked { + now := config.Now() + result, err := tx.Exec(` + UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ? + WHERE chain_id = ? AND chain_position = ? + `, now, now, task.ChainID, task.ChainPosition) + if err != nil { + return err + } + successorPromoted, err := result.RowsAffected() + if err != nil { + return err + } + if successorPromoted == 0 { + if _, err := tx.Exec(`UPDATE task_chains SET status = 'completed' WHERE id = ?`, task.ChainID); err != nil { + return err + } + } + } + + return tx.Commit() +} + // ErrChainTaskLocked is returned by CompleteNativeTask when the task // belongs to a chain but isn't the currently-unlocked position -- without // this guard, completing a locked task directly by id (bypassing the UI, diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go index d82576e..9a7e55f 100644 --- a/internal/store/native_tasks_test.go +++ b/internal/store/native_tasks_test.go @@ -698,3 +698,23 @@ func TestCreateNextIteration_CarriesEstimatedMinutesForward(t *testing.T) { t.Errorf("EstimatedMinutes = %d, want 60 (carried forward)", next.EstimatedMinutes) } } + +func TestDeleteNativeTask_PlainTask_Removed(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.DeleteNativeTask("real-1"); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + if _, err := s.GetNativeTaskByID("real-1"); !errors.Is(err, ErrNativeTaskNotFound) { + t.Errorf("err = %v, want ErrNativeTaskNotFound", err) + } +} + +func TestDeleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.DeleteNativeTask("does-not-exist"); !errors.Is(err, ErrNativeTaskNotFound) { + t.Errorf("err = %v, want ErrNativeTaskNotFound", err) + } +} diff --git a/web/templates/partials/task-detail.html b/web/templates/partials/task-detail.html index 135c38f..abcc5b9 100644 --- a/web/templates/partials/task-detail.html +++ b/web/templates/partials/task-detail.html @@ -1,15 +1,28 @@ {{define "task-detail"}} <div class="p-4"> - <h3 class="font-semibold text-white mb-3">{{.Title}}</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="{{.ID}}"> <input type="hidden" name="source" value="{{.Source}}"> + <label class="block text-sm font-medium text-white/70 mb-1">Title</label> + <input type="text" name="title" value="{{.Title}}" required + class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm font-semibold text-white placeholder-white/50 mb-3"> <label class="block text-sm font-medium text-white/70 mb-1">Description</label> <textarea name="description" class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3 h-32">{{.Description}}</textarea> <button type="submit" class="w-full bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg text-sm font-medium">Save</button> </form> {{if .IsDoot}} + <button type="button" + class="w-full mt-3 bg-red-500/10 hover:bg-red-500/20 text-red-300 px-4 py-2 rounded-lg text-sm font-medium" + hx-delete="/tasks/{{.ID}}?source={{.Source}}" + hx-confirm="Delete this task? This can't be undone." + hx-swap="none" + hx-on::after-request="if(event.detail.successful) { closeTaskModal(); htmx.trigger(document.body, 'refresh-tasks'); }"> + Delete Task + </button> + {{end}} + + {{if .IsDoot}} <div class="mt-4 pt-4 border-t border-white/10"> <h4 class="text-sm font-medium text-white/70 mb-2">Recurrence</h4> {{if .RecurrenceFreq}} diff --git a/web/templates/partials/timeline-tab.html b/web/templates/partials/timeline-tab.html index d6f410e..c232001 100644 --- a/web/templates/partials/timeline-tab.html +++ b/web/templates/partials/timeline-tab.html @@ -147,7 +147,13 @@ hx-swap="outerHTML" class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> {{end}} - <span class="{{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}{{multiDayLabel .}}</span> + <span class="{{if .IsCompleted}}line-through text-white/50{{end}} {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}}cursor-pointer hover:underline{{end}}" + {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} + 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')" + {{end}}>{{.Title}}{{multiDayLabel .}}</span> {{if .IsOverdue}}<span class="overdue-badge">overdue</span>{{end}} {{if .ChainTotal}}<span class="chain-badge">{{.ChainPosition}}/{{.ChainTotal}}</span>{{end}} {{if eq .BucketState "active"}}<span class="chain-badge" style="cursor:pointer" hx-post="/defer-atom" hx-vals='{"id": "{{.ID}}"}' hx-swap="none">defer</span>{{end}} @@ -203,7 +209,13 @@ onclick="event.stopPropagation();" class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0"> {{end}} - <span class="calendar-event-title {{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span> + <span class="calendar-event-title {{if .IsCompleted}}line-through text-white/50{{end}} {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}}cursor-pointer hover:underline{{end}}" + {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} + hx-get="/tasks/detail?id={{.ID}}&source={{.Source}}" + hx-target="#task-edit-content" + hx-swap="innerHTML" + onclick="event.stopPropagation(); document.getElementById('task-edit-modal').classList.remove('hidden')" + {{end}}>{{.Title}}</span> {{if .ChainTotal}}<span class="chain-badge">{{.ChainPosition}}/{{.ChainTotal}}</span>{{end}} {{if eq .BucketState "active"}}<span class="chain-badge" style="cursor:pointer" hx-post="/defer-atom" hx-vals='{"id": "{{.ID}}"}' hx-swap="none">defer</span>{{end}} </div> @@ -253,7 +265,13 @@ class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer shrink-0"> {{end}} <!-- Title --> - <span class="flex-1 text-sm text-white/80 {{if .IsCompleted}}line-through text-white/40{{end}} truncate"> + <span class="flex-1 text-sm text-white/80 {{if .IsCompleted}}line-through text-white/40{{end}} truncate {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}}cursor-pointer hover:underline{{end}}" + {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} + 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')" + {{end}}> {{.Title}}{{multiDayLabel .}} {{if and (eq .MultiDayVariant "") .EndTime}}<span class="text-white/30 text-xs ml-1">– {{.EndTime.Format "3:04 PM"}}</span>{{end}} {{if .ChainTotal}}<span class="chain-badge">{{.ChainPosition}}/{{.ChainTotal}}</span>{{end}} @@ -330,7 +348,13 @@ <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 .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</h3> + <h3 class="text-sm text-white font-medium break-words {{if .IsCompleted}}line-through text-white/50{{end}} {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}}cursor-pointer hover:underline{{end}}" + {{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} + 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')" + {{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"> |
