diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-07 10:39:28 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-07 10:39:28 +0000 |
| commit | eadd17df2946a1219fdf02f2ee0a0ac19734e56d (patch) | |
| tree | e0460d1cdd9c17fd47ce7cce2fe7845378d8011e | |
| parent | 06450fe69ade2928deb9274bb67b7ba60d394b4f (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.go | 1 | ||||
| -rw-r--r-- | internal/handlers/buckets_web_test.go | 24 | ||||
| -rw-r--r-- | internal/handlers/chains_web_test.go | 3 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 160 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 107 | ||||
| -rw-r--r-- | internal/handlers/settings.go | 9 | ||||
| -rw-r--r-- | web/templates/index.html | 9 | ||||
| -rw-r--r-- | web/templates/partials/buckets-section.html | 26 | ||||
| -rw-r--r-- | web/templates/partials/task-detail.html | 66 | ||||
| -rw-r--r-- | web/templates/partials/tasks-tab.html | 66 | ||||
| -rw-r--r-- | web/templates/settings.html | 50 |
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">← 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> |
