Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
64bcbb1b97
commit
fd3dcc2cad
@ -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
|
||||
<p className="text-sm text-muted-foreground">{sprint.goal}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Sprint Progress</span>
|
||||
<span className="font-medium">{sprintProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${sprintProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatSprintDateRange(sprint)}
|
||||
</span>
|
||||
{sprintProject && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderKanban className="w-3 h-3" />
|
||||
{sprintProject.project.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
@ -338,15 +316,13 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
|
||||
export default async function ProjectsOverviewPage() {
|
||||
// Fetch all data in parallel
|
||||
let projectStats: ProjectStats[] = [];
|
||||
let sprints: Sprint[] = [];
|
||||
let activeSprint: Sprint | null = null;
|
||||
let sprintCounts = { planning: 0, active: 0, completed: 0, total: 0 };
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
try {
|
||||
[projectStats, sprints, activeSprint, sprintCounts] = await Promise.all([
|
||||
[projectStats, activeSprint, sprintCounts] = await Promise.all([
|
||||
fetchProjectsWithStats(),
|
||||
fetchAllSprints(),
|
||||
fetchActiveSprint(),
|
||||
countSprintsByStatus(),
|
||||
]);
|
||||
@ -443,7 +419,7 @@ export default async function ProjectsOverviewPage() {
|
||||
{activeSprint && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<ActiveSprintCard sprint={activeSprint} projectStats={projectStats} />
|
||||
<ActiveSprintCard sprint={activeSprint} />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<Sprint, "id" | "createdAt"> = {
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
|
||||
<Flag className="w-12 h-12 mb-4 text-zinc-600" />
|
||||
@ -327,7 +320,7 @@ export function SprintBoard() {
|
||||
className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
|
||||
>
|
||||
<option value="">Select a sprint...</option>
|
||||
{projectSprints.map((sprint) => (
|
||||
{sprints.map((sprint) => (
|
||||
<option key={sprint.id} value={sprint.id}>
|
||||
{sprint.name}
|
||||
</option>
|
||||
|
||||
227
docs/tanstack-query-zustand-migration-plan.md
Normal file
227
docs/tanstack-query-zustand-migration-plan.md
Normal file
@ -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
|
||||
@ -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<Sprint[]> {
|
||||
return snapshot.sprints;
|
||||
}
|
||||
|
||||
export async function fetchProjectSprints(projectId: string): Promise<Sprint[]> {
|
||||
const sprints = await fetchAllSprints();
|
||||
return sprints.filter((sprint) => sprint.projectId === projectId);
|
||||
}
|
||||
|
||||
export async function fetchActiveSprint(): Promise<Sprint | null> {
|
||||
const sprints = await fetchAllSprints();
|
||||
const active = sprints.find((sprint) => sprint.status === "active");
|
||||
|
||||
@ -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<Sprint>
|
||||
|
||||
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<TaskStore>()(
|
||||
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<TaskStore>()(
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user