# Refactoring Plan — Task Dashboard **Generated:** 2026-03-16 **Baseline commit:** `7d627d1` --- ## 1. Executive Summary The Task Dashboard codebase is in solid shape overall: it builds cleanly, uses consistent patterns (CacheFetcher, Atom abstraction, chi router), and has a reasonable test suite for the core workflows. The most significant concerns are a **failing test in CI** caused by a research file calling a deprecated API endpoint, **low test coverage** in auth (20.4%) and API clients (46.2%), and **scattered raw-HTML string building** in several handlers that bypasses the template system and makes those paths untestable. Secondary concerns include a correctness bug in `GetTasksByDateRange` (unused `start` parameter), a meal-grouping algorithm duplicated verbatim in two separate handlers, and a `convertSyncItemToTask` wrapper that allocates a one-element slice just to unwrap it. None of these are regressions waiting to happen, but they add maintenance surface and mislead future readers. The codebase does NOT need a large structural overhaul. The recommended approach is targeted, low-risk fixes ordered by impact. --- ## 2. Baseline Metrics | Metric | Value | |--------|-------| | Build status | ✅ Clean | | Test suite | ❌ 1 failing (`TestTodoistSyncResearch`) | | `internal/api` coverage | 46.2% | | `internal/auth` coverage | 20.4% | | `internal/handlers` coverage | 53.4% | | `internal/store` coverage | 65.9% | | `internal/models` coverage | 73.6% | | `internal/config` coverage | 87.2% | | `internal/middleware` coverage | 86.8% | | Total non-test Go source lines | ~6,200 | | handlers.go (single file) | 1,240 lines | | store/sqlite.go (single file) | 1,529 lines | --- ## 3. Refactoring Catalogue Items are ordered by **impact × effort** (high-impact, low-effort first). --- ### RF-01: Fix failing research test (breaking CI) **Priority:** Critical **Effort:** Small **Risk:** Low **Current state:** `internal/api/research_test.go:TestTodoistSyncResearch` calls the real Todoist Sync v9 API (`https://api.todoist.com/sync/v9/sync`) using a raw HTTP client. The test has a skip guard for `TODOIST_API_KEY == ""`, but the key is set in the sandbox environment. Todoist has deprecated this endpoint and now returns HTTP 410, causing `go test ./...` to exit non-zero. **Target state:** Tag the file with a `//go:build integration` constraint so it only runs explicitly (`go test -tags integration ./...`). This immediately unblocks CI without deleting useful research. **Affected files:** - `internal/api/research_test.go` **Why it matters:** Every run of `go test ./...` fails, which masks real regressions and violates the TDD green-bar requirement. **Before:** ```go // research_test.go (no build tag) func TestTodoistSyncResearch(t *testing.T) { apiKey := os.Getenv("TODOIST_API_KEY") if apiKey == "" { t.Skip("TODOIST_API_KEY not set, skipping research test") } // ... calls real API that returns 410 ``` **After:** ```go //go:build integration package api // ... rest of file unchanged ``` --- ### RF-02: Fix correctness bug in `GetTasksByDateRange` — unused `start` param **Priority:** High **Effort:** Small **Risk:** Low **Current state:** `store/sqlite.go:GetTasksByDateRange(start, end time.Time)` accepts a `start` parameter but the SQL query ignores it: ```sql WHERE due_date IS NOT NULL AND due_date <= ? AND completed = FALSE ``` This returns **all tasks with due_date ≤ end**, including tasks from years ago. The function comment says it intentionally returns overdue tasks, but the `start` parameter is actively misleading — callers believe they are filtering to a range. **Target state:** Either (a) remove the `start` parameter to match actual behavior, or (b) add a lower bound but exclude items before an arbitrary "overdue cutoff" (e.g., 30 days). Option (a) is simpler. **Affected files:** - `internal/store/sqlite.go` — remove `start` from signature - `internal/handlers/timeline_logic.go` — update call site - `internal/store/sqlite_test.go` — update test helper calls **Why it matters:** The signature is a lie. A reader writing a new call with `start=30 days ago` expects filtering that never happens. This is a latent correctness bug: timeline items from an indefinite past can appear. **Before:** ```go func (s *Store) GetTasksByDateRange(start, end time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT ... FROM tasks WHERE due_date IS NOT NULL AND due_date <= ? -- start ignored! AND completed = FALSE `, end) ``` **After:** ```go // GetTasksByDateRange retrieves tasks due on or before end, including overdue. func (s *Store) GetTasksByDateRange(end time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT ... FROM tasks WHERE due_date IS NOT NULL AND due_date <= ? AND completed = FALSE `, end) ``` --- ### RF-03: Extract meal-grouping logic to a shared helper **Priority:** High **Effort:** Small **Risk:** Low **Current state:** The meal-grouping algorithm (group meals by date+mealType, combine recipe names, apply time defaults) appears **verbatim in two places**: 1. `handlers.go:HandleTabMeals` (lines ~1149–1185) 2. `timeline_logic.go:BuildTimeline` (lines ~51–106) Any change to meal display (e.g., adding ingredients, changing sort order) must be made in both places. Additionally, the `mealTypeOrder()` function in `handlers.go` and the SQL `CASE` expression in `store.GetMeals()` both encode the same ordering (`breakfast=0, lunch=1, dinner=2`). **Target state:** Extract a `groupAndSortMeals(meals []models.Meal) []CombinedMeal` helper in a shared location (e.g., `handlers/meals.go`). Both `HandleTabMeals` and `BuildTimeline` call this helper. Remove the duplicate `CASE` in `GetMeals()` by relying on the Go-layer sort (or add a constant). **Affected files:** - `internal/handlers/handlers.go` — use helper, remove local grouping code - `internal/handlers/timeline_logic.go` — use same helper - New: `internal/handlers/meals.go` (or added to `helpers.go`) **Why it matters:** DRY. Any meal display change now requires one edit, not two. This is the highest-value duplication removal in the codebase. **Before (abbreviated):** ```go // handlers.go HandleTabMeals type mealKey struct { date string; mealType string } mealGroups := make(map[mealKey][]models.Meal) for _, meal := range meals { ... } var combined []CombinedMeal for _, group := range mealGroups { ... } sort.Slice(combined, ...) // timeline_logic.go BuildTimeline — identical logic type mealKey struct { date string; mealType string } mealGroups := make(map[mealKey][]models.Meal) for _, meal := range meals { ... } ``` **After:** ```go // handlers/meals.go func groupMeals(meals []models.Meal) []CombinedMeal { ... } // handlers.go — uses helper combined := groupMeals(meals) // timeline_logic.go — uses same helper for _, cm := range groupMeals(meals) { ... } ``` --- ### RF-04: Replace inline HTML builders with templates **Priority:** High **Effort:** Medium **Risk:** Low **Current state:** Three handlers build and return raw HTML strings instead of using the template system. This bypasses XSS safety, breaks testability, and is inconsistent with all other handlers: 1. `handlers.go:HandleGetBugs` (lines ~854–866) — inline `fmt.Fprintf` loop 2. `handlers.go:HandleGetTaskDetail` (lines ~922–934) — 8-line HTML literal 3. `handlers.go:HandleGetListsOptions` (lines ~838–844) — `fmt.Fprintf` loop All other handlers use `HTMLResponse(w, h.renderer, "template-name", data)`. **Target state:** Move each of these to named partial templates. The handlers become 5-line `HTMLResponse(...)` calls. **Affected files:** - `internal/handlers/handlers.go` - `web/templates/partials/bugs-list.html` (new) - `web/templates/partials/task-detail.html` (new) - `web/templates/partials/list-options.html` (new) **Why it matters:** Inline HTML builders cannot be tested via `assertTemplateContains`. They also defeat Go's auto-escaping, requiring manual `template.HTMLEscapeString()` calls that can be forgotten. One missed escape = XSS. **Before:** ```go func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) { // ... w.Header().Set("Content-Type", "text/html") if len(bugs) == 0 { _, _ = fmt.Fprint(w, `

