725 lines
25 KiB
TypeScript
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,
|
|
})
|
|
}
|
|
}
|
|
},
|
|
}
|
|
)
|
|
)
|