summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agent/worklog.md1
-rw-r--r--cmd/dashboard/main.go6
-rw-r--r--docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md2
-rw-r--r--internal/handlers/buckets_web.go131
-rw-r--r--internal/handlers/buckets_web_test.go193
-rw-r--r--internal/handlers/settings.go9
-rw-r--r--internal/models/types.go7
-rw-r--r--internal/store/buckets.go45
-rw-r--r--internal/store/buckets_web_test.go62
-rw-r--r--web/templates/partials/buckets-section.html53
-rw-r--r--web/templates/settings.html49
11 files changed, 557 insertions, 1 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md
index 34f6cb4..d41438c 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -4,6 +4,7 @@
Cleaned Backlog
## Recently Completed
+- **Bucket CRUD + read-only Projects/Labels on the Settings page** — new "Maintenance Buckets" section: create a bucket (name/cycle_days/pick_n), add a pool item by title (creates the task and assigns it in one step via a new form), remove an item, delete a bucket (unbuckets its tasks rather than deleting them). New store methods `GetBucketItems`/`DeleteBucket`. New read-only "Projects" and "Labels" sections (name + color swatch) -- simple enough that read-only was the right call, per user direction, rather than duplicating the Android popup's editing UX on web. Fully covered by store+handler tests (`go test ./...` green). Not yet deployed.
- **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.
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 3699a12..cb2b757 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -370,6 +370,12 @@ func main() {
r.Delete("/settings/features/{name}", h.HandleDeleteFeature)
r.Delete("/settings/agents/{id}", h.HandleDeleteAgent)
+ // Maintenance bucket management (Settings page)
+ r.Post("/settings/buckets", h.HandleBucketsCreate)
+ r.Delete("/settings/buckets/{id}", h.HandleBucketDelete)
+ r.Post("/settings/buckets/{id}/items", h.HandleBucketItemAdd)
+ r.Post("/settings/buckets/items/remove", h.HandleBucketItemRemove)
+
// WebSocket for notifications
r.Get("/ws/notifications", h.HandleWebSocket)
})
diff --git a/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md b/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md
index 69ce370..b137d7a 100644
--- a/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md
+++ b/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md
@@ -1,6 +1,6 @@
# Recurring Maintenance Buckets — Design
-**Status:** Implemented 2026-07-17. Backend (migration 027, store, scheduler, HTTP API), web timeline Defer action, and Android widget data layer + row Defer button all shipped with tests (`go test ./...` green). See `docs/superpowers/plans/2026-07-17-recurring-maintenance-buckets.md` for the task breakdown. Not built: a dedicated bucket-management UI (create bucket / add-remove pool items screen) on either web or Android -- the CRUD API exists and is tested, but per this spec's own "Not designed in detail here" on UI, the management surface itself is deferred.
+**Status:** Implemented 2026-07-17-18. Backend (migration 027, store, scheduler, HTTP API), web timeline Defer action, Android widget data layer + row Defer button, and (2026-07-18, per user request) a web bucket-management section on the Settings page -- create bucket, add a pool item by title (creates the task and assigns it in one step), remove an item, delete a bucket -- all shipped with tests (`go test ./...` green). See `docs/superpowers/plans/2026-07-17-recurring-maintenance-buckets.md` for the task breakdown. Still not built: the equivalent management screen on Android (per this spec's own scoping, Android was always meant to be day-to-day Complete/Defer only, with management staying a web-only surface).
## Context
diff --git a/internal/handlers/buckets_web.go b/internal/handlers/buckets_web.go
new file mode 100644
index 0000000..094e8dd
--- /dev/null
+++ b/internal/handlers/buckets_web.go
@@ -0,0 +1,131 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "task-dashboard/internal/models"
+ "task-dashboard/internal/store"
+)
+
+// BuildBucketSummaries returns every maintenance bucket with its full pool
+// (dormant and active items both). Backs the Settings page's bucket
+// management section.
+func BuildBucketSummaries(s *store.Store) ([]models.BucketSummary, error) {
+ buckets, err := s.GetBuckets()
+ if err != nil {
+ return nil, err
+ }
+ summaries := make([]models.BucketSummary, 0, len(buckets))
+ for _, bucket := range buckets {
+ items, err := s.GetBucketItems(bucket.ID)
+ if err != nil {
+ return nil, err
+ }
+ summaries = append(summaries, models.BucketSummary{Bucket: bucket, Items: items})
+ }
+ return summaries, nil
+}
+
+// HandleBucketsCreate creates a new maintenance bucket from the Settings page's form.
+func (h *Handler) HandleBucketsCreate(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+ name := strings.TrimSpace(r.FormValue("name"))
+ cycleDays, err1 := strconv.Atoi(r.FormValue("cycle_days"))
+ pickN, err2 := strconv.Atoi(r.FormValue("pick_n"))
+ if name == "" || err1 != nil || err2 != nil || cycleDays <= 0 || pickN <= 0 {
+ JSONError(w, http.StatusBadRequest, "Name, a positive cycle_days, and a positive pick_n are required", nil)
+ return
+ }
+ if _, err := h.store.CreateBucket(name, cycleDays, pickN); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to create bucket", err)
+ return
+ }
+ h.renderBucketsSection(w, r)
+}
+
+// HandleBucketItemAdd creates a new native task from the form's title and
+// assigns it to the bucket's pool in one step -- the Settings page's
+// "type a title to add to the pool" flow. To add a task that already
+// exists elsewhere, use the /api/widget/buckets/{id}/items endpoint with
+// its id directly (unchanged, still task_id-based).
+func (h *Handler) HandleBucketItemAdd(w http.ResponseWriter, r *http.Request) {
+ bucketID := chi.URLParam(r, "id")
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+ title := strings.TrimSpace(r.FormValue("title"))
+ if title == "" {
+ JSONError(w, http.StatusBadRequest, "title is required", nil)
+ return
+ }
+ task := models.Task{ID: newID(), Content: title, Priority: 1}
+ if err := h.store.CreateNativeTask(task); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to create task", err)
+ return
+ }
+ if err := h.store.AddBucketItem(bucketID, task.ID); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to add task to bucket", err)
+ return
+ }
+ h.renderBucketsSection(w, r)
+}
+
+// HandleBucketItemRemove clears a task's bucket membership from the
+// Settings page (the task itself is left in place, just unbucketed).
+func (h *Handler) HandleBucketItemRemove(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+ taskID := r.FormValue("task_id")
+ if err := h.store.RemoveBucketItem(taskID); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ JSONError(w, http.StatusNotFound, "task not found", err)
+ return
+ }
+ JSONError(w, http.StatusInternalServerError, "Failed to remove bucket item", err)
+ return
+ }
+ h.renderBucketsSection(w, r)
+}
+
+// HandleBucketDelete removes a bucket, returning its member tasks to being
+// plain unbucketed tasks.
+func (h *Handler) HandleBucketDelete(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if err := h.store.DeleteBucket(id); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ JSONError(w, http.StatusNotFound, "bucket not found", err)
+ return
+ }
+ JSONError(w, http.StatusInternalServerError, "Failed to delete bucket", err)
+ return
+ }
+ h.renderBucketsSection(w, r)
+}
+
+// renderBucketsSection re-renders the "buckets-section" partial after any
+// bucket mutation, so the Settings page's htmx targets can swap in the
+// fresh pool contents without a full page reload.
+func (h *Handler) renderBucketsSection(w http.ResponseWriter, r *http.Request) {
+ summaries, err := BuildBucketSummaries(h.store)
+ if err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to load buckets", err)
+ return
+ }
+ data := struct {
+ Buckets []models.BucketSummary
+ }{Buckets: summaries}
+ if err := h.renderer.Render(w, "buckets-section", data); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to render buckets", err)
+ }
+}
diff --git a/internal/handlers/buckets_web_test.go b/internal/handlers/buckets_web_test.go
new file mode 100644
index 0000000..9e09ca0
--- /dev/null
+++ b/internal/handlers/buckets_web_test.go
@@ -0,0 +1,193 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "task-dashboard/internal/models"
+ "task-dashboard/internal/store"
+)
+
+func TestHandleBucketsCreate_Web_CreatesBucket(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/buckets", strings.NewReader("name=Gutters&cycle_days=30&pick_n=2"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleBucketsCreate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ buckets, err := h.store.GetBuckets()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(buckets) != 1 || buckets[0].Name != "Gutters" {
+ t.Errorf("buckets = %+v", buckets)
+ }
+}
+
+func TestHandleBucketsCreate_Web_InvalidPickN_Returns400(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/buckets", strings.NewReader("name=Gutters&cycle_days=30&pick_n=0"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleBucketsCreate(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleBucketItemAdd_Web_CreatesAndAssignsTask(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("POST", "/settings/buckets/"+bucket.ID+"/items", strings.NewReader("title=Clean+gutters")), "id", bucket.ID)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleBucketItemAdd(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ items, err := h.store.GetBucketItems(bucket.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) != 1 || items[0].Content != "Clean gutters" || items[0].BucketState != "dormant" {
+ t.Errorf("items = %+v", items)
+ }
+}
+
+func TestHandleBucketItemAdd_Web_EmptyTitle_Returns400(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("POST", "/settings/buckets/"+bucket.ID+"/items", strings.NewReader("title=")), "id", bucket.ID)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleBucketItemAdd(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleBucketItemRemove_Web_RemovesMembership(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Clean gutters", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.AddBucketItem(bucket.ID, "task-1"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("POST", "/settings/buckets/items/remove", strings.NewReader("task_id=task-1"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleBucketItemRemove(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.BucketID != "" {
+ t.Errorf("task.BucketID = %q, want empty", task.BucketID)
+ }
+}
+
+func TestHandleBucketDelete_Web_RemovesBucket(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("DELETE", "/settings/buckets/"+bucket.ID, nil), "id", bucket.ID)
+ w := httptest.NewRecorder()
+ h.HandleBucketDelete(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ buckets, err := h.store.GetBuckets()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(buckets) != 0 {
+ t.Errorf("buckets = %+v, want none", buckets)
+ }
+}
+
+func TestHandleSettingsPage_IncludesBucketsProjectsLabels(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+ if _, err := h.store.CreateBucket("Gutters", 30, 1); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.store.CreateProject("Home", "#3B82F6"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.SetLabelColor("chore", "#F59E0B"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/settings", nil)
+ w := httptest.NewRecorder()
+ h.HandleSettingsPage(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]
+ data, ok := lastCall.Data.(struct {
+ Configs map[string][]models.SourceConfig
+ Sources []string
+ Toggles []models.FeatureToggle
+ SyncLog []store.SyncLogEntry
+ Agents []models.Agent
+ Buckets []models.BucketSummary
+ Projects []models.Project
+ Labels []models.LabelColor
+ CSRFToken string
+ WebAuthnEnabled bool
+ })
+ if !ok {
+ t.Fatalf("unexpected data type %T", lastCall.Data)
+ }
+ if len(data.Buckets) != 1 || data.Buckets[0].Bucket.Name != "Gutters" {
+ t.Errorf("Buckets = %+v", data.Buckets)
+ }
+ if len(data.Projects) != 1 || data.Projects[0].Name != "Home" {
+ t.Errorf("Projects = %+v", data.Projects)
+ }
+ if len(data.Labels) != 1 || data.Labels[0].Name != "chore" {
+ t.Errorf("Labels = %+v", data.Labels)
+ }
+}
diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go
index 28988b1..43fce20 100644
--- a/internal/handlers/settings.go
+++ b/internal/handlers/settings.go
@@ -17,6 +17,9 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
toggles, _ := h.store.GetFeatureToggles()
syncLog, _ := h.store.GetRecentSyncLog(20)
agents, _ := h.store.GetAllAgents()
+ buckets, _ := BuildBucketSummaries(h.store)
+ projects, _ := h.store.GetProjects()
+ labels, _ := h.store.GetLabelColors()
// Group configs by source
bySource := make(map[string][]models.SourceConfig)
@@ -30,6 +33,9 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
Toggles []models.FeatureToggle
SyncLog []store.SyncLogEntry
Agents []models.Agent
+ Buckets []models.BucketSummary
+ Projects []models.Project
+ Labels []models.LabelColor
CSRFToken string
WebAuthnEnabled bool
}{
@@ -38,6 +44,9 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
Toggles: toggles,
SyncLog: syncLog,
Agents: agents,
+ Buckets: buckets,
+ Projects: projects,
+ Labels: labels,
CSRFToken: auth.GetCSRFTokenFromContext(r.Context()),
WebAuthnEnabled: h.WebAuthnEnabled,
}
diff --git a/internal/models/types.go b/internal/models/types.go
index 846ce50..151ef54 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -62,6 +62,13 @@ type ChainSummary struct {
Total int
}
+// BucketSummary is a maintenance bucket plus its full pool (dormant and
+// active tasks both), for the Settings page's bucket-management view.
+type BucketSummary struct {
+ Bucket MaintenanceBucket
+ Items []Task
+}
+
// 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/buckets.go b/internal/store/buckets.go
index 8bbcca9..1e3ce9a 100644
--- a/internal/store/buckets.go
+++ b/internal/store/buckets.go
@@ -89,6 +89,51 @@ func (s *Store) RemoveBucketItem(taskID string) error {
return checkRowsAffected(result)
}
+// GetBucketItems returns every task in a bucket's pool (dormant and active
+// both), active first, then by priority -- backs the bucket-management view.
+func (s *Store) GetBucketItems(bucketID string) ([]models.Task, error) {
+ rows, err := s.db.Query(`
+ SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
+ FROM native_tasks
+ WHERE bucket_id = ?
+ ORDER BY (bucket_state = 'active') DESC, priority DESC
+ `, bucketID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ return scanNativeTasks(rows)
+}
+
+// DeleteBucket removes a bucket and returns every member task to being a
+// plain, unbucketed task (clearing bucket_id/bucket_state/
+// bucket_last_active_at) rather than deleting them. Returns
+// ErrNativeTaskNotFound if id doesn't match any row.
+func (s *Store) DeleteBucket(id string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ if _, err := tx.Exec(`
+ UPDATE native_tasks SET bucket_id = '', bucket_state = '', bucket_last_active_at = NULL, updated_at = ? WHERE bucket_id = ?
+ `, config.Now(), id); err != nil {
+ return err
+ }
+ result, err := tx.Exec(`DELETE FROM maintenance_buckets WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ if err := checkRowsAffected(result); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
// selectBucketCycle activates the top pick_n dormant items in the bucket's
// pool, scored by staleness (never-activated items first, then oldest
// bucket_last_active_at) with task priority as a tiebreaker. Activated items
diff --git a/internal/store/buckets_web_test.go b/internal/store/buckets_web_test.go
new file mode 100644
index 0000000..564b09a
--- /dev/null
+++ b/internal/store/buckets_web_test.go
@@ -0,0 +1,62 @@
+package store
+
+import (
+ "testing"
+ "time"
+)
+
+func TestGetBucketItems_ReturnsActiveFirst(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ bucket, err := s.CreateBucket("Gutters", 30, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ createDormantTask(t, s, "dormant-1", bucket.ID, 1, nil)
+ createDormantTask(t, s, "dormant-2", bucket.ID, 1, nil)
+ if _, err := s.selectBucketCycle(bucket.ID, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ items, err := s.GetBucketItems(bucket.ID)
+ if err != nil {
+ t.Fatalf("GetBucketItems: %v", err)
+ }
+ if len(items) != 2 {
+ t.Fatalf("len(items) = %d, want 2", len(items))
+ }
+ if items[0].BucketState != "active" {
+ t.Errorf("items[0].BucketState = %q, want active (active items sort first)", items[0].BucketState)
+ }
+}
+
+func TestDeleteBucket_UnbucketsItemsAndRemovesBucket(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ bucket, err := s.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ createDormantTask(t, s, "item-1", bucket.ID, 1, nil)
+
+ if err := s.DeleteBucket(bucket.ID); err != nil {
+ t.Fatalf("DeleteBucket: %v", err)
+ }
+
+ if _, err := s.GetBucketByID(bucket.ID); err != ErrNativeTaskNotFound {
+ t.Errorf("bucket should be gone, err = %v", err)
+ }
+ task, err := s.GetNativeTaskByID("item-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.BucketID != "" || task.BucketState != "" {
+ t.Errorf("task = %+v, want unbucketed (task itself kept, not deleted)", task)
+ }
+}
+
+func TestDeleteBucket_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.DeleteBucket("does-not-exist"); err != ErrNativeTaskNotFound {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
diff --git a/web/templates/partials/buckets-section.html b/web/templates/partials/buckets-section.html
new file mode 100644
index 0000000..d2f5b7e
--- /dev/null
+++ b/web/templates/partials/buckets-section.html
@@ -0,0 +1,53 @@
+{{define "buckets-section"}}
+<div id="buckets-list" class="grid gap-4">
+ {{if .Buckets}}
+ {{range .Buckets}}
+ <div class="card" id="bucket-{{.Bucket.ID}}">
+ <div class="flex items-center justify-between gap-4 mb-3">
+ <div>
+ <strong class="text-white">{{.Bucket.Name}}</strong>
+ <span class="text-xs text-slate-400 ml-2">every {{.Bucket.CycleDays}}d, picks {{.Bucket.PickN}}</span>
+ </div>
+ <button class="text-xs text-red-400 hover:text-red-300 transition-colors"
+ hx-delete="/settings/buckets/{{.Bucket.ID}}"
+ hx-target="#buckets-list"
+ hx-swap="outerHTML"
+ hx-confirm="Delete bucket '{{.Bucket.Name}}'? Its tasks will be kept, just unbucketed.">
+ Delete Bucket
+ </button>
+ </div>
+
+ <div class="space-y-1 mb-3">
+ {{if .Items}}
+ {{range .Items}}
+ <div class="flex items-center justify-between gap-2 text-sm py-1">
+ <span class="text-slate-200">{{.Content}}</span>
+ <div class="flex items-center gap-3 flex-shrink-0">
+ <span class="text-[10px] uppercase tracking-wider {{if eq .BucketState "active"}}text-green-400{{else}}text-slate-500{{end}}">{{.BucketState}}</span>
+ <button class="text-xs text-red-400/70 hover:text-red-300 transition-colors"
+ hx-post="/settings/buckets/items/remove"
+ hx-vals='{"task_id": "{{.ID}}"}'
+ hx-target="#buckets-list"
+ hx-swap="outerHTML">
+ Remove
+ </button>
+ </div>
+ </div>
+ {{end}}
+ {{else}}
+ <p class="text-xs text-slate-500 italic">Pool is empty.</p>
+ {{end}}
+ </div>
+
+ <form class="flex gap-2" hx-post="/settings/buckets/{{.Bucket.ID}}/items" hx-target="#buckets-list" hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
+ <input type="text" name="title" placeholder="Add a pool item..." required
+ class="flex-1 bg-slate-950 border border-white/10 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-500 outline-none">
+ <button type="submit" class="text-sm bg-slate-800 hover:bg-slate-700 text-white px-3 py-1.5 rounded-lg transition-colors">Add</button>
+ </form>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="card text-center text-slate-500 py-10">No maintenance buckets yet.</div>
+ {{end}}
+</div>
+{{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
index a955f64..3bb143c 100644
--- a/web/templates/settings.html
+++ b/web/templates/settings.html
@@ -264,6 +264,55 @@
{{end}}
</div>
</section>
+
+ <!-- Maintenance Buckets Section -->
+ <section class="mb-12">
+ <h2 class="text-xl font-medium text-white mb-6 pb-2 border-b border-white/10">Maintenance Buckets</h2>
+ {{template "buckets-section" .}}
+ <form class="mt-4 flex flex-wrap gap-3 p-4 bg-slate-900/40 rounded-xl border border-white/5" hx-post="/settings/buckets" hx-target="#buckets-list" hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
+ <input type="text" name="name" placeholder="Bucket name" required
+ class="flex-1 bg-slate-950 border border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-1 focus:ring-blue-500 outline-none">
+ <input type="number" name="cycle_days" placeholder="Cycle (days)" required min="1"
+ class="w-32 bg-slate-950 border border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-1 focus:ring-blue-500 outline-none">
+ <input type="number" name="pick_n" placeholder="Pick N" required min="1"
+ class="w-28 bg-slate-950 border border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-1 focus:ring-blue-500 outline-none">
+ <button type="submit" class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg text-sm font-medium transition-colors">Create Bucket</button>
+ </form>
+ </section>
+
+ <!-- Projects Section (read-only) -->
+ <section class="mb-12">
+ <h2 class="text-xl font-medium text-white mb-6 pb-2 border-b border-white/10">Projects</h2>
+ <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+ {{if .Projects}}
+ {{range .Projects}}
+ <div class="card flex items-center gap-3 py-2">
+ <span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: {{.Color}}"></span>
+ <span class="text-sm text-slate-200 truncate">{{.Name}}</span>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="card col-span-full text-center text-slate-500 py-6 text-sm italic">No projects yet.</div>
+ {{end}}
+ </div>
+ </section>
+
+ <!-- Labels Section (read-only) -->
+ <section class="mb-12">
+ <h2 class="text-xl font-medium text-white mb-6 pb-2 border-b border-white/10">Labels</h2>
+ <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+ {{if .Labels}}
+ {{range .Labels}}
+ <div class="card flex items-center gap-3 py-2">
+ <span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: {{.Color}}"></span>
+ <span class="text-sm text-slate-200 truncate">{{.Name}}</span>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="card col-span-full text-center text-slate-500 py-6 text-sm italic">No labels assigned a color yet.</div>
+ {{end}}
+ </div>
+ </section>
</div>
</body>
</html>