From fd3dcc2cad6664fb3261443aef0fce5657cecaaf Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Wed, 25 Feb 2026 13:59:26 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- app/projects/page.tsx | 30 +-- components/gantt/BacklogView.tsx | 2 - components/gantt/SprintBoard.tsx | 13 +- docs/tanstack-query-zustand-migration-plan.md | 227 ++++++++++++++++++ lib/data/projects.ts | 6 - stores/useTaskStore.ts | 47 +++- 6 files changed, 273 insertions(+), 52 deletions(-) create mode 100644 docs/tanstack-query-zustand-migration-plan.md diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 209222a..d05e3a7 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -26,7 +26,6 @@ import { import Link from "next/link"; import { fetchProjectsWithStats, - fetchAllSprints, fetchActiveSprint, countSprintsByStatus, getProjectHealth, @@ -265,10 +264,8 @@ function ProjectCard({ stats }: { stats: ProjectStats }) { } // Active Sprint Card Component -function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectStats: ProjectStats[] }) { +function ActiveSprintCard({ sprint }: { sprint: Sprint }) { const daysRemaining = getSprintDaysRemaining(sprint); - const sprintProject = projectStats.find(p => p.project.id === sprint.projectId); - const sprintProgress = sprintProject?.progress || 0; const isOverdue = daysRemaining < 0; return ( @@ -294,30 +291,11 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta

{sprint.goal}

)} -
-
- Sprint Progress - {sprintProgress}% -
-
-
-
-
-
{formatSprintDateRange(sprint)} - {sprintProject && ( - - - {sprintProject.project.name} - - )}
- +
diff --git a/components/gantt/BacklogView.tsx b/components/gantt/BacklogView.tsx index 4b7242a..3a33c54 100644 --- a/components/gantt/BacklogView.tsx +++ b/components/gantt/BacklogView.tsx @@ -226,7 +226,6 @@ export function BacklogView() { const { tasks, sprints, - selectedProjectId, updateTask, addSprint, } = useTaskStore() @@ -352,7 +351,6 @@ export function BacklogView() { startDate: newSprint.startDate || new Date().toISOString(), endDate: newSprint.endDate || new Date().toISOString(), status: "planning", - projectId: selectedProjectId || "2", }) setIsCreatingSprint(false) diff --git a/components/gantt/SprintBoard.tsx b/components/gantt/SprintBoard.tsx index d1845d0..349fb46 100644 --- a/components/gantt/SprintBoard.tsx +++ b/components/gantt/SprintBoard.tsx @@ -149,7 +149,6 @@ export function SprintBoard() { sprints, selectedSprintId, selectSprint, - selectedProjectId, updateTask, selectTask, addSprint, @@ -173,11 +172,6 @@ export function SprintBoard() { }) ) - // Get sprints for selected project - const projectSprints = sprints.filter( - (s) => s.projectId === selectedProjectId - ) - // Get current sprint const currentSprint = sprints.find((s) => s.id === selectedSprintId) @@ -194,7 +188,7 @@ export function SprintBoard() { const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null const handleCreateSprint = () => { - if (!newSprint.name || !selectedProjectId) return + if (!newSprint.name) return const sprint: Omit = { 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)