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 Link from "next/link";
|
||||||
import {
|
import {
|
||||||
fetchProjectsWithStats,
|
fetchProjectsWithStats,
|
||||||
fetchAllSprints,
|
|
||||||
fetchActiveSprint,
|
fetchActiveSprint,
|
||||||
countSprintsByStatus,
|
countSprintsByStatus,
|
||||||
getProjectHealth,
|
getProjectHealth,
|
||||||
@ -265,10 +264,8 @@ function ProjectCard({ stats }: { stats: ProjectStats }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Active Sprint Card Component
|
// Active Sprint Card Component
|
||||||
function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectStats: ProjectStats[] }) {
|
function ActiveSprintCard({ sprint }: { sprint: Sprint }) {
|
||||||
const daysRemaining = getSprintDaysRemaining(sprint);
|
const daysRemaining = getSprintDaysRemaining(sprint);
|
||||||
const sprintProject = projectStats.find(p => p.project.id === sprint.projectId);
|
|
||||||
const sprintProgress = sprintProject?.progress || 0;
|
|
||||||
const isOverdue = daysRemaining < 0;
|
const isOverdue = daysRemaining < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -294,30 +291,11 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
|
|||||||
<p className="text-sm text-muted-foreground">{sprint.goal}</p>
|
<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">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3" />
|
<Calendar className="w-3 h-3" />
|
||||||
{formatSprintDateRange(sprint)}
|
{formatSprintDateRange(sprint)}
|
||||||
</span>
|
</span>
|
||||||
{sprintProject && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<FolderKanban className="w-3 h-3" />
|
|
||||||
{sprintProject.project.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@ -338,15 +316,13 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
|
|||||||
export default async function ProjectsOverviewPage() {
|
export default async function ProjectsOverviewPage() {
|
||||||
// Fetch all data in parallel
|
// Fetch all data in parallel
|
||||||
let projectStats: ProjectStats[] = [];
|
let projectStats: ProjectStats[] = [];
|
||||||
let sprints: Sprint[] = [];
|
|
||||||
let activeSprint: Sprint | null = null;
|
let activeSprint: Sprint | null = null;
|
||||||
let sprintCounts = { planning: 0, active: 0, completed: 0, total: 0 };
|
let sprintCounts = { planning: 0, active: 0, completed: 0, total: 0 };
|
||||||
let errorMessage: string | null = null;
|
let errorMessage: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[projectStats, sprints, activeSprint, sprintCounts] = await Promise.all([
|
[projectStats, activeSprint, sprintCounts] = await Promise.all([
|
||||||
fetchProjectsWithStats(),
|
fetchProjectsWithStats(),
|
||||||
fetchAllSprints(),
|
|
||||||
fetchActiveSprint(),
|
fetchActiveSprint(),
|
||||||
countSprintsByStatus(),
|
countSprintsByStatus(),
|
||||||
]);
|
]);
|
||||||
@ -443,7 +419,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
{activeSprint && (
|
{activeSprint && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<ActiveSprintCard sprint={activeSprint} projectStats={projectStats} />
|
<ActiveSprintCard sprint={activeSprint} />
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|||||||
@ -226,7 +226,6 @@ export function BacklogView() {
|
|||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
sprints,
|
sprints,
|
||||||
selectedProjectId,
|
|
||||||
updateTask,
|
updateTask,
|
||||||
addSprint,
|
addSprint,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
@ -352,7 +351,6 @@ export function BacklogView() {
|
|||||||
startDate: newSprint.startDate || new Date().toISOString(),
|
startDate: newSprint.startDate || new Date().toISOString(),
|
||||||
endDate: newSprint.endDate || new Date().toISOString(),
|
endDate: newSprint.endDate || new Date().toISOString(),
|
||||||
status: "planning",
|
status: "planning",
|
||||||
projectId: selectedProjectId || "2",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsCreatingSprint(false)
|
setIsCreatingSprint(false)
|
||||||
|
|||||||
@ -149,7 +149,6 @@ export function SprintBoard() {
|
|||||||
sprints,
|
sprints,
|
||||||
selectedSprintId,
|
selectedSprintId,
|
||||||
selectSprint,
|
selectSprint,
|
||||||
selectedProjectId,
|
|
||||||
updateTask,
|
updateTask,
|
||||||
selectTask,
|
selectTask,
|
||||||
addSprint,
|
addSprint,
|
||||||
@ -173,11 +172,6 @@ export function SprintBoard() {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get sprints for selected project
|
|
||||||
const projectSprints = sprints.filter(
|
|
||||||
(s) => s.projectId === selectedProjectId
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get current sprint
|
// Get current sprint
|
||||||
const currentSprint = sprints.find((s) => s.id === selectedSprintId)
|
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 activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
|
||||||
|
|
||||||
const handleCreateSprint = () => {
|
const handleCreateSprint = () => {
|
||||||
if (!newSprint.name || !selectedProjectId) return
|
if (!newSprint.name) return
|
||||||
|
|
||||||
const sprint: Omit<Sprint, "id" | "createdAt"> = {
|
const sprint: Omit<Sprint, "id" | "createdAt"> = {
|
||||||
name: newSprint.name,
|
name: newSprint.name,
|
||||||
@ -202,7 +196,6 @@ export function SprintBoard() {
|
|||||||
startDate: newSprint.startDate || new Date().toISOString(),
|
startDate: newSprint.startDate || new Date().toISOString(),
|
||||||
endDate: newSprint.endDate || new Date().toISOString(),
|
endDate: newSprint.endDate || new Date().toISOString(),
|
||||||
status: "planning",
|
status: "planning",
|
||||||
projectId: selectedProjectId,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addSprint(sprint)
|
addSprint(sprint)
|
||||||
@ -236,7 +229,7 @@ export function SprintBoard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (projectSprints.length === 0 && !isCreatingSprint) {
|
if (sprints.length === 0 && !isCreatingSprint) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
|
<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" />
|
<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"
|
className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-slate-100"
|
||||||
>
|
>
|
||||||
<option value="">Select a sprint...</option>
|
<option value="">Select a sprint...</option>
|
||||||
{projectSprints.map((sprint) => (
|
{sprints.map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name}
|
{sprint.name}
|
||||||
</option>
|
</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";
|
status: "planning" | "active" | "completed" | "cancelled";
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
projectId: string;
|
|
||||||
goal?: string;
|
goal?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,11 +44,6 @@ export async function fetchAllSprints(): Promise<Sprint[]> {
|
|||||||
return snapshot.sprints;
|
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> {
|
export async function fetchActiveSprint(): Promise<Sprint | null> {
|
||||||
const sprints = await fetchAllSprints();
|
const sprints = await fetchAllSprints();
|
||||||
const active = sprints.find((sprint) => sprint.status === "active");
|
const active = sprints.find((sprint) => sprint.status === "active");
|
||||||
|
|||||||
@ -13,7 +13,6 @@ export interface Sprint {
|
|||||||
startDate: string
|
startDate: string
|
||||||
endDate: string
|
endDate: string
|
||||||
status: SprintStatus
|
status: SprintStatus
|
||||||
projectId: string
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,7 +132,6 @@ const defaultSprints: Sprint[] = [
|
|||||||
startDate: sprint1Start.toISOString(),
|
startDate: sprint1Start.toISOString(),
|
||||||
endDate: sprint1End.toISOString(),
|
endDate: sprint1End.toISOString(),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
projectId: '2',
|
|
||||||
createdAt: new Date().toISOString(),
|
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,
|
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[] =>
|
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
|
||||||
comments
|
comments
|
||||||
.filter((comment) => comment.id !== targetId)
|
.filter((comment) => comment.id !== targetId)
|
||||||
@ -561,7 +596,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
set({
|
set({
|
||||||
projects: data.projects || [],
|
projects: data.projects || [],
|
||||||
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
|
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
|
||||||
sprints: data.sprints || [],
|
sprints: normalizeSprints(data.sprints),
|
||||||
lastSynced: Date.now(),
|
lastSynced: Date.now(),
|
||||||
})
|
})
|
||||||
console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length)
|
console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length)
|
||||||
@ -616,18 +651,16 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const newProjects = state.projects.filter((p) => p.id !== id)
|
const newProjects = state.projects.filter((p) => p.id !== id)
|
||||||
const newTasks = state.tasks.filter((t) => t.projectId !== id)
|
const newTasks = state.tasks.filter((t) => t.projectId !== id)
|
||||||
const newSprints = state.sprints.filter((s) => s.projectId !== id)
|
syncToServer(newProjects, newTasks, state.sprints)
|
||||||
syncToServer(newProjects, newTasks, newSprints)
|
|
||||||
return {
|
return {
|
||||||
projects: newProjects,
|
projects: newProjects,
|
||||||
tasks: newTasks,
|
tasks: newTasks,
|
||||||
sprints: newSprints,
|
|
||||||
selectedProjectId: state.selectedProjectId === id ? null : state.selectedProjectId,
|
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) => {
|
addTask: (task) => {
|
||||||
const actor = profileToCommentAuthor(get().currentUser)
|
const actor = profileToCommentAuthor(get().currentUser)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user