Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-25 13:59:26 -06:00
parent 64bcbb1b97
commit fd3dcc2cad
6 changed files with 273 additions and 52 deletions

View File

@ -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">

View File

@ -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)

View File

@ -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>

View 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

View File

@ -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");

View File

@ -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)