gantt-board/src/stores/useTaskStore.ts
Max 8aaca14e3a updated comments
Signed-off-by: Max <ai-agent@topdoglabs.com>
2026-02-23 16:44:17 -06:00

725 lines
25 KiB
TypeScript

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<void>
setCurrentUser: (user: Partial<UserProfile>) => void
// Project actions
addProject: (name: string, description?: string) => void
updateProject: (id: string, updates: Partial<Project>) => void
deleteProject: (id: string) => void
selectProject: (id: string | null) => void
// Task actions
addTask: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'>) => void
updateTask: (id: string, updates: Partial<Task>) => Promise<boolean>
deleteTask: (id: string) => void
selectTask: (id: string | null) => void
// Sprint actions
addSprint: (sprint: Omit<Sprint, 'id' | 'createdAt'>) => void
updateSprint: (id: string, updates: Partial<Sprint>) => 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<UserProfile>
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<Comment>
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<TaskAttachment>
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<unknown> {
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<TaskStore>()(
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<string, unknown> = { 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,
})
}
}
},
}
)
)