= {
name: newSprint.name,
@@ -202,7 +196,6 @@ export function SprintBoard() {
startDate: newSprint.startDate || new Date().toISOString(),
endDate: newSprint.endDate || new Date().toISOString(),
status: "planning",
- projectId: selectedProjectId,
}
addSprint(sprint)
@@ -236,7 +229,7 @@ export function SprintBoard() {
}
}
- if (projectSprints.length === 0 && !isCreatingSprint) {
+ if (sprints.length === 0 && !isCreatingSprint) {
return (
@@ -327,7 +320,7 @@ export function SprintBoard() {
className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
>
- {projectSprints.map((sprint) => (
+ {sprints.map((sprint) => (
diff --git a/docs/tanstack-query-zustand-migration-plan.md b/docs/tanstack-query-zustand-migration-plan.md
new file mode 100644
index 0000000..8c4e45f
--- /dev/null
+++ b/docs/tanstack-query-zustand-migration-plan.md
@@ -0,0 +1,227 @@
+# TanStack Query + Zustand Migration Plan
+
+Last updated: February 25, 2026
+
+## Goal
+
+Improve page-load performance and consistency across both projects by introducing a clear data-layer split:
+
+- TanStack Query for all server state (fetching, cache, refetch, invalidation)
+- Zustand for UI/client state only (selection, view mode, dialogs, preferences)
+
+Projects in scope:
+
+- `mission-control` (`/Users/mattbruce/Documents/Projects/OpenClaw/Web/mission-control`)
+- `gantt-board` (`/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board`)
+
+Both apps use the same Supabase backend.
+
+## Locked Decisions
+
+- Rollout strategy: incremental by route
+- Mutation strategy: optimistic updates + rollback on error
+- Freshness strategy: invalidate/refetch only (no Supabase Realtime in this phase)
+- `mission-control` scope for this phase: client data paths only
+ - activity, documents, quick search, task due-dates widget, auth session checks
+ - keep server-rendered dashboard/task/project overview pages unchanged
+- `gantt-board` scope for this phase: migrate board/task/project/sprint client data flows off store-driven sync
+
+## Current Problems To Fix
+
+### `gantt-board`
+
+- `src/stores/useTaskStore.ts` mixes server data + UI state + network side effects
+- frequent full `syncFromServer()` calls
+- repetitive `fetch("/api/auth/users")` and `fetch("/api/auth/session")` across routes/components
+- mutation flow often does write then full refetch
+
+### `mission-control`
+
+- isolated client hooks perform uncached fetches
+ - `hooks/use-activity-feed.ts`
+ - `hooks/useDocuments.ts`
+ - `components/calendar/TaskCalendarIntegration.tsx`
+ - `components/layout/quick-search.tsx`
+- duplicated activity fetch work across multiple widgets/components
+- documents refresh currently uses full page reload
+
+## Target Architecture
+
+1. Per-repo query layer
+
+- `lib/query/client.ts` (or `src/lib/query/client.ts`)
+- `lib/query/keys.ts`
+- `lib/query/fetcher.ts`
+- domain hooks in `lib/query/hooks/*`
+
+2. Global defaults
+
+- `gcTime`: 10 minutes
+- `retry`: 1 for idempotent GET queries
+- `retry`: 0 for auth/session checks and mutation-like flows
+- `refetchOnReconnect`: `true`
+- `refetchOnWindowFocus`: `true` for board/activity/session data, `false` for quick-search query
+
+3. Stale-time defaults
+
+- session: 30s
+- tasks snapshot (active-sprint): 15s
+- tasks snapshot (all): 60s
+- task detail snapshot: 30s
+- users list: 5m
+- projects/sprints lists: 60s
+- documents list: 60s
+- quick search: 20s
+- due-dates widget: 60s
+
+4. Zustand boundary
+
+- retain UI state only
+- remove network fetch/sync logic from stores
+- persist only stable UI preferences and selections
+
+## Query Key Contract
+
+Use stable keys in both repos:
+
+```ts
+["auth", "session"]
+["users", "directory"] // mission-control: /api/users/directory
+["users", "auth-list"] // gantt-board: /api/auth/users
+["tasks", "snapshot", { scope: "active-sprint" | "all", include?: "detail", taskId?: string }]
+["projects", "list"]
+["sprints", "list", { status?: "planning" | "active" | "completed" }]
+["sprints", "current", { projectId?: string, includeCompletedFallback?: boolean }]
+["documents", "list"]
+["tasks", "due-dates", { limit: number }]
+["search", { q: string }]
+["activity", { limit: number, projectId?: string, filterType?: string }]
+```
+
+## Planned Hook Interfaces
+
+- `useSessionQuery()`
+- `useBoardSnapshotQuery(params)` (`gantt-board`)
+- `useUpsertTaskMutation()`
+- `useDeleteTaskMutation()`
+- `useProjectMutations()`
+- `useSprintMutations()`
+- `useActivityQuery(params)` (`mission-control`)
+- `useDocumentsQuery()`
+- `useTasksDueDatesQuery(limit)`
+- `useQuickSearchQuery(q)`
+
+## Phased Implementation
+
+## Phase 1: Shared Infrastructure
+
+Both repos:
+
+1. Ensure root Query provider with shared query-client defaults
+2. Add typed fetcher helper with abort/error normalization
+3. Add query key factory and domain API wrappers
+4. Add cache patch + invalidation utility helpers
+
+## Phase 2: `gantt-board` Main Board Route
+
+1. Replace `syncFromServer`/auth `useEffect` flow in `src/app/page.tsx` with queries
+2. Load active-sprint snapshot first; fetch full snapshot when entering backlog/search
+3. Replace direct users fetch with shared users query
+4. Move task/project/sprint writes to TanStack mutation hooks
+5. Apply optimistic update + rollback + targeted invalidation
+
+## Phase 3: `gantt-board` Secondary Routes
+
+1. Task detail route -> detail query (`include=detail`, `taskId`) + mutation hooks
+2. Projects list and project detail routes -> query-based data + no manual `syncFromServer`
+3. Sprint archive route -> query-based sprints/tasks and derived stats
+4. Search/backlog user lookups -> shared cached users query
+5. Reduce `useTaskStore` to UI-only store with compatibility layer during migration
+
+## Phase 4: `mission-control` Client Data Paths
+
+1. Add Query provider in app layout
+2. Replace `use-activity-feed` internal polling/state with `useQuery` + `refetchInterval: 30000`
+3. Replace `useDocuments` mount fetch + reload refresh with query + invalidation/refetch
+4. Replace due-dates widget `fetch` with query hook
+5. Replace quick-search effect `fetch` with debounced query (`enabled: q.length >= 2`)
+6. Replace login session precheck `useEffect` with session query
+7. Keep server-rendered dashboard/task/project overviews unchanged for this phase
+
+## Phase 5: Cleanup
+
+1. Remove dead network/sync paths from Zustand stores
+2. Remove hardcoded/default mock payloads in stores where no longer used
+3. Remove unused `swr` dependency in `gantt-board` if still unused
+4. Normalize loading/error/empty states from query status
+5. Ensure no full-page reload is used for data refresh paths
+
+## Mutation Invalidation Matrix
+
+- Task create/update/delete/comment/attachment:
+ - invalidate task snapshot keys (all relevant scopes), due-dates keys, and activity keys if impacted
+- Project mutations:
+ - invalidate projects + task snapshots + sprint current/list keys
+- Sprint mutations:
+ - invalidate sprint list/current + task snapshot keys
+- Auth login/logout/account changes:
+ - invalidate session + users keys
+
+## Testing Plan
+
+## Automated
+
+1. Query key stability tests for parameterized keys
+2. Optimistic mutation tests:
+ - success keeps optimistic value and reconciles
+ - error rolls back
+3. Hook integration tests:
+ - active-sprint initial load
+ - full-scope lazy load when switching view
+ - deduped users lookup fetches across mounts
+4. Keep existing `gantt-board` API/CLI contract tests passing
+
+## Manual Acceptance
+
+1. `gantt-board` initial load should issue one session query, one active-sprint query, one users query
+2. Backlog/search switch should not refetch full snapshot on every render
+3. Drag/drop should update instantly and rollback correctly on error
+4. Task detail should load detail payload without forcing global full sync
+5. `mission-control` activity widgets should share cached activity data instead of duplicate requests
+6. Documents refresh should no longer reload the page
+7. Quick search should debounce and reuse cache for repeat queries
+
+## Performance Exit Criteria
+
+1. Warm route visits should render from cache first with background refetch
+2. Redundant network requests should drop materially on board/activity/document-heavy flows
+3. No hard refresh required to see server-backed updates
+
+## Migration Touchpoints (Primary)
+
+`mission-control`:
+
+- `app/layout.tsx`
+- `hooks/use-activity-feed.ts`
+- `hooks/useDocuments.ts`
+- `components/layout/quick-search.tsx`
+- `components/calendar/TaskCalendarIntegration.tsx`
+- `app/login/page.tsx`
+
+`gantt-board`:
+
+- `src/components/QueryProvider.tsx`
+- `src/stores/useTaskStore.ts`
+- `src/app/page.tsx`
+- `src/app/tasks/[taskId]/page.tsx`
+- `src/app/projects/page.tsx`
+- `src/app/projects/[id]/page.tsx`
+- `src/app/sprints/archive/page.tsx`
+
+## Notes For Future Chats
+
+If a new chat needs to continue this effort, reference:
+
+- this file path directly
+- "TanStack Query + Zustand migration plan (Feb 25, 2026)"
+- the locked decisions section above
diff --git a/lib/data/projects.ts b/lib/data/projects.ts
index f000961..51c53ae 100644
--- a/lib/data/projects.ts
+++ b/lib/data/projects.ts
@@ -15,7 +15,6 @@ export interface Sprint {
status: "planning" | "active" | "completed" | "cancelled";
startDate: string;
endDate: string;
- projectId: string;
goal?: string;
}
@@ -45,11 +44,6 @@ export async function fetchAllSprints(): Promise {
return snapshot.sprints;
}
-export async function fetchProjectSprints(projectId: string): Promise {
- const sprints = await fetchAllSprints();
- return sprints.filter((sprint) => sprint.projectId === projectId);
-}
-
export async function fetchActiveSprint(): Promise {
const sprints = await fetchAllSprints();
const active = sprints.find((sprint) => sprint.status === "active");
diff --git a/stores/useTaskStore.ts b/stores/useTaskStore.ts
index 1e314cd..b8c7cba 100644
--- a/stores/useTaskStore.ts
+++ b/stores/useTaskStore.ts
@@ -13,7 +13,6 @@ export interface Sprint {
startDate: string
endDate: string
status: SprintStatus
- projectId: string
createdAt: string
}
@@ -133,7 +132,6 @@ const defaultSprints: Sprint[] = [
startDate: sprint1Start.toISOString(),
endDate: sprint1End.toISOString(),
status: 'active',
- projectId: '2',
createdAt: new Date().toISOString(),
},
]
@@ -496,6 +494,43 @@ const normalizeTask = (task: Task): Task => ({
assigneeAvatarUrl: typeof task.assigneeAvatarUrl === 'string' && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined,
})
+const isSprintStatus = (value: unknown): value is SprintStatus =>
+ value === 'planning' || value === 'active' || value === 'completed'
+
+const normalizeSprints = (value: unknown): Sprint[] => {
+ if (!Array.isArray(value)) return []
+
+ const normalized: Sprint[] = []
+
+ for (const entry of value) {
+ if (!entry || typeof entry !== 'object') continue
+ const candidate = entry as Partial
+
+ if (typeof candidate.id !== 'string' || candidate.id.trim().length === 0) continue
+ if (typeof candidate.name !== 'string' || candidate.name.trim().length === 0) continue
+ if (typeof candidate.startDate !== 'string' || candidate.startDate.trim().length === 0) continue
+ if (typeof candidate.endDate !== 'string' || candidate.endDate.trim().length === 0) continue
+ if (!isSprintStatus(candidate.status)) continue
+
+ const sprint: Sprint = {
+ id: candidate.id,
+ name: candidate.name,
+ startDate: candidate.startDate,
+ endDate: candidate.endDate,
+ status: candidate.status,
+ createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
+ }
+
+ if (typeof candidate.goal === 'string') {
+ sprint.goal = candidate.goal
+ }
+
+ normalized.push(sprint)
+ }
+
+ return normalized
+}
+
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
comments
.filter((comment) => comment.id !== targetId)
@@ -561,7 +596,7 @@ export const useTaskStore = create()(
set({
projects: data.projects || [],
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
- sprints: data.sprints || [],
+ sprints: normalizeSprints(data.sprints),
lastSynced: Date.now(),
})
console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length)
@@ -616,18 +651,16 @@ export const useTaskStore = create()(
set((state) => {
const newProjects = state.projects.filter((p) => p.id !== id)
const newTasks = state.tasks.filter((t) => t.projectId !== id)
- const newSprints = state.sprints.filter((s) => s.projectId !== id)
- syncToServer(newProjects, newTasks, newSprints)
+ syncToServer(newProjects, newTasks, state.sprints)
return {
projects: newProjects,
tasks: newTasks,
- sprints: newSprints,
selectedProjectId: state.selectedProjectId === id ? null : state.selectedProjectId,
}
})
},
- selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }),
+ selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null }),
addTask: (task) => {
const actor = profileToCommentAuthor(get().currentUser)