import { create } from 'zustand' import { persist } from 'zustand/middleware' export type TaskType = 'idea' | 'task' | 'bug' | 'research' | 'plan' export type TaskStatus = 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done' export type Priority = 'low' | 'medium' | 'high' | 'urgent' export type SprintStatus = 'planning' | 'active' | 'completed' const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i export interface Sprint { id: string name: string goal?: string startDate: string endDate: string status: SprintStatus projectId: string createdAt: string } export interface Comment { id: string text: string createdAt: string commentAuthorId: string replies?: Comment[] } export interface UserProfile { id: string name: string email?: string avatarUrl?: string } export interface TaskAttachment { id: string name: string type: string size: number dataUrl: string uploadedAt: string } export interface Task { id: string title: string description?: string type: TaskType status: TaskStatus priority: Priority projectId: string sprintId?: string createdAt: string updatedAt: string createdById?: string createdByName?: string createdByAvatarUrl?: string updatedById?: string updatedByName?: string updatedByAvatarUrl?: string assigneeId?: string assigneeName?: string assigneeEmail?: string assigneeAvatarUrl?: string dueDate?: string comments: Comment[] tags: string[] attachments?: TaskAttachment[] } export interface Project { id: string name: string description?: string color: string createdAt: string } interface TaskStore { projects: Project[] tasks: Task[] sprints: Sprint[] selectedProjectId: string | null selectedTaskId: string | null selectedSprintId: string | null currentUser: UserProfile isLoading: boolean lastSynced: number | null syncError: string | null // Sync actions syncFromServer: () => Promise setCurrentUser: (user: Partial) => void // Project actions addProject: (name: string, description?: string) => void updateProject: (id: string, updates: Partial) => void deleteProject: (id: string) => void selectProject: (id: string | null) => void // Task actions addTask: (task: Omit) => void updateTask: (id: string, updates: Partial) => Promise deleteTask: (id: string) => void selectTask: (id: string | null) => void // Sprint actions addSprint: (sprint: Omit) => void updateSprint: (id: string, updates: Partial) => void deleteSprint: (id: string) => void selectSprint: (id: string | null) => void getTasksBySprint: (sprintId: string) => Task[] // Comment actions addComment: (taskId: string, text: string, commentAuthorId: string) => void deleteComment: (taskId: string, commentId: string) => void // Filters getTasksByProject: (projectId: string) => Task[] getTasksByStatus: (status: TaskStatus) => Task[] getTaskById: (id: string) => Task | undefined } const defaultCurrentUser: UserProfile = { id: '', name: 'Unknown User', } const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => { if (!value || typeof value !== 'object') return fallback const candidate = value as Partial const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 ? candidate.id : fallback.id const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0 ? candidate.name.trim() : fallback.name const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined return { id, name, email, avatarUrl } } const profileToCommentAuthor = (profile: UserProfile) => ({ id: profile.id, name: profile.name, avatarUrl: profile.avatarUrl, }) const normalizeCommentAuthorId = (value: unknown): string | null => typeof value === 'string' && value.trim().length > 0 ? value.trim() : null const normalizeComments = (value: unknown): Comment[] => { if (!Array.isArray(value)) return [] const comments: Comment[] = [] for (const entry of value) { if (!entry || typeof entry !== 'object') continue const candidate = entry as Partial if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string' || typeof candidate.createdAt !== 'string') continue const commentAuthorId = normalizeCommentAuthorId(candidate.commentAuthorId) if (!commentAuthorId) continue comments.push({ id: candidate.id, text: candidate.text, createdAt: candidate.createdAt, commentAuthorId, replies: normalizeComments(candidate.replies), }) } return comments } const normalizeAttachments = (value: unknown): TaskAttachment[] => { if (!Array.isArray(value)) return [] return value .map((entry) => { if (!entry || typeof entry !== 'object') return null const candidate = entry as Partial if (typeof candidate.id !== 'string' || typeof candidate.name !== 'string' || typeof candidate.dataUrl !== 'string') { return null } return { id: candidate.id, name: candidate.name, type: typeof candidate.type === 'string' ? candidate.type : 'application/octet-stream', size: typeof candidate.size === 'number' ? candidate.size : 0, dataUrl: candidate.dataUrl, uploadedAt: typeof candidate.uploadedAt === 'string' ? candidate.uploadedAt : new Date().toISOString(), } }) .filter((attachment): attachment is TaskAttachment => attachment !== null) } const normalizeTask = (task: Task): Task => ({ ...task, comments: normalizeComments(task.comments), tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [], attachments: normalizeAttachments(task.attachments), createdById: typeof task.createdById === 'string' && task.createdById.trim().length > 0 ? task.createdById : undefined, createdByName: typeof task.createdByName === 'string' && task.createdByName.trim().length > 0 ? task.createdByName : undefined, createdByAvatarUrl: typeof task.createdByAvatarUrl === 'string' && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : undefined, updatedById: typeof task.updatedById === 'string' && task.updatedById.trim().length > 0 ? task.updatedById : undefined, updatedByName: typeof task.updatedByName === 'string' && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined, updatedByAvatarUrl: typeof task.updatedByAvatarUrl === 'string' && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined, assigneeId: typeof task.assigneeId === 'string' && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined, assigneeName: typeof task.assigneeName === 'string' && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined, assigneeEmail: typeof task.assigneeEmail === 'string' && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined, assigneeAvatarUrl: typeof task.assigneeAvatarUrl === 'string' && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined, }) const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] => comments .filter((comment) => comment.id !== targetId) .map((comment) => ({ ...comment, replies: removeCommentFromThread(normalizeComments(comment.replies), targetId), })) const generateTaskId = (): string => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID() } throw new Error('Unable to generate task UUID in this browser') } const getErrorMessage = (payload: unknown, fallback: string): string => { if (!payload || typeof payload !== 'object') return fallback const candidate = payload as { error?: unknown } return typeof candidate.error === 'string' && candidate.error.trim().length > 0 ? candidate.error : fallback } async function requestApi(path: string, init: RequestInit): Promise { const response = await fetch(path, init) if (response.ok) { return response.json().catch(() => ({})) } const payload = await response.json().catch(() => null) const message = getErrorMessage(payload, `${path} failed with status ${response.status}`) throw new Error(message) } // Helper to sync a single task to server (lightweight) async function syncTaskToServer(task: Task) { console.log('>>> syncTaskToServer: saving task', task.id, task.title) const isValidUuid = (value: string | undefined) => typeof value === 'string' && UUID_PATTERN.test(value) if (!isValidUuid(task.id)) { console.error('>>> syncTaskToServer: invalid task.id (expected UUID)', task.id) return false } if (!isValidUuid(task.projectId)) { console.error('>>> syncTaskToServer: invalid task.projectId (expected UUID)', task.projectId) return false } if (task.sprintId && !isValidUuid(task.sprintId)) { console.error('>>> syncTaskToServer: invalid task.sprintId (expected UUID)', task.sprintId) return false } if (task.assigneeId && !isValidUuid(task.assigneeId)) { console.error('>>> syncTaskToServer: invalid task.assigneeId (expected UUID)', task.assigneeId) return false } try { const res = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task }), }) if (res.ok) { console.log('>>> syncTaskToServer: saved successfully') return true } else { const rawBody = await res.text().catch(() => '') let errorPayload: unknown = null if (rawBody) { try { errorPayload = JSON.parse(rawBody) } catch { errorPayload = { rawBody } } } console.error('>>> syncTaskToServer: failed with status', res.status, errorPayload) return false } } catch (error) { console.error('>>> syncTaskToServer: Failed to sync:', error) return false } } // Helper to delete a task from server async function deleteTaskFromServer(taskId: string) { console.log('>>> deleteTaskFromServer: deleting task', taskId) try { const res = await fetch('/api/tasks', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId }), }) if (res.ok) { console.log('>>> deleteTaskFromServer: deleted successfully') return true } else { console.error('>>> deleteTaskFromServer: failed with status', res.status) return false } } catch (error) { console.error('>>> deleteTaskFromServer: Failed to delete:', error) return false } } export const useTaskStore = create()( persist( (set, get) => ({ projects: [], tasks: [], sprints: [], selectedProjectId: null, selectedTaskId: null, selectedSprintId: null, currentUser: defaultCurrentUser, isLoading: false, lastSynced: null, syncError: null, syncFromServer: async () => { console.log('>>> syncFromServer START') set({ isLoading: true, syncError: null }) try { const res = await fetch('/api/tasks', { cache: 'no-store' }) console.log('>>> syncFromServer: API response status:', res.status) if (!res.ok) { const errorPayload = await res.json().catch(() => ({})) console.error('>>> syncFromServer: API error payload:', errorPayload) const message = typeof errorPayload?.error === 'string' ? errorPayload.error : `syncFromServer failed with status ${res.status}` throw new Error(message) } const data = await res.json() const serverProjects: Project[] = data.projects || [] const serverCurrentUser = normalizeUserProfile(data.currentUser, get().currentUser) const currentSelectedProjectId = get().selectedProjectId const nextSelectedProjectId = currentSelectedProjectId && serverProjects.some((project) => project.id === currentSelectedProjectId) ? currentSelectedProjectId : (serverProjects[0]?.id ?? null) console.log('>>> syncFromServer: fetched data:', { projectsCount: serverProjects.length, tasksCount: data.tasks?.length, sprintsCount: data.sprints?.length, firstTaskTitle: data.tasks?.[0]?.title, lastUpdated: data.lastUpdated, }) console.log('>>> syncFromServer: current store tasks count BEFORE set:', get().tasks.length) set({ projects: serverProjects, tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)), sprints: data.sprints || [], currentUser: serverCurrentUser, selectedProjectId: nextSelectedProjectId, syncError: null, lastSynced: Date.now(), }) console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length) } catch (error) { console.error('>>> syncFromServer: Failed to sync from server:', error) const message = error instanceof Error ? error.message : 'Unknown sync error' set({ projects: [], tasks: [], sprints: [], selectedProjectId: null, selectedTaskId: null, selectedSprintId: null, currentUser: defaultCurrentUser, syncError: message, }) } finally { set({ isLoading: false }) console.log('>>> syncFromServer END') } }, setCurrentUser: (user) => { set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) })) }, addProject: (name, description) => { const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] const color = colors[Math.floor(Math.random() * colors.length)] void (async () => { try { await requestApi('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description, color }), }) await get().syncFromServer() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create project' console.error('>>> addProject failed:', error) set({ syncError: message }) } })() }, updateProject: (id, updates) => { const payload: Record = { id } if (updates.name !== undefined) payload.name = updates.name if (updates.description !== undefined) payload.description = updates.description if (updates.color !== undefined) payload.color = updates.color void (async () => { try { await requestApi('/api/projects', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) await get().syncFromServer() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to update project' console.error('>>> updateProject failed:', error) set({ syncError: message }) } })() }, deleteProject: (id) => { void (async () => { try { await requestApi('/api/projects', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) await get().syncFromServer() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to delete project' console.error('>>> deleteProject failed:', error) set({ syncError: message }) } })() }, selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }), addTask: (task) => { const actor = profileToCommentAuthor(get().currentUser) const newTask: Task = { ...task, id: generateTaskId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), createdById: actor.id, createdByName: actor.name, createdByAvatarUrl: actor.avatarUrl, updatedById: actor.id, updatedByName: actor.name, updatedByAvatarUrl: actor.avatarUrl, assigneeId: task.assigneeId, assigneeName: task.assigneeName, assigneeEmail: task.assigneeEmail, assigneeAvatarUrl: task.assigneeAvatarUrl, comments: normalizeComments([]), attachments: normalizeAttachments(task.attachments), } set((state) => { const newTasks = [...state.tasks, newTask] void (async () => { const success = await syncTaskToServer(newTask) if (!success) { await get().syncFromServer() } })() return { tasks: newTasks } }) }, updateTask: async (id, updates) => { console.log('updateTask called:', id, updates) let syncSuccess = false let updatedTask: Task | undefined set((state) => { const actor = profileToCommentAuthor(state.currentUser) const newTasks = state.tasks.map((t) => t.id === id ? normalizeTask({ ...t, ...updates, comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments, attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments, updatedAt: new Date().toISOString(), updatedById: actor.id, updatedByName: actor.name, updatedByAvatarUrl: actor.avatarUrl, } as Task) : t ) updatedTask = newTasks.find(t => t.id === id) console.log('updateTask: updated task:', updatedTask) return { tasks: newTasks } }) // Sync individual task to server (lightweight) if (updatedTask) { syncSuccess = await syncTaskToServer(updatedTask) if (!syncSuccess) { await get().syncFromServer() } } return syncSuccess }, deleteTask: (id) => { set((state) => { const newTasks = state.tasks.filter((t) => t.id !== id) void (async () => { const success = await deleteTaskFromServer(id) if (!success) { await get().syncFromServer() } })() return { tasks: newTasks, selectedTaskId: state.selectedTaskId === id ? null : state.selectedTaskId, } }) }, selectTask: (id) => set({ selectedTaskId: id }), // Sprint actions addSprint: (sprint) => { void (async () => { try { await requestApi('/api/sprints', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sprint), }) await get().syncFromServer() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create sprint' console.error('>>> addSprint failed:', error) set({ syncError: message }) } })() }, updateSprint: (id, updates) => { void (async () => { try { await requestApi('/api/sprints', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, ...updates }), }) await get().syncFromServer() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to update sprint' console.error('>>> updateSprint failed:', error) set({ syncError: message }) } })() }, deleteSprint: (id) => { void (async () => { try { await requestApi('/api/sprints', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) await get().syncFromServer() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to delete sprint' console.error('>>> deleteSprint failed:', error) set({ syncError: message }) } })() }, selectSprint: (id) => set({ selectedSprintId: id }), getTasksBySprint: (sprintId) => { return get().tasks.filter((t) => t.sprintId === sprintId) }, addComment: (taskId, text, commentAuthorId) => { const normalizedCommentAuthorId = normalizeCommentAuthorId(commentAuthorId) if (!normalizedCommentAuthorId) { const message = 'Failed to add comment: invalid comment author id' console.error(message, { taskId, commentAuthorId }) set({ syncError: message }) return } const newComment: Comment = { id: Date.now().toString(), text, createdAt: new Date().toISOString(), commentAuthorId: normalizedCommentAuthorId, replies: [], } set((state) => { const updater = profileToCommentAuthor(state.currentUser) const newTasks = state.tasks.map((t) => t.id === taskId ? { ...t, comments: [...normalizeComments(t.comments), newComment], updatedAt: new Date().toISOString(), updatedById: updater.id, updatedByName: updater.name, updatedByAvatarUrl: updater.avatarUrl, } : t ) const updatedTask = newTasks.find(t => t.id === taskId) if (updatedTask) { void (async () => { const success = await syncTaskToServer(updatedTask) if (!success) { await get().syncFromServer() } })() } return { tasks: newTasks } }) }, deleteComment: (taskId, commentId) => { set((state) => { const updater = profileToCommentAuthor(state.currentUser) const newTasks = state.tasks.map((t) => t.id === taskId ? { ...t, comments: removeCommentFromThread(normalizeComments(t.comments), commentId), updatedAt: new Date().toISOString(), updatedById: updater.id, updatedByName: updater.name, updatedByAvatarUrl: updater.avatarUrl, } : t ) const updatedTask = newTasks.find(t => t.id === taskId) if (updatedTask) { void (async () => { const success = await syncTaskToServer(updatedTask) if (!success) { await get().syncFromServer() } })() } return { tasks: newTasks } }) }, getTasksByProject: (projectId) => { return get().tasks.filter((t) => t.projectId === projectId) }, getTasksByStatus: (status) => { return get().tasks.filter((t) => t.status === status) }, getTaskById: (id) => { return get().tasks.find((t) => t.id === id) }, }), { name: 'task-store', version: 1, migrate: (persistedState) => { if (!persistedState || typeof persistedState !== 'object') { return {} } const state = persistedState as { selectedProjectId?: unknown selectedTaskId?: unknown selectedSprintId?: unknown } return { selectedProjectId: typeof state.selectedProjectId === 'string' ? state.selectedProjectId : null, selectedTaskId: typeof state.selectedTaskId === 'string' ? state.selectedTaskId : null, selectedSprintId: typeof state.selectedSprintId === 'string' ? state.selectedSprintId : null, } }, partialize: (state) => ({ // Persist UI selection state only. All business data comes from Supabase. selectedProjectId: state.selectedProjectId, selectedTaskId: state.selectedTaskId, selectedSprintId: state.selectedSprintId, }), onRehydrateStorage: () => { console.log('>>> PERSIST: onRehydrateStorage called') return (state, error) => { if (error) { console.log('>>> PERSIST: Rehydration error:', error) } else { console.log('>>> PERSIST: Rehydrated state:', { selectedProjectId: state?.selectedProjectId, selectedTaskId: state?.selectedTaskId, selectedSprintId: state?.selectedSprintId, }) } } }, } ) )