summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agent/worklog.md1
-rw-r--r--cmd/dashboard/main.go9
-rw-r--r--docs/superpowers/specs/2026-07-15-linear-task-chains-design.md2
-rw-r--r--internal/handlers/atoms.go16
-rw-r--r--internal/handlers/atoms_test.go53
-rw-r--r--internal/handlers/chains_web.go109
-rw-r--r--internal/handlers/chains_web_test.go213
-rw-r--r--internal/handlers/handlers.go13
-rw-r--r--internal/handlers/widget.go4
-rw-r--r--internal/models/atom.go16
-rw-r--r--internal/models/types.go11
-rw-r--r--internal/store/chains.go111
-rw-r--r--internal/store/chains_test.go68
-rw-r--r--internal/store/native_tasks.go26
-rw-r--r--web/templates/index.html17
-rw-r--r--web/templates/partials/chain-detail.html52
-rw-r--r--web/templates/partials/tasks-tab.html40
17 files changed, 726 insertions, 35 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md
index 932cad8..34f6cb4 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -4,6 +4,7 @@
Cleaned Backlog
## Recently Completed
+- **Tasks tab rework: Chains section, chain checklist modal, chain-completion lock guard, project-name visibility** — the flat Tasks-tab atom list was silently dumping every chain step (locked and unlocked) and dormant bucket-pool items into the grid as ordinary undated cards, with no chain/project context and, worse, a raw Complete checkbox that `CompleteNativeTask` didn't actually guard against for locked steps (would have corrupted the WIP-1 invariant if clicked). Fixed: `CompleteNativeTask` now returns `ErrChainTaskLocked` for a locked chain task (mapped to 400 in both the widget and web complete-atom handlers); chain tasks (locked or unlocked) and dormant bucket items are excluded from the flat atom list entirely; a new "Chains" section on the Tasks tab shows one card per active/paused chain (`GetChains`, `BuildChainSummaries`) with the current step, N/M progress, and a click-through to a new modal (`GET /chains/{id}`, `chain-detail.html`) listing every position in order (locked/unlocked/completed) with pause/resume/abandon buttons -- the web checklist view originally deferred as Android-only. Along the way, found and fixed a real bug this surfaced: resuming a paused chain only flipped the status flag, never actually unlocking the deferred successor, so a chain paused right after a completion would stay stuck forever -- `SetChainStatus` now catches up the deferred advancement on resume, idempotently (no due-date reset if nothing was actually stuck). Regular atom cards also gained a project-name chip for general visibility. Fully covered by store+handler tests (`go test ./...` green). Not yet deployed.
- **Linear task chains + recurring maintenance buckets** — implemented the last two items from `[[doot-future-task-scheduling-ideas]]` (items 1 and 2, budgets/availability and labels/projects, turned out to already be shipped -- their spec status headers and the budgets plan's checkboxes had just never been updated to say so; corrected both). Chains: `task_chains` table + `chain_id`/`chain_position`/`chain_unlocked` on `native_tasks` (migration 026), WIP-limit-1 sequencing wired into `CompleteNativeTask`'s existing recurrence-hook pattern, locked tasks excluded from all date-based queries, 5 new `/api/widget/chains*` endpoints, a position badge ("N/M") on web timeline rows and the Android widget row. Buckets: `maintenance_buckets` table + `bucket_id`/`bucket_state`/`bucket_last_active_at` on `native_tasks` (migration 027), staleness-then-priority selection scoring, a new `RunBucketCycleCheck` scheduler loop mirroring `RunRecurrenceCheck`, 5 new endpoints including the distinct Defer action (returns to pool without crediting completion, unlike Complete), a Defer button on both web timeline rows and the Android widget row. Both fully covered by store+handler tests (`go test ./...` green). Deferred (backend/API exists, UI doesn't): a dedicated Android chain-checklist screen, and bucket-management create/add-item screens on web or Android -- both explicitly out of scope in their specs' own interviews. Not yet deployed or built into the Android APK.
- **Task labels and projects** — doot-native tasks get a lightweight Projects concept (name + user-assignable color, exactly one per task) and the previously-dormant `Labels` field is finally wired up end-to-end (free-text tags, each assigned a deterministic color on first use). Both are inherited automatically across recurring task series, just like content/description already are. Editable in the task-detail popup (project picker with inline "create new" + color swatches, a label chip editor); the widget's time-grid rows show a small project-color accent. Trello/Google Tasks cards are unaffected. Built via a 9-task subagent-driven plan, all tasks reviewed clean; deployed server + widget APK.
- **Widget polish: task/event font parity, instant task completion, recurrence dialog redesign** — TaskRow's title color (a flat hardcoded gray) now matches EventBlock's (Color.White, dimmed only when past) at the same size/weight — the color gap was reading as a font mismatch. CompleteTaskAction now optimistically removes the completed item from the cached list and re-renders immediately (matching RefreshTaskAction's existing pattern) instead of waiting for the full complete->fetch->render round trip. Redesigned RecurrenceEditDialog: the 4 frequency chips and 7 weekday chips were each in a non-wrapping Row (overflowing/cut off on real phone widths) — now FlowRow-based so they wrap; single-letter weekday chips; a narrow fixed-width interval field with a correctly-pluralized unit label (was "Every N dailys"); muted section labels for visual structure. Deployed new APK.
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 893a46f..3699a12 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -161,7 +161,7 @@ func main() {
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(config.RequestTimeout))
r.Use(appmiddleware.SecurityHeaders(cfg.Debug)) // Security headers
- r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
+ r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
// Service gateway — upstream services proxied through doot's auth + SSL layer.
// Each mount registers a path prefix, upstream URL, and any public webhook paths.
@@ -296,7 +296,6 @@ func main() {
})
}
-
// Protected routes (auth required)
r.Group(func(r chi.Router) {
r.Use(authHandlers.Middleware().CSRFProtect)
@@ -330,6 +329,12 @@ func main() {
r.Post("/uncomplete-atom", h.HandleUncompleteAtom)
r.Post("/defer-atom", h.HandleDeferAtom)
+ // Chain checklist view (Tasks tab)
+ r.Get("/chains/{id}", h.HandleChainDetailView)
+ r.Post("/chains/{id}/pause", h.HandleChainPause)
+ r.Post("/chains/{id}/resume", h.HandleChainResume)
+ r.Post("/chains/{id}/abandon", h.HandleChainAbandon)
+
// Unified Quick Add (for Tasks tab)
r.Post("/unified-add", h.HandleUnifiedAdd)
r.Get("/partials/lists", h.HandleGetListsOptions)
diff --git a/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md b/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md
index 16e5337..13d9077 100644
--- a/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md
+++ b/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md
@@ -1,6 +1,6 @@
# Linear Task Chains with WIP Limits — Design
-**Status:** Implemented 2026-07-17. Backend (migration 026, store, HTTP API), web timeline position badge, and Android widget data layer + row badge all shipped with tests (`go test ./...` green). See `docs/superpowers/plans/2026-07-17-linear-task-chains.md` for the task breakdown. Not built: a dedicated Android checklist screen for browsing a full chain (locked + unlocked) -- the row-level badge and `GET /api/widget/chains/{id}` endpoint exist, but the standalone browsable view described in "Visibility" below is deferred, consistent with this spec's own "Web UI specifics for the chain/checklist view" being out of scope.
+**Status:** Implemented 2026-07-17-18. Backend (migration 026, store, HTTP API), web timeline position badge, Android widget data layer + row badge, and (2026-07-18, extending beyond this spec's original web-out-of-scope note, per user request) a full web checklist view -- a "Chains" section on the Tasks tab plus a modal listing every position in order with pause/resume/abandon -- all shipped with tests (`go test ./...` green). See `docs/superpowers/plans/2026-07-17-linear-task-chains.md` for the task breakdown. Still not built: the equivalent dedicated Android checklist screen (row badge and `GET /api/widget/chains/{id}` exist; no standalone Android view yet).
## Context
diff --git a/internal/handlers/atoms.go b/internal/handlers/atoms.go
index 9474150..16cd003 100644
--- a/internal/handlers/atoms.go
+++ b/internal/handlers/atoms.go
@@ -30,8 +30,22 @@ func BuildUnifiedAtomList(s *store.Store, claudomator api.ClaudomatorClient) ([]
atoms := make([]models.Atom, 0, len(gTasks)+len(nativeTasks))
- // Add native doot tasks (GetNativeTasks already filters completed=0)
+ // Add native doot tasks (GetNativeTasks already filters completed=0).
+ // Chain tasks (locked or unlocked) and dormant bucket-pool items are
+ // deliberately excluded here. Chains get their own "Chains" section
+ // (see BuildChainSummaries) that owns the whole lifecycle -- viewing
+ // every step, completing the current one, pause/resume/abandon -- in
+ // one place, rather than the current step also duplicating into this
+ // flat list and the locked steps cluttering it with non-actionable
+ // cards. Dormant bucket items aren't actionable until picked, per the
+ // bucket design.
for _, task := range nativeTasks {
+ if task.ChainID != "" {
+ continue
+ }
+ if task.BucketState == "dormant" {
+ continue
+ }
atoms = append(atoms, models.NativeTaskToAtom(task))
}
diff --git a/internal/handlers/atoms_test.go b/internal/handlers/atoms_test.go
index 521b147..7cb94a7 100644
--- a/internal/handlers/atoms_test.go
+++ b/internal/handlers/atoms_test.go
@@ -44,6 +44,59 @@ func TestBuildUnifiedAtomList_WithNativeTasks(t *testing.T) {
}
}
+func TestBuildUnifiedAtomList_ExcludesLockedChainTasks(t *testing.T) {
+ s, err := store.New(":memory:", "../../migrations")
+ if err != nil {
+ t.Fatalf("failed to create in-memory store: %v", err)
+ }
+ defer s.Close()
+
+ if _, err := s.CreateChain("Track", []models.ChainTaskInput{
+ {Content: "Step 1"}, {Content: "Step 2"}, {Content: "Step 3"},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ atoms, _, err := BuildUnifiedAtomList(s, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, a := range atoms {
+ if a.Title == "Step 1" || a.Title == "Step 2" || a.Title == "Step 3" {
+ t.Errorf("chain task %q should not appear in the flat atom list (chains get their own Chains section)", a.Title)
+ }
+ }
+}
+
+func TestBuildUnifiedAtomList_ExcludesDormantBucketItems(t *testing.T) {
+ s, err := store.New(":memory:", "../../migrations")
+ if err != nil {
+ t.Fatalf("failed to create in-memory store: %v", err)
+ }
+ defer s.Close()
+
+ bucket, err := s.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.CreateNativeTask(models.Task{ID: "pool-item", Content: "Clean gutters", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.AddBucketItem(bucket.ID, "pool-item"); err != nil {
+ t.Fatal(err)
+ }
+
+ atoms, _, err := BuildUnifiedAtomList(s, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, a := range atoms {
+ if a.Title == "Clean gutters" {
+ t.Error("dormant bucket-pool item should not appear in the flat atom list")
+ }
+ }
+}
+
func TestBuildUnifiedAtomList_WithClaudomator(t *testing.T) {
s, err := store.New(":memory:", "../../migrations")
if err != nil {
diff --git a/internal/handlers/chains_web.go b/internal/handlers/chains_web.go
new file mode 100644
index 0000000..862bec3
--- /dev/null
+++ b/internal/handlers/chains_web.go
@@ -0,0 +1,109 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "task-dashboard/internal/models"
+ "task-dashboard/internal/store"
+)
+
+// BuildChainSummaries returns one summary per active/paused chain --
+// project name, the currently-unlocked task (nil if none, e.g. a chain
+// that just completed and hasn't been re-fetched into the active/paused
+// set yet), and 1-indexed position/total for a "N/M" progress display.
+// Backs the Tasks tab's Chains section.
+func BuildChainSummaries(s *store.Store) ([]models.ChainSummary, error) {
+ chains, err := s.GetChains()
+ if err != nil {
+ return nil, err
+ }
+ summaries := make([]models.ChainSummary, 0, len(chains))
+ for _, chain := range chains {
+ project, err := s.GetProjectByID(chain.ProjectID)
+ if err != nil {
+ continue // orphaned project reference -- skip rather than fail the whole tab
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ return nil, err
+ }
+ summary := models.ChainSummary{Chain: chain, ProjectName: project.Name, Total: len(tasks)}
+ for i, t := range tasks {
+ if t.ChainUnlocked {
+ task := t
+ summary.CurrentTask = &task
+ summary.Position = i + 1
+ break
+ }
+ }
+ summaries = append(summaries, summary)
+ }
+ return summaries, nil
+}
+
+// HandleChainDetailView renders the full ordered checklist for a chain --
+// locked and unlocked tasks both -- for the web Tasks tab's chain modal.
+// This is the "visible in the tasks list" requirement from the chains
+// design spec, met on web (the spec originally scoped this to Android
+// only; extended to web per user request).
+func (h *Handler) HandleChainDetailView(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ chain, err := h.store.GetChain(id)
+ if err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "chain not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ project, err := h.store.GetProjectByID(chain.ProjectID)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ tasks, err := h.store.GetChainTasks(id)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ data := struct {
+ Chain models.Chain
+ ProjectName string
+ Tasks []models.Task
+ }{Chain: *chain, ProjectName: project.Name, Tasks: tasks}
+ HTMLResponse(w, h.renderer, "chain-detail", data)
+}
+
+// HandleChainPause, HandleChainResume, and HandleChainAbandon are the
+// session-authed web equivalents of the widget API's chain-status
+// endpoints, for the Tasks tab's chain modal.
+func (h *Handler) HandleChainPause(w http.ResponseWriter, r *http.Request) {
+ h.setChainStatusWeb(w, r, "paused")
+}
+
+func (h *Handler) HandleChainResume(w http.ResponseWriter, r *http.Request) {
+ h.setChainStatusWeb(w, r, "active")
+}
+
+func (h *Handler) HandleChainAbandon(w http.ResponseWriter, r *http.Request) {
+ h.setChainStatusWeb(w, r, "abandoned")
+}
+
+func (h *Handler) setChainStatusWeb(w http.ResponseWriter, r *http.Request, status string) {
+ id := chi.URLParam(r, "id")
+ if err := h.store.SetChainStatus(id, status); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "chain not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to update chain", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("HX-Reswap", "none")
+ w.Header().Set("HX-Trigger", "refresh-tasks")
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/internal/handlers/chains_web_test.go b/internal/handlers/chains_web_test.go
new file mode 100644
index 0000000..d27dab9
--- /dev/null
+++ b/internal/handlers/chains_web_test.go
@@ -0,0 +1,213 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "task-dashboard/internal/models"
+)
+
+func TestBuildChainSummaries_ReturnsPositionAndCurrentTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ chain, err := db.CreateChain("Track", []models.ChainTaskInput{
+ {Content: "Step 1"}, {Content: "Step 2"}, {Content: "Step 3"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ summaries, err := BuildChainSummaries(db)
+ if err != nil {
+ t.Fatalf("BuildChainSummaries: %v", err)
+ }
+ if len(summaries) != 1 {
+ t.Fatalf("len(summaries) = %d, want 1", len(summaries))
+ }
+ s := summaries[0]
+ if s.Chain.ID != chain.ID || s.ProjectName != "Track" {
+ t.Errorf("summary = %+v", s)
+ }
+ if s.Total != 3 || s.Position != 1 {
+ t.Errorf("Position/Total = %d/%d, want 1/3", s.Position, s.Total)
+ }
+ if s.CurrentTask == nil || s.CurrentTask.Content != "Step 1" {
+ t.Errorf("CurrentTask = %+v, want Step 1", s.CurrentTask)
+ }
+}
+
+func TestBuildChainSummaries_ExcludesAbandonedAndCompleted(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ chain, err := db.CreateChain("Track", []models.ChainTaskInput{{Content: "Only step"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.SetChainStatus(chain.ID, "abandoned"); err != nil {
+ t.Fatal(err)
+ }
+
+ summaries, err := BuildChainSummaries(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(summaries) != 0 {
+ t.Errorf("summaries = %+v, want none (abandoned chain excluded)", summaries)
+ }
+}
+
+func TestHandleChainDetailView_RendersFullOrderedList(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ chain, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("GET", "/chains/"+chain.ID, nil), "id", chain.ID)
+ w := httptest.NewRecorder()
+ h.HandleChainDetailView(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+
+ mock := h.renderer.(*MockRenderer)
+ if len(mock.Calls) == 0 {
+ t.Fatal("expected renderer to be called")
+ }
+ lastCall := mock.Calls[len(mock.Calls)-1]
+ if lastCall.Name != "chain-detail" {
+ t.Errorf("template = %q, want chain-detail", lastCall.Name)
+ }
+ data, ok := lastCall.Data.(struct {
+ Chain models.Chain
+ ProjectName string
+ Tasks []models.Task
+ })
+ if !ok {
+ t.Fatalf("unexpected data type %T", lastCall.Data)
+ }
+ if data.ProjectName != "Track" || len(data.Tasks) != 2 {
+ t.Errorf("data = %+v", data)
+ }
+ if data.Tasks[0].Content != "Step 1" || data.Tasks[1].Content != "Step 2" {
+ t.Errorf("tasks out of order: %+v", data.Tasks)
+ }
+}
+
+func TestHandleChainDetailView_UnknownID_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := withURLParam(httptest.NewRequest("GET", "/chains/nope", nil), "id", "nope")
+ w := httptest.NewRecorder()
+ h.HandleChainDetailView(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleChainPauseResumeAbandon_Web(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ chain, err := db.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/pause", nil), "id", chain.ID)
+ w := httptest.NewRecorder()
+ h.HandleChainPause(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("pause status = %d, want 200", w.Code)
+ }
+ if w.Header().Get("HX-Trigger") != "refresh-tasks" {
+ t.Errorf("HX-Trigger = %q, want refresh-tasks", w.Header().Get("HX-Trigger"))
+ }
+ paused, err := db.GetChain(chain.ID)
+ if err != nil || paused.Status != "paused" {
+ t.Fatalf("chain after pause = %+v, err=%v", paused, err)
+ }
+
+ req = withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/resume", nil), "id", chain.ID)
+ w = httptest.NewRecorder()
+ h.HandleChainResume(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("resume status = %d, want 200", w.Code)
+ }
+
+ req = withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/abandon", nil), "id", chain.ID)
+ w = httptest.NewRecorder()
+ h.HandleChainAbandon(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("abandon status = %d, want 200", w.Code)
+ }
+ abandoned, err := db.GetChain(chain.ID)
+ if err != nil || abandoned.Status != "abandoned" {
+ t.Fatalf("chain after abandon = %+v, err=%v", abandoned, err)
+ }
+}
+
+func TestHandleChainPause_UnknownID_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := withURLParam(httptest.NewRequest("POST", "/chains/nope/pause", nil), "id", "nope")
+ w := httptest.NewRecorder()
+ h.HandleChainPause(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleTabTasks_IncludesChainSummaries(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if _, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/tabs/tasks", nil)
+ w := httptest.NewRecorder()
+ h.HandleTabTasks(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+
+ mock := h.renderer.(*MockRenderer)
+ lastCall := mock.Calls[len(mock.Calls)-1]
+ if lastCall.Name != "tasks-tab" {
+ t.Errorf("template = %q, want tasks-tab", lastCall.Name)
+ }
+ data, ok := lastCall.Data.(struct {
+ Atoms []models.Atom
+ FutureAtoms []models.Atom
+ Boards []models.Board
+ Chains []models.ChainSummary
+ Today string
+ })
+ if !ok {
+ t.Fatalf("unexpected data type %T", lastCall.Data)
+ }
+ if len(data.Chains) != 1 || data.Chains[0].CurrentTask == nil || data.Chains[0].CurrentTask.Content != "Step 1" {
+ t.Errorf("Chains = %+v", data.Chains)
+ }
+ for _, a := range data.Atoms {
+ if a.Title == "Step 1" {
+ t.Error("chain task should not also appear in the flat Atoms list")
+ }
+ }
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 343d0b1..54c6a70 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"crypto/rand"
+ "errors"
"fmt"
"html/template"
"log"
@@ -46,6 +47,7 @@ func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googl
// Template functions
funcMap := template.FuncMap{
"subtract": func(a, b int) int { return a - b },
+ "add": func(a, b int) int { return a + b },
// multiDayLabel returns the "(starts/ends HH:MM)" suffix for a
// multi-day calendar event's row on the day it's rendered, or ""
// for a normal item or a "spans" (pass-through) day, which shows
@@ -580,6 +582,10 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl
}
if err != nil {
+ if errors.Is(err, store.ErrChainTaskLocked) {
+ JSONError(w, http.StatusBadRequest, "Task is locked in its chain", err)
+ return
+ }
action := "complete"
if !complete {
action = "reopen"
@@ -858,6 +864,11 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
return
}
+ chainSummaries, err := BuildChainSummaries(h.store)
+ if err != nil {
+ log.Printf("Warning: failed to build chain summaries: %v", err)
+ }
+
SortAtomsByUrgency(atoms)
currentAtoms, futureAtoms := PartitionAtomsByTime(atoms)
@@ -865,11 +876,13 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
Atoms []models.Atom
FutureAtoms []models.Atom
Boards []models.Board
+ Chains []models.ChainSummary
Today string
}{
Atoms: currentAtoms,
FutureAtoms: futureAtoms,
Boards: boards,
+ Chains: chainSummaries,
Today: config.Now().Format("2006-01-02"),
}
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go
index a7f7b66..915909e 100644
--- a/internal/handlers/widget.go
+++ b/internal/handlers/widget.go
@@ -391,6 +391,10 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "task not found", http.StatusNotFound)
return
}
+ if errors.Is(err, store.ErrChainTaskLocked) {
+ http.Error(w, "task is locked in its chain", http.StatusBadRequest)
+ return
+ }
http.Error(w, "failed to complete task", http.StatusInternalServerError)
return
}
diff --git a/internal/models/atom.go b/internal/models/atom.go
index bcf3c78..824faaf 100644
--- a/internal/models/atom.go
+++ b/internal/models/atom.go
@@ -36,12 +36,16 @@ type Atom struct {
Priority int // Normalized: 1 (Low) to 4 (Urgent)
+ // ProjectName is set for doot-native tasks with a project assigned
+ // (empty for every other source, and for unassigned native tasks).
+ ProjectName string
+
// UI Helpers (to be populated by mappers)
- 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)
+ 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{}
@@ -89,6 +93,7 @@ func NativeTaskToAtom(t Task) Atom {
DueDate: t.DueDate,
CreatedAt: t.CreatedAt,
Priority: priority,
+ ProjectName: t.ProjectName,
SourceIcon: "✅",
ColorClass: "border-green-500",
Raw: t,
@@ -153,4 +158,3 @@ func GoogleTaskToAtom(t GoogleTask) Atom {
Raw: t,
}
}
-
diff --git a/internal/models/types.go b/internal/models/types.go
index aa65e18..846ce50 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -51,6 +51,17 @@ type Chain struct {
CreatedAt time.Time `json:"created_at"`
}
+// ChainSummary is a chain plus enough denormalized context (project name,
+// the currently-unlocked task, position/total) to render a single Tasks-tab
+// card without the template needing its own store access.
+type ChainSummary struct {
+ Chain Chain
+ ProjectName string
+ CurrentTask *Task // nil if the chain has no unlocked task right now
+ Position int // 1-indexed
+ Total int
+}
+
// ChainTaskInput seeds one position of a chain at creation time. Priority
// of 0 (the zero value, JSON field omitted) defaults to 1, matching
// CreateNativeTask's default.
diff --git a/internal/store/chains.go b/internal/store/chains.go
index f3bd0d7..f854678 100644
--- a/internal/store/chains.go
+++ b/internal/store/chains.go
@@ -84,6 +84,31 @@ func (s *Store) GetChain(id string) (*models.Chain, error) {
return &c, nil
}
+// GetChains returns every active or paused chain, oldest first. Completed
+// and abandoned chains are excluded -- they're done, not part of "current
+// work" surfaces like the Tasks tab.
+func (s *Store) GetChains() ([]models.Chain, error) {
+ rows, err := s.db.Query(`
+ SELECT id, project_id, status, created_at FROM task_chains
+ WHERE status IN ('active', 'paused')
+ ORDER BY created_at ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var chains []models.Chain
+ for rows.Next() {
+ var c models.Chain
+ if err := rows.Scan(&c.ID, &c.ProjectID, &c.Status, &c.CreatedAt); err != nil {
+ return nil, err
+ }
+ chains = append(chains, c)
+ }
+ return chains, rows.Err()
+}
+
// GetChainTasks returns every task in the chain, locked and unlocked both,
// ordered by chain_position -- the full checklist view.
func (s *Store) GetChainTasks(chainID string) ([]models.Task, error) {
@@ -104,8 +129,34 @@ func (s *Store) GetChainTasks(chainID string) ([]models.Task, error) {
}
// SetChainStatus updates a chain's status (active/paused/abandoned/completed).
-// Returns ErrNativeTaskNotFound if id doesn't match any row.
+// Resuming a paused chain (status "active" from a current status of
+// "paused") catches up any advancement that was deferred while paused --
+// per the design, "the chain must be explicitly resumed for the next task
+// to unlock," i.e. resuming itself performs the unlock, not merely
+// re-arming future completions to do so. Without this, a chain paused
+// immediately after a completion (before its successor could unlock) would
+// stay stuck forever: no task is ever unlocked, so no future completion
+// could trigger advanceChain either. Returns ErrNativeTaskNotFound if id
+// doesn't match any row.
func (s *Store) SetChainStatus(id, status string) error {
+ if status == "active" {
+ current, err := s.GetChain(id)
+ if err != nil {
+ return err
+ }
+ if current.Status == "paused" {
+ maxCompleted, err := s.maxCompletedChainPosition(id)
+ if err != nil {
+ return err
+ }
+ if maxCompleted != nil {
+ if err := s.advanceChain(id, *maxCompleted); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
result, err := s.db.Exec(`UPDATE task_chains SET status = ? WHERE id = ?`, status, id)
if err != nil {
return err
@@ -113,34 +164,50 @@ func (s *Store) SetChainStatus(id, status string) error {
return checkRowsAffected(result)
}
-// advanceChain is called from CompleteNativeTask when the just-completed
-// task has a chain_id set. Per the design, a paused chain does not
-// auto-advance -- it must be explicitly resumed first. Completing the last
-// position marks the chain completed instead of advancing.
-func (s *Store) advanceChain(chainID string, completedPosition int) error {
- chain, err := s.GetChain(chainID)
+// maxCompletedChainPosition returns the highest chain_position among
+// completed tasks in the chain, or nil if none are completed yet.
+func (s *Store) maxCompletedChainPosition(chainID string) (*int, error) {
+ var pos sql.NullInt64
+ err := s.db.QueryRow(`
+ SELECT MAX(chain_position) FROM native_tasks WHERE chain_id = ? AND completed = 1
+ `, chainID).Scan(&pos)
if err != nil {
- return err
+ return nil, err
}
- if chain.Status == "paused" {
- return nil
+ if !pos.Valid {
+ return nil, nil
}
+ p := int(pos.Int64)
+ return &p, nil
+}
- now := config.Now()
- result, err := s.db.Exec(`
- UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ?
- WHERE chain_id = ? AND chain_position = ?
- `, now, now, chainID, completedPosition+1)
- if err != nil {
- return err
+// advanceChain is called from CompleteNativeTask (when the just-completed
+// task has a chain_id set and the chain isn't paused) and from
+// SetChainStatus's resume catch-up. Idempotent: if the successor position
+// is already unlocked, it's left untouched (so a resume catch-up run
+// against a chain that was never actually stuck is a no-op, not a
+// due_date-resetting re-unlock). Completing/catching-up-to the last
+// position marks the chain completed instead of unlocking a successor.
+func (s *Store) advanceChain(chainID string, completedPosition int) error {
+ var successorID string
+ var successorUnlocked bool
+ err := s.db.QueryRow(`
+ SELECT id, chain_unlocked FROM native_tasks WHERE chain_id = ? AND chain_position = ?
+ `, chainID, completedPosition+1).Scan(&successorID, &successorUnlocked)
+ if err == sql.ErrNoRows {
+ // No next position -- the completed task was the last in the chain.
+ return s.SetChainStatus(chainID, "completed")
}
- affected, err := result.RowsAffected()
if err != nil {
return err
}
- if affected == 0 {
- // No next position -- the completed task was the last in the chain.
- return s.SetChainStatus(chainID, "completed")
+ if successorUnlocked {
+ return nil
}
- return nil
+
+ now := config.Now()
+ _, err = s.db.Exec(`
+ UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ? WHERE id = ?
+ `, now, now, successorID)
+ return err
}
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go
index 1d16508..6359d11 100644
--- a/internal/store/chains_test.go
+++ b/internal/store/chains_test.go
@@ -132,10 +132,19 @@ func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) {
t.Error("position 1 should still be locked while chain is paused")
}
- // Resuming re-enables advancement on the *next* completion.
+ // Resuming performs the deferred unlock itself -- position 1 becomes
+ // completable immediately, not only after some future completion.
if err := s.SetChainStatus(chain.ID, "active"); err != nil {
t.Fatal(err)
}
+ resumed, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !resumed[1].ChainUnlocked {
+ t.Fatal("expected resume to unlock position 1 immediately (deferred advancement catch-up)")
+ }
+
if err := s.CompleteNativeTask(tasks[1].ID); err != nil {
t.Fatalf("CompleteNativeTask after resume: %v", err)
}
@@ -148,6 +157,63 @@ func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) {
}
}
+func TestCompleteNativeTask_LockedChainTask_ReturnsErrChainTaskLocked(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.CompleteNativeTask(tasks[1].ID); err != ErrChainTaskLocked {
+ t.Errorf("err = %v, want ErrChainTaskLocked", err)
+ }
+
+ // Confirm nothing was mutated -- still locked, still not completed.
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after[1].Completed || after[1].ChainUnlocked {
+ t.Errorf("locked task should be untouched by the rejected completion attempt: %+v", after[1])
+ }
+}
+
+func TestSetChainStatus_ResumeWithNothingStuck_DoesNotResetDueDate(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)
+ }
+ originalDueDate := *tasks[0].DueDate
+
+ // Pause and resume with nothing completed yet -- position 0 is already
+ // unlocked and should be left untouched by the resume catch-up.
+ if err := s.SetChainStatus(chain.ID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetChainStatus(chain.ID, "active"); err != nil {
+ t.Fatal(err)
+ }
+
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after[0].DueDate.Equal(originalDueDate) {
+ t.Errorf("DueDate = %v, want unchanged %v (resume catch-up should be a no-op when nothing was stuck)", after[0].DueDate, originalDueDate)
+ }
+}
+
func TestGetUndatedNativeTasks_ExcludesLockedChainTasks(t *testing.T) {
s := newNativeTasksTestStore(t)
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 6f8ba7a..11a9197 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -161,15 +161,27 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error {
return err
}
+// 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,
+// which never renders a checkbox for locked chain tasks) would still run
+// advanceChain against the wrong position, breaking the chain's WIP-1
+// invariant.
+var ErrChainTaskLocked = errors.New("task is locked in its chain")
+
// CompleteNativeTask marks a task as completed. If it's the latest
// occurrence of a recurring series (no newer row exists yet), it also
// creates the next iteration. Returns ErrNativeTaskNotFound if id doesn't
-// match any row.
+// match any row, or ErrChainTaskLocked if it's a locked (not yet
+// actionable) chain task.
func (s *Store) CompleteNativeTask(id string) error {
task, err := s.GetNativeTaskByID(id)
if err != nil {
return err
}
+ if task.ChainID != "" && !task.ChainUnlocked {
+ return ErrChainTaskLocked
+ }
result, err := s.db.Exec(`
UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
@@ -182,9 +194,19 @@ func (s *Store) CompleteNativeTask(id string) error {
}
if task.ChainID != "" {
- if err := s.advanceChain(task.ChainID, task.ChainPosition); err != nil {
+ chain, err := s.GetChain(task.ChainID)
+ if err != nil {
return err
}
+ // A paused chain does not auto-advance -- completing its unlocked
+ // task is still allowed (it's the one actionable step), but the
+ // successor stays locked until the chain is explicitly resumed
+ // (see SetChainStatus's resume catch-up).
+ if chain.Status != "paused" {
+ if err := s.advanceChain(task.ChainID, task.ChainPosition); err != nil {
+ return err
+ }
+ }
}
if task.BucketID != "" {
diff --git a/web/templates/index.html b/web/templates/index.html
index 19f1c7e..7be2c52 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -262,11 +262,15 @@
if (e.key === 'Escape') {
closeActionModal();
closeTaskModal();
+ closeChainModal();
}
});
function closeTaskModal() {
document.getElementById('task-edit-modal').classList.add('hidden');
}
+ function closeChainModal() {
+ document.getElementById('chain-view-modal').classList.add('hidden');
+ }
</script>
<!-- Task Edit Modal -->
@@ -282,6 +286,19 @@
</div>
</div>
+ <!-- Chain Checklist Modal -->
+ <div id="chain-view-modal" class="hidden fixed inset-0 bg-modal-overlay flex items-center justify-center p-4 z-50">
+ <div class="bg-modal backdrop-blur-sm rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col" style="box-shadow: 0 0 20px black;">
+ <div class="p-4 border-b border-white/10 flex justify-between items-center flex-shrink-0">
+ <h2 class="font-medium text-white">Chain</h2>
+ <button onclick="closeChainModal()" class="text-white/40 hover:text-white">✕</button>
+ </div>
+ <div id="chain-view-content" class="overflow-y-auto">
+ <p class="p-4 text-white/50 text-sm">Loading...</p>
+ </div>
+ </div>
+ </div>
+
<div class="fixed bottom-0 right-0 p-2 text-[10px] text-white/20 pointer-events-none">
{{.BuildVersion}}
</div>
diff --git a/web/templates/partials/chain-detail.html b/web/templates/partials/chain-detail.html
new file mode 100644
index 0000000..3372f03
--- /dev/null
+++ b/web/templates/partials/chain-detail.html
@@ -0,0 +1,52 @@
+{{define "chain-detail"}}
+<div class="p-4 space-y-4">
+ <div class="flex items-center justify-between gap-2">
+ <div>
+ <h3 class="text-white font-medium">{{.ProjectName}}</h3>
+ <p class="text-xs text-white/50 mt-0.5">
+ {{.Chain.Status}}
+ {{range $i, $t := .Tasks}}{{if $t.ChainUnlocked}} &middot; step {{add $i 1}} of {{len $.Tasks}}{{end}}{{end}}
+ </p>
+ </div>
+ <div class="flex gap-2 flex-shrink-0">
+ {{if eq .Chain.Status "active"}}
+ <button hx-post="/chains/{{.Chain.ID}}/pause"
+ hx-on::after-request="if(event.detail.successful) { htmx.trigger(document.body, 'refresh-tasks'); closeChainModal(); }"
+ class="text-xs px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70">Pause</button>
+ {{else if eq .Chain.Status "paused"}}
+ <button hx-post="/chains/{{.Chain.ID}}/resume"
+ hx-on::after-request="if(event.detail.successful) { htmx.trigger(document.body, 'refresh-tasks'); closeChainModal(); }"
+ class="text-xs px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70">Resume</button>
+ {{end}}
+ <button hx-post="/chains/{{.Chain.ID}}/abandon"
+ hx-confirm="Abandon this chain? This can't be undone."
+ hx-on::after-request="if(event.detail.successful) { htmx.trigger(document.body, 'refresh-tasks'); closeChainModal(); }"
+ class="text-xs px-2 py-1 rounded bg-red-500/10 hover:bg-red-500/20 text-red-300/80">Abandon</button>
+ </div>
+ </div>
+
+ <div class="space-y-1">
+ {{range $i, $t := .Tasks}}
+ <div class="flex items-start gap-2 py-2 px-2 rounded-lg {{if $t.Completed}}opacity-50{{else if not $t.ChainUnlocked}}opacity-40{{end}}">
+ {{if $t.Completed}}
+ <span class="mt-0.5 text-green-400 flex-shrink-0">✓</span>
+ {{else if $t.ChainUnlocked}}
+ <input type="checkbox"
+ hx-post="/complete-atom"
+ hx-vals='{"id": "{{$t.ID}}", "source": "doot"}'
+ hx-on::after-request="if(event.detail.successful) { htmx.trigger(document.body, 'refresh-tasks'); htmx.ajax('GET', '/chains/{{$.Chain.ID}}', {target:'#chain-view-content', swap:'innerHTML'}); }"
+ class="mt-0.5 h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
+ {{else}}
+ <span class="mt-0.5 text-white/30 flex-shrink-0">🔒</span>
+ {{end}}
+ <div class="flex-1 min-w-0">
+ <span class="text-sm {{if $t.Completed}}line-through text-white/50{{else if $t.ChainUnlocked}}text-white{{else}}text-white/40{{end}}">{{add $i 1}}. {{$t.Content}}</span>
+ {{if $t.Description}}
+ <p class="text-xs text-white/40 mt-0.5">{{$t.Description}}</p>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+</div>
+{{end}}
diff --git a/web/templates/partials/tasks-tab.html b/web/templates/partials/tasks-tab.html
index 04c20da..b506716 100644
--- a/web/templates/partials/tasks-tab.html
+++ b/web/templates/partials/tasks-tab.html
@@ -5,6 +5,40 @@
hx-target="#tab-content"
hx-swap="innerHTML">
+ <!-- Chains -->
+ {{if .Chains}}
+ <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+ {{range .Chains}}
+ <div class="bg-card bg-card-hover transition-colors rounded-lg border border-purple-400/20">
+ <div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4">
+ {{if .CurrentTask}}
+ <input type="checkbox"
+ hx-post="/complete-atom"
+ hx-vals='{"id": "{{.CurrentTask.ID}}", "source": "doot"}'
+ hx-target="closest div.rounded-lg"
+ hx-swap="outerHTML"
+ class="mt-1 h-5 w-5 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
+ {{end}}
+ <span class="text-lg flex-shrink-0">🔗</span>
+ <div class="flex-1 min-w-0">
+ <div class="flex items-start justify-between gap-2">
+ <h3 class="text-sm text-white font-medium break-words cursor-pointer hover:underline"
+ hx-get="/chains/{{.Chain.ID}}"
+ hx-target="#chain-view-content"
+ hx-swap="innerHTML"
+ onclick="document.getElementById('chain-view-modal').classList.remove('hidden')">{{if .CurrentTask}}{{.CurrentTask.Content}}{{else}}{{.ProjectName}}{{end}}</h3>
+ </div>
+ <div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-white/50">
+ <span class="text-purple-300/80">{{.ProjectName}} &middot; {{.Position}}/{{.Total}}</span>
+ {{if eq .Chain.Status "paused"}}<span class="text-amber-300/80">paused</span>{{end}}
+ </div>
+ </div>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ {{end}}
+
<!-- Tasks List -->
{{if .Atoms}}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -35,6 +69,9 @@
{{end}}
</div>
<div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-white/50">
+ {{if .ProjectName}}
+ <span class="text-teal-300/70">{{.ProjectName}}</span>
+ {{end}}
{{if .DueDate}}
<span class="{{if .IsOverdue}}text-amber-300/80{{end}}">{{.DueDate.Format "Jan 2"}}{{if .HasSetTime}}, {{.DueDate.Format "3:04pm"}}{{end}}</span>
{{end}}
@@ -92,6 +129,9 @@
{{end}}
</div>
<div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-white/40">
+ {{if .ProjectName}}
+ <span class="text-teal-300/60">{{.ProjectName}}</span>
+ {{end}}
{{if .DueDate}}
<span>{{.DueDate.Format "Jan 2"}}{{if .HasSetTime}}, {{.DueDate.Format "3:04pm"}}{{end}}</span>
{{end}}