No bugs reported yet.

`) return } for _, bug := range bugs { _, _ = fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(bug.Description), ...) } } ``` **After:** ```go func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) { bugs, err := h.store.GetBugs() if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to fetch bugs", err) return } HTMLResponse(w, h.renderer, "bugs-list", bugs) } ``` --- ### RF-05: Add `GetTask(id)` and `GetCard(id)` store methods **Priority:** Medium **Effort:** Small **Risk:** Low **Current state:** `handlers.go:getAtomDetails()` (lines 732–768) needs a single task or card by ID. It does this by fetching **all** tasks / all boards / all cards and iterating: ```go case "todoist": if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { if t.ID == id { return t.Content, t.DueDate } } } case "trello": if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { return c.Name, c.DueDate } } } } ``` `HandleGetTaskDetail` (lines ~888–935) has the same pattern for fetching a single task. **Target state:** Add `GetTask(id string) (*models.Task, error)` and `GetCard(id string) (*models.Card, error)` to the store. Use indexed SQL lookups instead of O(n) Go iteration. **Affected files:** - `internal/store/sqlite.go` — add two methods - `internal/handlers/handlers.go` — use new methods in `getAtomDetails` and `HandleGetTaskDetail` - `internal/store/sqlite_test.go` — add tests for new methods **Why it matters:** Each task completion currently reads ALL tasks from SQLite. More importantly, it's semantically wrong — these are O(1) operations disguised as O(n). --- ### RF-06: Eliminate `convertSyncItemToTask` wrapper **Priority:** Medium **Effort:** Small **Risk:** Low **Current state:** `handlers.go:convertSyncItemToTask` (lines 412–430) wraps a single item in a slice, calls `ConvertSyncItemsToTasks`, and unwraps: ```go func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap map[string]string) models.Task { items := api.ConvertSyncItemsToTasks([]api.SyncItemResponse{item}, projectMap) if len(items) > 0 { return items[0] } // Fallback: reconstruct manually (same logic duplicated) return models.Task{ ... } } ``` The fallback path also duplicates the field mapping from `ConvertSyncItemsToTasks`. **Target state:** Export a `ConvertSyncItemToTask(item SyncItemResponse, projectMap map[string]string) (models.Task, bool)` function in the `api` package, returning `(task, wasActive)`. The handler calls this directly for incremental sync items. The `convertSyncItemToTask` method on Handler is deleted. **Affected files:** - `internal/api/todoist.go` — add single-item converter - `internal/handlers/handlers.go` — remove method, use api package function - `internal/api/todoist_test.go` — add test for single-item converter **Why it matters:** Clarity and correctness. The current fallback silently drops due-date parsing for the single-item case (note: `ConvertSyncItemsToTasks` skips completed/deleted items, so the fallback IS reachable for completed items being reported by incremental sync). --- ### RF-07: Remove `GetMealsByDateRange` wrapper **Priority:** Low **Effort:** Tiny **Risk:** None **Current state:** ```go // sqlite.go func (s *Store) GetMealsByDateRange(start, end time.Time) ([]models.Meal, error) { return s.GetMeals(start, end) } ``` This is a one-line wrapper with identical signature and semantics to `GetMeals`. Both exist in the public API of Store, creating confusion about which to call. **Target state:** Delete `GetMealsByDateRange`. All callers use `GetMeals` directly. (Only one caller exists: `timeline_logic.go`.) **Affected files:** - `internal/store/sqlite.go` — remove method - `internal/handlers/timeline_logic.go` — call `GetMeals` directly --- ### RF-08: Standardize `ParseForm` + field extraction pattern **Priority:** Low **Effort:** Medium **Risk:** Low **Current state:** Most handlers ignore the `requireFormValue` helper defined in `helpers.go` and manually call `r.ParseForm()` + `r.FormValue()` with explicit nil checks. `parseFormOr400` is used in only 2 of ~20 handler functions. The pattern varies: ```go // Pattern A (most common): 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 } // Pattern B (2 handlers): if !parseFormOr400(w, r) { return } id := r.FormValue("id") if id == "" { ... } // Pattern C (0 usages): id, ok := requireFormValue(w, r, "id") if !ok { return } ``` **Target state:** Migrate all handlers to Pattern C (`requireFormValue`) where applicable. This reduces handler boilerplate from 6-8 lines to 2. Add a test for `requireFormValue`. **Affected files:** - `internal/handlers/handlers.go` - `internal/handlers/settings.go` - `internal/handlers/shopping.go` - `internal/handlers/helpers.go` — add tests --- ### RF-09: Add `//go:build integration` tag to Trello research test **Priority:** Low **Effort:** Tiny **Risk:** None **Current state:** `TestTrelloOptimizationResearch` also makes real network calls (it passes currently only because the Trello token in the environment is valid). It should share the `integration` build tag applied in RF-01. **Affected files:** - `internal/api/research_test.go` --- ### RF-10: Investigate Todoist Sync v9 endpoint status **Priority:** Medium **Effort:** Small **Risk:** Medium **Current state:** `api/todoist.go` uses `todoistSyncBaseURL = "https://api.todoist.com/sync/v9"` for the incremental sync client. The research test reveals the v9 sync endpoint returns 410 when called. However, the production sync tests pass because they use mocked HTTP servers. **Why it matters:** If `/sync/v9/sync` is truly deprecated, the production sync path will fail silently (falling back to stale cache). This needs live verification. **Target state:** Add an integration test for `TodoistClient.Sync()` against the real API (behind the `integration` build tag) to confirm the endpoint works or identify the correct migration path. **Affected files:** - `internal/api/todoist_test.go` — add integration test --- ## 4. Test Coverage Gaps The following areas have no tests or critically insufficient coverage: | Area | Coverage | Gap | |------|----------|-----| | `internal/api` — Google Calendar client | ~0% | No unit tests for `GetUpcomingEvents`, `parseEventTime`, `deduplicateEvents` | | `internal/api` — Google Tasks client | ~0% | No unit tests for `GetTasks`, `GetTasksByDateRange`, `CompleteTask` | | `internal/auth` — middleware | 20.4% | Session load/save, CSRF token injection not directly tested | | `internal/handlers` — Planning tab | 0% | `HandleTabPlanning` has complex logic (3 sort passes, dual categorization) with no test | | `internal/handlers` — Meals tab | 0% | `HandleTabMeals` with meal grouping has no test | | `internal/handlers` — Bug handlers | 0% | `HandleGetBugs`, `HandleReportBug` have no tests | | `internal/handlers` — Task detail | 0% | `HandleGetTaskDetail`, `HandleUpdateTask` have no tests | | `internal/handlers` — List options | 0% | `HandleGetListsOptions` has no test | | `internal/store` — agent session expiry | partial | Expired session handling not tested | | `internal/store` — `GetCompletedTasks` | partial | Time parsing for SQLite format not regression-tested | ### Priority test additions 1. **Google Calendar unit test** — mock the Google API client, test `deduplicateEvents` and `parseEventTime` in isolation (these are pure functions with no HTTP dependency). 2. **`HandleTabPlanning` test** — this handler has the most branching logic in the codebase (events/tasks/cards across 3 time windows, 2 categorizations). One happy-path integration test and one edge case (item right on the `tomorrow` boundary). 3. **`GetCompletedTasks` time parsing test** — the function parses SQLite's `"2006-01-02 15:04:05"` format manually. A test should verify this doesn't silently zero-out `CompletedAt` on timezone changes. 4. **Auth middleware coverage** — expand to test CSRF token injection and session creation edge cases. 5. **Google Tasks unit tests** — `GetTasksByDateRange` applies date filtering in Go (not SQL), which makes it particularly important to test boundary conditions. --- ## 5. Known Gaps from SESSION_STATE.md | Known Gap | Status in This Plan | |-----------|---------------------| | Google Tasks API client lacks dedicated unit tests | Addressed in RF-10 + Test Gap §1 | | WebAuthn requires env vars in production | Not a code issue — deploy concern only | | Agent write operations (Phase 2, 3) | Out of scope for refactoring | Additional finding not in SESSION_STATE: - **`GetTasksByDateRange` silently drops `start` filter** (RF-02) — this was not previously identified. The overdue-task comment masks the bug. - **Research test breaks CI** (RF-01) — not documented anywhere. --- ## 6. Do-Not-Refactor List | Area | Reason | |------|--------| | `CacheFetcher[T]` generic pattern | Well-designed, tested, and stable. | | `Atom` model + `ComputeUIFields` | Correct and well-tested. | | `handleAtomToggle` dispatch switch | Clear, tested, and covers all sources. | | Agent auth flow (`agent.go`) | Comprehensive tests, Phase 2/3 coming soon. | | WebSocket hub (`websocket.go`) | Works correctly; testing requires goroutine coordination. Leave for now. | | Migration system (`runMigrations`) | Simple and correct. SQLite doesn't support transactional DDL. | | `sortTasksByUrgency` / `SortAtomsByUrgency` | Two different sort strategies for two views — not duplication. | | Auth bcrypt handlers | Correct and the coverage is what it is given bcrypt timing. | --- ## 7. Implementation Roadmap ### Phase 1 — High-impact, zero-regression risk (do first) 1. **RF-01** — Add `//go:build integration` to research_test.go → fixes CI 2. **RF-09** — Tag Trello research test 3. **RF-02** — Remove unused `start` param from `GetTasksByDateRange` 4. **RF-07** — Delete `GetMealsByDateRange` wrapper 5. **Add Google Calendar unit tests** (deduplicateEvents, parseEventTime — pure functions) ### Phase 2 — Medium-impact improvements 6. **RF-05** — Add `GetTask(id)` and `GetCard(id)` store methods with tests 7. **RF-06** — Eliminate `convertSyncItemToTask` wrapper 8. **RF-03** — Extract shared `groupMeals()` helper 9. **RF-10** — Add Todoist Sync v9 integration test to confirm endpoint health 10. **Add `HandleTabPlanning` test** (happy path + boundary) 11. **Add `HandleTabMeals` test** ### Phase 3 — Template migration + broader coverage 12. **RF-04** — Move inline HTML builders to templates; add tests for each 13. **RF-08** — Standardize form parsing with `requireFormValue` 14. **Add Google Tasks unit tests** (date-range filtering, completion) 15. **Expand auth middleware tests** 16. **Add `HandleGetBugs`, `HandleReportBug` tests** --- ## Appendix: Specific Code Locations | Issue | File | Lines | |-------|------|-------| | Failing research test | `internal/api/research_test.go` | 1, 20 | | `start` param ignored | `internal/store/sqlite.go` | 741–748 | | Meal grouping duplicate #1 | `internal/handlers/handlers.go` | 1149–1185 | | Meal grouping duplicate #2 | `internal/handlers/timeline_logic.go` | 51–106 | | Inline HTML: bugs | `internal/handlers/handlers.go` | 854–866 | | Inline HTML: task detail | `internal/handlers/handlers.go` | 922–934 | | Inline HTML: list options | `internal/handlers/handlers.go` | 838–844 | | `convertSyncItemToTask` | `internal/handlers/handlers.go` | 412–430 | | `GetAtomDetails` O(n) scan | `internal/handlers/handlers.go` | 732–768 | | `GetMealsByDateRange` wrapper | `internal/store/sqlite.go` | 757–759 | | `requireFormValue` unused | `internal/handlers/helpers.go` | 19–26 |