summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agent/critiques.md78
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/api/plantoeat.go5
-rw-r--r--internal/handlers/agent.go10
-rw-r--r--internal/handlers/agent_test.go41
-rw-r--r--internal/handlers/handlers.go34
-rw-r--r--internal/handlers/handlers_test.go80
7 files changed, 215 insertions, 34 deletions
diff --git a/.agent/critiques.md b/.agent/critiques.md
new file mode 100644
index 0000000..a507cbd
--- /dev/null
+++ b/.agent/critiques.md
@@ -0,0 +1,78 @@
+# Critiques (running list)
+
+Working notes on structural/design weaknesses, gathered as they come up in
+conversation rather than invented in the abstract. Not a plan — a list to
+react to and prioritize before anything here gets acted on.
+
+## Data model
+
+1. **`native_tasks` is one flat table doing ~4 jobs.** A task, a
+ chain-membership, a bucket-membership, and a recurrence-series are all
+ the same row, distinguished only by which subset of bolt-on columns
+ (`chain_id/position/unlocked`, `bucket_id/state/last_active_at`,
+ `recurrence_freq/interval/weekdays/series_id/override`) are non-empty.
+ Every new feature adds more nullable-in-practice columns to the same
+ row; every query has to reason about which columns are "live" for a
+ given row's shape. Cost grows linearly with feature count.
+
+2. **No real task-dependency graph.** "Blocking" / "depends on" only exist
+ as (a) a chain's implicit position ordering, or (b) free-text notes a
+ human has to read and self-enforce. There's no way to express "task B
+ can't start until task A" when A isn't B's chain predecessor, without
+ falling back to prose. This is the literal gap hit building the Pelagic
+ Marine Electronics project (5 real dependency edges, none enforceable).
+
+3. **Chains and projects are welded 1:1.** `CreateChain` always creates its
+ own backing project — no chain-within-an-existing-project, no
+ multiple-chains-sharing-a-project. This is why one business plan with 7
+ phases produced 7 separate entries in the project list. Looks like a
+ modeling shortcut, not a deliberate choice.
+
+4. **Three incompatible "shapes" of repeat/pooled work.** Chains (WIP-1,
+ ordered, position-based), buckets (pick-N pool, cycle-based,
+ staleness-ordered), and recurrence (single-task series, date-anchored)
+ solve overlapping problems with three separate non-composable
+ mechanisms and three separate column sets on the same table. A task
+ can't be "a bucket item that's also gated behind another task" — none
+ of the three concepts compose.
+
+5. **Labels have no referential integrity.** `labels(name, color)` is
+ color metadata only; actual label assignment is a raw JSON array on
+ `native_tasks` with no FK to `labels.name`. A typo in a label string
+ silently creates an new, uncolored "label" rather than erroring or
+ reusing the existing one.
+
+## Process / instruction-file cruft (doot-scoped)
+
+6. **`.agent/config.md`'s "ULTRA-STRICT ROOT SAFETY PROTOCOL"** (wait for
+ explicit "GO" before any system-changing call) contradicts how this
+ project actually runs — confirmed stale 2026-08-14. Pending: fold into
+ whatever the workspace-wide instruction-file cleanup lands on, rather
+ than patch in isolation.
+
+7. **`.agent/worklog.md`** references issues (#66-73) that don't match any
+ real recent work — leftover scaffolding, not maintained. Confirmed
+ stale 2026-08-14. Same pending treatment as #6.
+
+## Integrations
+
+Reviewed 2026-08-14. Five immediate papercuts found and fixed same day:
+`getAtomDetails`'s gtasks case was a stub (logged every completed Google
+Task as literally "Google Task" with no due date); gtasks completion via
+the web Tasks/Timeline tab and the Agent API skipped cache invalidation
+(trello had it, gtasks didn't, in two call sites); `HandleTaskDetailPage`
+hand-duplicated `loadTaskDetailData`'s lookup instead of calling it, so it
+never got the earlier gtasks-detail fix; `PlanToEatAPI.GetRecipes` was
+dead code (own comment: "for Phase 2," never called) — deleted.
+
+Long-term questions surfaced, not decided:
+- Should `GoogleTasksAPI` grow real create/due-date methods (the REST API
+ supports both; the interface just never exposed them)?
+- Should the ~13 copy-pasted `switch source` dispatch blocks collapse into
+ one shared resolver? Not urgent at 3 actionable sources.
+- The ad hoc `findGoogleTask`/`findCard` linear-scan-by-ID lookups are
+ copy-pasted inline in ~5 places instead of reused — style issue at
+ current (personal-scale) data volumes, not a performance one.
+- Read-path caching is already well-unified (generic `CacheFetcher[T]`) —
+ don't over-correct that side, the inconsistency was only ever on
+ write/invalidation.
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index e764130..02e0cb5 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -21,7 +21,6 @@ type TrelloAPI interface {
type PlanToEatAPI interface {
GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error)
GetShoppingList(ctx context.Context) ([]models.ShoppingItem, error)
- GetRecipes(ctx context.Context) error
}
// GoogleCalendarAPI defines the interface for Google Calendar operations
diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go
index 770987a..edd494a 100644
--- a/internal/api/plantoeat.go
+++ b/internal/api/plantoeat.go
@@ -184,11 +184,6 @@ func normalizeMealType(mealType string) string {
}
}
-// GetRecipes fetches recipes (for Phase 2)
-func (c *PlanToEatClient) GetRecipes(ctx context.Context) error {
- return fmt.Errorf("not implemented yet")
-}
-
// GetShoppingList fetches the shopping list by scraping the web interface
// Requires a valid session cookie set via SetSessionCookie
func (c *PlanToEatClient) GetShoppingList(ctx context.Context) ([]models.ShoppingItem, error) {
diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go
index 151520b..577ff87 100644
--- a/internal/handlers/agent.go
+++ b/internal/handlers/agent.go
@@ -450,12 +450,18 @@ func (h *Handler) handleAgentTaskToggle(w http.ResponseWriter, r *http.Request,
if complete {
title, dueDate := h.getAtomDetails(id, source)
_ = h.store.SaveCompletedTask(source, id, title, dueDate)
- if source == "trello" {
+ switch source {
+ case "trello":
_ = h.store.DeleteCard(id)
+ case "gtasks":
+ _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
}
} else {
- if source == "trello" {
+ switch source {
+ case "trello":
_ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ case "gtasks":
+ _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
}
}
diff --git a/internal/handlers/agent_test.go b/internal/handlers/agent_test.go
index 2893a13..ea661b1 100644
--- a/internal/handlers/agent_test.go
+++ b/internal/handlers/agent_test.go
@@ -13,6 +13,7 @@ import (
"task-dashboard/internal/config"
"task-dashboard/internal/models"
+ "task-dashboard/internal/store"
)
func TestHandleAgentAuthRequest(t *testing.T) {
@@ -833,6 +834,46 @@ func TestHandleAgentTaskWriteOperations(t *testing.T) {
})
}
+func TestHandleAgentTaskComplete_Gtasks_InvalidatesCache(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.UpdateCacheMetadata(store.CacheKeyGoogleTasks, 60); err != nil {
+ t.Fatal(err)
+ }
+
+ h := &Handler{store: db, googleTasksClient: &mockGoogleTasksClient{}, config: &config.Config{}}
+
+ session := &models.AgentSession{RequestToken: "rt", AgentName: "A", AgentID: "a-uuid", ExpiresAt: time.Now().Add(5 * time.Minute)}
+ db.CreateAgentSession(session)
+ db.ApproveAgentSession("rt", "st", time.Now().Add(time.Hour))
+
+ req := httptest.NewRequest(http.MethodPost, "/agent/tasks/g1/complete?source=gtasks&listId=list-a", nil)
+ req = req.WithContext(context.WithValue(req.Context(), agentSessionContextKey, session))
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", "g1")
+ req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+ w := httptest.NewRecorder()
+
+ h.HandleAgentTaskComplete(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ meta, err := db.GetCacheMetadata(store.CacheKeyGoogleTasks)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if meta != nil {
+ t.Error("expected gtasks cache metadata to be invalidated after agent-api completion, but it still exists")
+ }
+}
+
func TestHandleAgentCreateOperations(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 7220474..aedbd11 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -667,6 +667,8 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl
switch source {
case "trello":
_ = h.store.DeleteCard(id)
+ case "gtasks":
+ _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
case "doot":
// native tasks stay in DB marked completed; no cache to invalidate
}
@@ -712,7 +714,9 @@ func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) {
}
}
case "gtasks":
- return "Google Task", nil
+ if t, ok := h.findGoogleTask(id); ok {
+ return t.Title, t.DueDate
+ }
}
return "Task", nil
}
@@ -906,30 +910,8 @@ func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) {
return
}
- var title, description string
- switch source {
- case "doot":
- if tasks, err := h.store.GetNativeTasks(); err == nil {
- for _, t := range tasks {
- if t.ID == id {
- title, description = t.Content, t.Description
- break
- }
- }
- }
- 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
- break
- }
- }
- }
- }
- }
-
+ detail := h.loadTaskDetailData(id, source)
+ title := detail.Title
if title == "" {
title = "Task"
}
@@ -941,7 +923,7 @@ func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) {
Description string
CSRFToken string
Saved bool
- }{title, id, source, description, auth.GetCSRFTokenFromContext(r.Context()), r.URL.Query().Get("saved") == "1"}
+ }{title, id, source, detail.Description, auth.GetCSRFTokenFromContext(r.Context()), r.URL.Query().Get("saved") == "1"}
if err := h.renderer.Render(w, "task-detail-page.html", data); err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 8900f66..7b18cf1 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -2475,6 +2475,86 @@ func TestHandleCompleteAtom_DootShowsTitle(t *testing.T) {
}
}
+func TestHandleCompleteAtom_Gtasks_LogsRealTitleAndInvalidatesCache(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ due := time.Now().Add(24 * time.Hour)
+ if err := h.store.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", ListID: "list-a", DueDate: &due, UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.UpdateCacheMetadata(store.CacheKeyGoogleTasks, 60); err != nil {
+ t.Fatal(err)
+ }
+ h.googleTasksClient = &mockGoogleTasksClient{}
+
+ req := httptest.NewRequest("POST", "/complete-atom", nil)
+ req.Form = map[string][]string{"id": {"g1"}, "source": {"gtasks"}, "listId": {"list-a"}}
+ w := httptest.NewRecorder()
+ h.HandleCompleteAtom(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+
+ completed, err := h.store.GetCompletedTasks(10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(completed) != 1 || completed[0].Title != "Renew passport" {
+ t.Errorf("completed log = %+v, want title %q (was hardcoded \"Google Task\" before the getAtomDetails fix)", completed, "Renew passport")
+ }
+
+ meta, err := h.store.GetCacheMetadata(store.CacheKeyGoogleTasks)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if meta != nil {
+ t.Error("expected gtasks cache metadata to be invalidated after completion, but it still exists")
+ }
+}
+
+func TestHandleTaskDetailPage_GtasksSource_LoadsRealTaskFields(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", Notes: "bring photo", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/task?id=g1&source=gtasks", nil)
+ w := httptest.NewRecorder()
+ h.HandleTaskDetailPage(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+
+ mock := h.renderer.(*MockRenderer)
+ if len(mock.Calls) != 1 {
+ t.Fatalf("expected 1 render call, got %d", len(mock.Calls))
+ }
+ type pageData struct {
+ Title string
+ Description string
+ }
+ jsonBytes, _ := json.Marshal(mock.Calls[0].Data)
+ var got pageData
+ if err := json.Unmarshal(jsonBytes, &got); err != nil {
+ t.Fatalf("failed to unmarshal render data: %v", err)
+ }
+ if got.Title != "Renew passport" {
+ t.Errorf("Title = %q, want %q", got.Title, "Renew passport")
+ }
+ if got.Description != "bring photo" {
+ t.Errorf("Description = %q, want %q", got.Description, "bring photo")
+ }
+}
+
func TestHandleTimeline_IncludesBudgetStatusWhenTrackedTaskExists(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()