summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-07 10:39:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-07 10:39:28 +0000
commiteadd17df2946a1219fdf02f2ee0a0ac19734e56d (patch)
treee0460d1cdd9c17fd47ce7cce2fe7845378d8011e
parent06450fe69ade2928deb9274bb67b7ba60d394b4f (diff)
Wire the Tasks tab into nav; fold in buckets/projects/labels and recurrence
The Tasks tab (/tabs/tasks) existed server-side and was tested, but nothing in the nav linked to it -- it was pure dead weight in the other direction. Wiring it up as the natural home for everything that was either misplaced in Settings or missing a web UI entirely: - Maintenance Buckets, Projects, and Labels moved out of Settings and into the Tasks tab (restyled from Settings' opaque slate cards to the glass/backdrop-blur look already used by the tab's chain/atom cards -- they're now embedded in index.html's page shell, not a standalone page, so the shared bg-card/bg-input classes from that shell apply). Settings keeps only what's actually settings: Passkeys, Trusted Agents, Data Sources. - Added a Recurrence section to the task-detail modal (freq/interval/ weekday form, posting to a new POST /tasks/recurrence -- the HTMX counterpart to the widget API's HandleWidgetTaskRecurrence). Native task recurrence previously had zero web UI at all, only reachable via the Android widget's RecurrenceEditDialog. Also fixed a real bug found while touching this code: HandleGetTaskDetail's source switch only had a case for "trello" -- opening any native ("doot") task's detail modal, which is most tasks in this tab, showed a blank title and description. Factored both call sites (initial GET and the re-render after a recurrence edit) through one loadTaskDetailData helper and added the missing "doot" case. Also fixed task-detail.html's styling, which was still using pre-dark-theme classes (text-gray-900 etc.) -- functionally invisible text on the modal's dark background. Verified with a throwaway local server (real templates + real DB, not the MockRenderer the unit tests use) seeded with a recurring task, a bucket, a project, and a label -- confirmed all five touched routes render 200 with the expected content, including the populated Buckets/Projects/Labels sections and a real weekly-recurrence form with the correct weekdays pre-checked. Caught and fixed a copy bug this way too ("every 2 weeklys" -> "every 2 weeks"). Not committed; deleted after use. go build ./..., go vet ./..., and go test ./... all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
-rw-r--r--cmd/dashboard/main.go1
-rw-r--r--internal/handlers/buckets_web_test.go24
-rw-r--r--internal/handlers/chains_web_test.go3
-rw-r--r--internal/handlers/handlers.go160
-rw-r--r--internal/handlers/handlers_test.go107
-rw-r--r--internal/handlers/settings.go9
-rw-r--r--web/templates/index.html9
-rw-r--r--web/templates/partials/buckets-section.html26
-rw-r--r--web/templates/partials/task-detail.html66
-rw-r--r--web/templates/partials/tasks-tab.html66
-rw-r--r--web/templates/settings.html50
11 files changed, 416 insertions, 105 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index f83fea1..6d1e34e 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -360,6 +360,7 @@ func main() {
r.Get("/tasks/detail", h.HandleGetTaskDetail)
r.Get("/task", h.HandleTaskDetailPage)
r.Post("/tasks/update", h.HandleUpdateTask)
+ r.Post("/tasks/recurrence", h.HandleSetTaskRecurrence)
// Shopping quick-add
r.Post("/shopping/add", h.HandleShoppingQuickAdd)
diff --git a/internal/handlers/buckets_web_test.go b/internal/handlers/buckets_web_test.go
index befdc01..b29e842 100644
--- a/internal/handlers/buckets_web_test.go
+++ b/internal/handlers/buckets_web_test.go
@@ -7,7 +7,6 @@ import (
"testing"
"task-dashboard/internal/models"
- "task-dashboard/internal/store"
)
func TestHandleBucketsCreate_Web_CreatesBucket(t *testing.T) {
@@ -143,7 +142,7 @@ func TestHandleBucketDelete_Web_RemovesBucket(t *testing.T) {
}
}
-func TestHandleSettingsPage_IncludesBucketsProjectsLabels(t *testing.T) {
+func TestHandleTabTasks_IncludesBucketsProjectsLabels(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
if _, err := h.store.CreateBucket("Gutters", 30, 1); err != nil {
@@ -156,9 +155,9 @@ func TestHandleSettingsPage_IncludesBucketsProjectsLabels(t *testing.T) {
t.Fatal(err)
}
- req := httptest.NewRequest("GET", "/settings", nil)
+ req := httptest.NewRequest("GET", "/tabs/tasks", nil)
w := httptest.NewRecorder()
- h.HandleSettingsPage(w, req)
+ h.HandleTabTasks(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
@@ -167,15 +166,14 @@ func TestHandleSettingsPage_IncludesBucketsProjectsLabels(t *testing.T) {
mock := h.renderer.(*MockRenderer)
lastCall := mock.Calls[len(mock.Calls)-1]
data, ok := lastCall.Data.(struct {
- Configs map[string][]models.SourceConfig
- Sources []string
- SyncLog []store.SyncLogEntry
- Agents []models.Agent
- Buckets []models.BucketSummary
- Projects []models.Project
- Labels []models.LabelColor
- CSRFToken string
- WebAuthnEnabled bool
+ Atoms []models.Atom
+ FutureAtoms []models.Atom
+ Boards []models.Board
+ Chains []models.ChainSummary
+ Buckets []models.BucketSummary
+ Projects []models.Project
+ Labels []models.LabelColor
+ Today string
})
if !ok {
t.Fatalf("unexpected data type %T", lastCall.Data)
diff --git a/internal/handlers/chains_web_test.go b/internal/handlers/chains_web_test.go
index d27dab9..b297fb1 100644
--- a/internal/handlers/chains_web_test.go
+++ b/internal/handlers/chains_web_test.go
@@ -197,6 +197,9 @@ func TestHandleTabTasks_IncludesChainSummaries(t *testing.T) {
FutureAtoms []models.Atom
Boards []models.Board
Chains []models.ChainSummary
+ Buckets []models.BucketSummary
+ Projects []models.Project
+ Labels []models.LabelColor
Today string
})
if !ok {
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index a5bc51a..ed15e89 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -10,6 +10,7 @@ import (
"net/http"
"path/filepath"
"sort"
+ "strconv"
"strings"
"sync"
"time"
@@ -68,6 +69,45 @@ func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googl
return ""
}
},
+ // hasInt reports whether n is present in a []int -- used to pre-check
+ // the weekday checkboxes on a recurring task's edit form.
+ "hasInt": func(list []int, n int) bool {
+ for _, v := range list {
+ if v == n {
+ return true
+ }
+ }
+ return false
+ },
+ // recurrenceUnit maps a recurrence freq to its singular noun, so the
+ // task-detail template can say "every 2 weeks" instead of gluing an
+ // "s" onto the adjective "weekly" ("every 2 weeklys").
+ "recurrenceUnit": func(freq string) string {
+ switch freq {
+ case "daily":
+ return "day"
+ case "weekly":
+ return "week"
+ case "monthly":
+ return "month"
+ case "yearly":
+ return "year"
+ default:
+ return freq
+ }
+ },
+ // weekdayNames formats recurrence weekday indices (0=Sunday, matching
+ // time.Weekday, see models/recurrence.go) as "Mon, Wed, Fri".
+ "weekdayNames": func(weekdays []int) string {
+ names := [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
+ parts := make([]string, 0, len(weekdays))
+ for _, d := range weekdays {
+ if d >= 0 && d < 7 {
+ parts = append(parts, names[d])
+ }
+ }
+ return strings.Join(parts, ", ")
+ },
}
// Parse templates including partials
@@ -752,37 +792,102 @@ func (h *Handler) HandleGetListsOptions(w http.ResponseWriter, r *http.Request)
HTMLResponse(w, h.renderer, "lists-options", lists)
}
-// HandleGetTaskDetail returns task details as HTML for modal
-func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
- id := r.URL.Query().Get("id")
- source := r.URL.Query().Get("source")
-
- if id == "" || source == "" {
- JSONError(w, http.StatusBadRequest, "Missing id or source", nil)
- return
- }
+// taskDetailData is what the "task-detail" template renders (the modal
+// opened from any tab's task card). Recurrence fields are doot-native only.
+type taskDetailData struct {
+ Title string
+ ID string
+ Source string
+ Description string
+ IsDoot bool
+ RecurrenceFreq string
+ RecurrenceInterval int
+ RecurrenceWeekdays []int
+}
- var title, description string
+// loadTaskDetailData fetches a task's current fields for the detail modal,
+// shared between the initial GET and re-rendering after a recurrence edit.
+func (h *Handler) loadTaskDetailData(id, source string) taskDetailData {
+ data := taskDetailData{ID: id, Source: source}
switch source {
case "trello":
if boards, err := h.store.GetBoards(); err == nil {
for _, b := range boards {
for _, c := range b.Cards {
if c.ID == id {
- title, description = c.Name, c.Description
+ data.Title, data.Description = c.Name, c.Description
break
}
}
}
}
+ case "doot":
+ if task, err := h.store.GetNativeTaskByID(id); err == nil {
+ data.IsDoot = true
+ data.Title = task.Content
+ data.Description = task.Description
+ data.RecurrenceFreq = task.RecurrenceFreq
+ data.RecurrenceInterval = task.RecurrenceInterval
+ data.RecurrenceWeekdays = task.RecurrenceWeekdays
+ }
}
+ return data
+}
- HTMLResponse(w, h.renderer, "task-detail", struct {
- Title string
- ID string
- Source string
- Description string
- }{title, id, source, description})
+// HandleGetTaskDetail returns task details as HTML for modal.
+func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ source := r.URL.Query().Get("source")
+
+ if id == "" || source == "" {
+ JSONError(w, http.StatusBadRequest, "Missing id or source", nil)
+ return
+ }
+
+ HTMLResponse(w, h.renderer, "task-detail", h.loadTaskDetailData(id, source))
+}
+
+// HandleSetTaskRecurrence sets or clears a doot-native task's recurrence
+// pattern from the web task-detail modal -- the HTMX counterpart to the
+// widget API's HandleWidgetTaskRecurrence (internal/handlers/widget.go).
+// freq == "" clears recurrence. Re-renders the detail modal so it reflects
+// the new state without closing.
+func (h *Handler) HandleSetTaskRecurrence(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+ id := r.FormValue("id")
+ if id == "" {
+ JSONError(w, http.StatusBadRequest, "Missing id", nil)
+ return
+ }
+ freq := r.FormValue("freq")
+ if freq != "" && freq != "daily" && freq != "weekly" && freq != "monthly" && freq != "yearly" {
+ JSONError(w, http.StatusBadRequest, "Invalid freq", nil)
+ return
+ }
+ interval, _ := strconv.Atoi(r.FormValue("interval"))
+ if interval <= 0 {
+ interval = 1
+ }
+ var weekdays []int
+ for _, d := range r.Form["weekdays"] {
+ if n, err := strconv.Atoi(d); err == nil {
+ weekdays = append(weekdays, n)
+ }
+ }
+
+ if err := h.store.SetTaskRecurrence(id, freq, interval, weekdays); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ JSONError(w, http.StatusNotFound, "task not found", err)
+ return
+ }
+ JSONError(w, http.StatusInternalServerError, "Failed to update recurrence", err)
+ return
+ }
+
+ HTMLResponse(w, h.renderer, "task-detail", h.loadTaskDetailData(id, "doot"))
}
// HandleTaskDetailPage renders a standalone full-page task detail view (used by Android widget deep-links).
@@ -889,6 +994,21 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
log.Printf("Warning: failed to build chain summaries: %v", err)
}
+ bucketSummaries, err := BuildBucketSummaries(h.store)
+ if err != nil {
+ log.Printf("Warning: failed to build bucket summaries: %v", err)
+ }
+
+ projects, err := h.store.GetProjects()
+ if err != nil {
+ log.Printf("Warning: failed to load projects: %v", err)
+ }
+
+ labels, err := h.store.GetLabelColors()
+ if err != nil {
+ log.Printf("Warning: failed to load label colors: %v", err)
+ }
+
SortAtomsByUrgency(atoms)
currentAtoms, futureAtoms := PartitionAtomsByTime(atoms)
@@ -897,12 +1017,18 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
FutureAtoms []models.Atom
Boards []models.Board
Chains []models.ChainSummary
+ Buckets []models.BucketSummary
+ Projects []models.Project
+ Labels []models.LabelColor
Today string
}{
Atoms: currentAtoms,
FutureAtoms: futureAtoms,
Boards: boards,
Chains: chainSummaries,
+ Buckets: bucketSummaries,
+ Projects: projects,
+ Labels: labels,
Today: config.Now().Format("2006-01-02"),
}
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 4c776b0..a43dec2 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -1869,6 +1869,113 @@ func TestHandleGetTaskDetail_RendersTemplate(t *testing.T) {
}
}
+// TestHandleGetTaskDetail_DootSource_LoadsRealTaskFields guards against a
+// regression where source=="doot" fell through the switch in
+// loadTaskDetailData with no case, silently leaving Title/Description blank
+// -- native tasks are the primary type the Tasks tab and its detail modal
+// are built around, so this previously meant opening any native task's
+// detail showed an empty modal.
+func TestHandleGetTaskDetail_DootSource_LoadsRealTaskFields(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.CreateNativeTask(models.Task{
+ ID: "task-1", Content: "Clean gutters", Description: "Ladder's in the garage", Priority: 1,
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/tasks/detail?id=task-1&source=doot", nil)
+ w := httptest.NewRecorder()
+ h.HandleGetTaskDetail(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.(taskDetailData)
+ if !ok {
+ t.Fatalf("unexpected data type %T", lastCall.Data)
+ }
+ if !data.IsDoot {
+ t.Error("IsDoot = false, want true")
+ }
+ if data.Title != "Clean gutters" {
+ t.Errorf("Title = %q, want %q", data.Title, "Clean gutters")
+ }
+ if data.Description != "Ladder's in the garage" {
+ t.Errorf("Description = %q, want %q", data.Description, "Ladder's in the garage")
+ }
+}
+
+func TestHandleSetTaskRecurrence_SetsAndClears(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Water plants", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("POST", "/tasks/recurrence", strings.NewReader("id=task-1&freq=weekly&interval=2&weekdays=1&weekdays=3"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleSetTaskRecurrence(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.RecurrenceFreq != "weekly" || task.RecurrenceInterval != 2 {
+ t.Errorf("recurrence = freq=%q interval=%d, want weekly/2", task.RecurrenceFreq, task.RecurrenceInterval)
+ }
+ if len(task.RecurrenceWeekdays) != 2 {
+ t.Errorf("weekdays = %v, want [1 3]", task.RecurrenceWeekdays)
+ }
+ if task.RecurrenceSeriesID == "" {
+ t.Error("RecurrenceSeriesID should be set once a recurrence pattern is active")
+ }
+
+ // Clearing (freq="") should drop the pattern.
+ req2 := httptest.NewRequest("POST", "/tasks/recurrence", strings.NewReader("id=task-1&freq=&interval=1"))
+ req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w2 := httptest.NewRecorder()
+ h.HandleSetTaskRecurrence(w2, req2)
+
+ if w2.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w2.Code, w2.Body.String())
+ }
+ task, err = h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.RecurrenceFreq != "" {
+ t.Errorf("RecurrenceFreq = %q after clear, want empty", task.RecurrenceFreq)
+ }
+}
+
+func TestHandleSetTaskRecurrence_InvalidFreq_Returns400(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Water plants", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("POST", "/tasks/recurrence", strings.NewReader("id=task-1&freq=fortnightly"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleSetTaskRecurrence(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
// =============================================================================
// HandleGetListsOptions template tests
// =============================================================================
diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go
index 5ba3724..9ab832c 100644
--- a/internal/handlers/settings.go
+++ b/internal/handlers/settings.go
@@ -14,9 +14,6 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
configs, _ := h.store.GetSourceConfigs()
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)
@@ -29,9 +26,6 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
Sources []string
SyncLog []store.SyncLogEntry
Agents []models.Agent
- Buckets []models.BucketSummary
- Projects []models.Project
- Labels []models.LabelColor
CSRFToken string
WebAuthnEnabled bool
}{
@@ -39,9 +33,6 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
Sources: []string{"trello", "gcal", "gtasks"},
SyncLog: syncLog,
Agents: agents,
- Buckets: buckets,
- Projects: projects,
- Labels: labels,
CSRFToken: auth.GetCSRFTokenFromContext(r.Context()),
WebAuthnEnabled: h.WebAuthnEnabled,
}
diff --git a/web/templates/index.html b/web/templates/index.html
index 7be2c52..b11b557 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -82,6 +82,15 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</button>
<button
+ class="tab-button {{if eq .ActiveTab "tasks"}}tab-button-active{{end}}"
+ hx-get="/tabs/tasks"
+ hx-target="#tab-content"
+ hx-push-url="?tab=tasks"
+ onclick="setActiveTab(this)"
+ title="Tasks">
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg>
+ </button>
+ <button
class="tab-button {{if eq .ActiveTab "shopping"}}tab-button-active{{end}}"
hx-get="/tabs/shopping"
hx-target="#tab-content"
diff --git a/web/templates/partials/buckets-section.html b/web/templates/partials/buckets-section.html
index d2f5b7e..53b1e35 100644
--- a/web/templates/partials/buckets-section.html
+++ b/web/templates/partials/buckets-section.html
@@ -1,19 +1,19 @@
{{define "buckets-section"}}
-<div id="buckets-list" class="grid gap-4">
+<div id="buckets-list" class="grid gap-3 sm:grid-cols-2">
{{if .Buckets}}
{{range .Buckets}}
- <div class="card" id="bucket-{{.Bucket.ID}}">
+ <div class="bg-card bg-card-hover transition-colors rounded-lg border border-white/5 p-4" 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>
+ <strong class="text-white text-sm">{{.Bucket.Name}}</strong>
+ <span class="text-xs text-white/40 ml-2">every {{.Bucket.CycleDays}}d, picks {{.Bucket.PickN}}</span>
</div>
- <button class="text-xs text-red-400 hover:text-red-300 transition-colors"
+ <button class="text-xs text-red-400/80 hover:text-red-300 transition-colors flex-shrink-0"
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
+ Delete
</button>
</div>
@@ -21,10 +21,10 @@
{{if .Items}}
{{range .Items}}
<div class="flex items-center justify-between gap-2 text-sm py-1">
- <span class="text-slate-200">{{.Content}}</span>
+ <span class="text-white/80">{{.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"
+ <span class="text-[10px] uppercase tracking-wider {{if eq .BucketState "active"}}text-green-400{{else}}text-white/30{{end}}">{{.BucketState}}</span>
+ <button class="text-xs text-red-400/60 hover:text-red-300 transition-colors"
hx-post="/settings/buckets/items/remove"
hx-vals='{"task_id": "{{.ID}}"}'
hx-target="#buckets-list"
@@ -35,19 +35,19 @@
</div>
{{end}}
{{else}}
- <p class="text-xs text-slate-500 italic">Pool is empty.</p>
+ <p class="text-xs text-white/40 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>
+ class="flex-1 bg-input border border-white/20 rounded-lg px-3 py-1.5 text-sm text-white placeholder-white/50 outline-none focus:ring-1 focus:ring-white/30">
+ <button type="submit" class="text-sm bg-white/10 hover:bg-white/20 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>
+ <div class="bg-card rounded-lg border border-white/5 text-center text-white/40 py-10 sm:col-span-2">No maintenance buckets yet.</div>
{{end}}
</div>
{{end}}
diff --git a/web/templates/partials/task-detail.html b/web/templates/partials/task-detail.html
index ace604f..135c38f 100644
--- a/web/templates/partials/task-detail.html
+++ b/web/templates/partials/task-detail.html
@@ -1,12 +1,70 @@
{{define "task-detail"}}
<div class="p-4">
- <h3 class="font-semibold text-gray-900 mb-3">{{.Title}}</h3>
+ <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-gray-700 mb-1">Description</label>
- <textarea name="description" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-3 h-32">{{.Description}}</textarea>
- <button type="submit" class="w-full bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Save</button>
+ <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}}
+ <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}}
+ <p class="text-xs text-teal-300/80 mb-2">
+ Repeats every {{if gt .RecurrenceInterval 1}}{{.RecurrenceInterval}} {{recurrenceUnit .RecurrenceFreq}}s{{else}}{{recurrenceUnit .RecurrenceFreq}}{{end}}{{if and (eq .RecurrenceFreq "weekly") .RecurrenceWeekdays}} on {{weekdayNames .RecurrenceWeekdays}}{{end}}
+ </p>
+ {{end}}
+ <form hx-post="/tasks/recurrence" hx-target="#task-edit-content" hx-swap="innerHTML">
+ <input type="hidden" name="id" value="{{.ID}}">
+ <div class="flex gap-2 mb-2">
+ <select name="freq" class="flex-1 bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white">
+ <option value="" {{if eq .RecurrenceFreq ""}}selected{{end}}>None</option>
+ <option value="daily" {{if eq .RecurrenceFreq "daily"}}selected{{end}}>Daily</option>
+ <option value="weekly" {{if eq .RecurrenceFreq "weekly"}}selected{{end}}>Weekly</option>
+ <option value="monthly" {{if eq .RecurrenceFreq "monthly"}}selected{{end}}>Monthly</option>
+ <option value="yearly" {{if eq .RecurrenceFreq "yearly"}}selected{{end}}>Yearly</option>
+ </select>
+ <input type="number" name="interval" min="1" value="{{if gt .RecurrenceInterval 0}}{{.RecurrenceInterval}}{{else}}1{{end}}"
+ class="w-20 bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white" title="Every N">
+ </div>
+ <div class="flex flex-wrap gap-3 mb-3">
+ <label class="flex items-center gap-1 text-xs text-white/60">
+ <input type="checkbox" name="weekdays" value="0" {{if hasInt .RecurrenceWeekdays 0}}checked{{end}} class="h-3.5 w-3.5 rounded bg-black/40 border-white/30">
+ Su
+ </label>
+ <label class="flex items-center gap-1 text-xs text-white/60">
+ <input type="checkbox" name="weekdays" value="1" {{if hasInt .RecurrenceWeekdays 1}}checked{{end}} class="h-3.5 w-3.5 rounded bg-black/40 border-white/30">
+ Mo
+ </label>
+ <label class="flex items-center gap-1 text-xs text-white/60">
+ <input type="checkbox" name="weekdays" value="2" {{if hasInt .RecurrenceWeekdays 2}}checked{{end}} class="h-3.5 w-3.5 rounded bg-black/40 border-white/30">
+ Tu
+ </label>
+ <label class="flex items-center gap-1 text-xs text-white/60">
+ <input type="checkbox" name="weekdays" value="3" {{if hasInt .RecurrenceWeekdays 3}}checked{{end}} class="h-3.5 w-3.5 rounded bg-black/40 border-white/30">
+ We
+ </label>
+ <label class="flex items-center gap-1 text-xs text-white/60">
+ <input type="checkbox" name="weekdays" value="4" {{if hasInt .RecurrenceWeekdays 4}}checked{{end}} class="h-3.5 w-3.5 rounded bg-black/40 border-white/30">
+ Th
+ </label>
+ <label class="flex items-center gap-1 text-xs text-white/60">
+ <input type="checkbox" name="weekdays" value="5" {{if hasInt .RecurrenceWeekdays 5}}checked{{end}} class="h-3.5 w-3.5 rounded bg-black/40 border-white/30">
+ Fr
+ </label>
+ <label class="flex items-center gap-1 text-xs text-white/60">
+ <input type="checkbox" name="weekdays" value="6" {{if hasInt .RecurrenceWeekdays 6}}checked{{end}} class="h-3.5 w-3.5 rounded bg-black/40 border-white/30">
+ Sa
+ </label>
+ </div>
+ <button type="submit" class="w-full bg-white/10 hover:bg-white/20 text-white/90 px-4 py-2 rounded-lg text-sm font-medium">
+ {{if .RecurrenceFreq}}Update{{else}}Set{{end}} Recurrence
+ </button>
+ </form>
+ </div>
+ {{end}}
</div>
{{end}}
diff --git a/web/templates/partials/tasks-tab.html b/web/templates/partials/tasks-tab.html
index b506716..1ac6703 100644
--- a/web/templates/partials/tasks-tab.html
+++ b/web/templates/partials/tasks-tab.html
@@ -143,5 +143,71 @@
</div>
</details>
{{end}}
+
+ <!-- Maintenance Buckets -->
+ <details class="group">
+ <summary class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/70 cursor-pointer hover:text-white/90 list-none">
+ <span>🪣</span> Maintenance Buckets
+ <svg class="w-4 h-4 ml-auto transform transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+ </svg>
+ </summary>
+ {{template "buckets-section" .}}
+ <form class="mt-3 flex flex-wrap gap-2 p-3 bg-black/20 rounded-lg" 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-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 outline-none focus:ring-1 focus:ring-white/30">
+ <input type="number" name="cycle_days" placeholder="Cycle (days)" required min="1"
+ class="w-28 bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 outline-none focus:ring-1 focus:ring-white/30">
+ <input type="number" name="pick_n" placeholder="Pick N" required min="1"
+ class="w-24 bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 outline-none focus:ring-1 focus:ring-white/30">
+ <button type="submit" class="bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">Create Bucket</button>
+ </form>
+ </details>
+
+ <!-- Projects (read-only) -->
+ <details class="group">
+ <summary class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/70 cursor-pointer hover:text-white/90 list-none">
+ <span>📁</span> Projects
+ <span class="text-sm font-normal text-white/40">({{len .Projects}})</span>
+ <svg class="w-4 h-4 ml-auto transform transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+ </svg>
+ </summary>
+ <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+ {{if .Projects}}
+ {{range .Projects}}
+ <div class="bg-card rounded-lg border border-white/5 flex items-center gap-3 py-2 px-3">
+ <span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: {{.Color}}"></span>
+ <span class="text-sm text-white/80 truncate">{{.Name}}</span>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="bg-card rounded-lg border border-white/5 col-span-full text-center text-white/40 py-6 text-sm italic">No projects yet.</div>
+ {{end}}
+ </div>
+ </details>
+
+ <!-- Labels (read-only) -->
+ <details class="group">
+ <summary class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/70 cursor-pointer hover:text-white/90 list-none">
+ <span>🏷️</span> Labels
+ <span class="text-sm font-normal text-white/40">({{len .Labels}})</span>
+ <svg class="w-4 h-4 ml-auto transform transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+ </svg>
+ </summary>
+ <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+ {{if .Labels}}
+ {{range .Labels}}
+ <div class="bg-card rounded-lg border border-white/5 flex items-center gap-3 py-2 px-3">
+ <span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: {{.Color}}"></span>
+ <span class="text-sm text-white/80 truncate">{{.Name}}</span>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="bg-card rounded-lg border border-white/5 col-span-full text-center text-white/40 py-6 text-sm italic">No labels assigned a color yet.</div>
+ {{end}}
+ </div>
+ </details>
</div>
{{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
index f07700d..acee1ee 100644
--- a/web/templates/settings.html
+++ b/web/templates/settings.html
@@ -17,7 +17,7 @@
<div class="max-w-4xl mx-auto">
<a href="/" onclick="if (window.history.length > 1) { history.back(); return false; }" class="inline-block mb-6 text-slate-400 hover:text-white transition-colors">&larr; Back to Dashboard</a>
<h1 class="text-4xl font-light text-white mb-2 tracking-tight">Settings</h1>
- <p class="text-slate-400 mb-10">Configure feature toggles and data sources.</p>
+ <p class="text-slate-400 mb-10">Configure auth, agent access, and data sources.</p>
<!-- Passkeys Section -->
{{if .WebAuthnEnabled}}
@@ -221,54 +221,6 @@
</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>