Implement threaded task comments with nested replies
This commit is contained in:
parent
6361ba1bb3
commit
5ba0edd856
@ -587,7 +587,7 @@ export default function Home() {
|
|||||||
OpenClaw Task Hub
|
OpenClaw Task Hub
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
||||||
Track ideas, tasks, bugs, and plans — with threaded notes
|
Track ideas, tasks, bugs, and plans — with threaded comments
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -1155,13 +1155,13 @@ export default function Home() {
|
|||||||
<div className="border-t border-slate-800 pt-6">
|
<div className="border-t border-slate-800 pt-6">
|
||||||
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
Notes & Comments ({editedTask.comments?.length || 0})
|
Comments ({editedTask.comments?.length || 0})
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Comment List */}
|
{/* Comment List */}
|
||||||
<div className="space-y-4 mb-4">
|
<div className="space-y-4 mb-4">
|
||||||
{!editedTask.comments || editedTask.comments.length === 0 ? (
|
{!editedTask.comments || editedTask.comments.length === 0 ? (
|
||||||
<p className="text-slate-500 text-sm">No notes yet. Add the first one!</p>
|
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
|
||||||
) : (
|
) : (
|
||||||
editedTask.comments.map((comment) => (
|
editedTask.comments.map((comment) => (
|
||||||
<div
|
<div
|
||||||
@ -1208,7 +1208,7 @@ export default function Home() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
placeholder="Add a note or comment..."
|
placeholder="Add a comment..."
|
||||||
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
||||||
rows={2}
|
rows={2}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@ -7,7 +7,15 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useTaskStore, type Priority, type Task, type TaskAttachment, type TaskStatus, type TaskType } from "@/stores/useTaskStore"
|
import {
|
||||||
|
useTaskStore,
|
||||||
|
type Comment as TaskComment,
|
||||||
|
type Priority,
|
||||||
|
type Task,
|
||||||
|
type TaskAttachment,
|
||||||
|
type TaskStatus,
|
||||||
|
type TaskType,
|
||||||
|
} from "@/stores/useTaskStore"
|
||||||
|
|
||||||
const typeColors: Record<TaskType, string> = {
|
const typeColors: Record<TaskType, string> = {
|
||||||
idea: "bg-purple-500",
|
idea: "bg-purple-500",
|
||||||
@ -53,6 +61,60 @@ const getAttachments = (taskLike: { attachments?: unknown }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getComments = (value: unknown): TaskComment[] => {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") return null
|
||||||
|
const comment = entry as Partial<TaskComment>
|
||||||
|
if (typeof comment.id !== "string" || typeof comment.text !== "string") return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: comment.id,
|
||||||
|
text: comment.text,
|
||||||
|
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
|
||||||
|
author: comment.author === "assistant" ? "assistant" : "user",
|
||||||
|
replies: getComments(comment.replies),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((comment): comment is TaskComment => comment !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildComment = (text: string, author: "user" | "assistant" = "user"): TaskComment => ({
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
text,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
author,
|
||||||
|
replies: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const addReplyToThread = (comments: TaskComment[], parentId: string, reply: TaskComment): TaskComment[] =>
|
||||||
|
comments.map((comment) => {
|
||||||
|
if (comment.id === parentId) {
|
||||||
|
return {
|
||||||
|
...comment,
|
||||||
|
replies: [...getComments(comment.replies), reply],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comment,
|
||||||
|
replies: addReplyToThread(getComments(comment.replies), parentId, reply),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeCommentFromThread = (comments: TaskComment[], targetId: string): TaskComment[] =>
|
||||||
|
comments
|
||||||
|
.filter((comment) => comment.id !== targetId)
|
||||||
|
.map((comment) => ({
|
||||||
|
...comment,
|
||||||
|
replies: removeCommentFromThread(getComments(comment.replies), targetId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const countThreadComments = (comments: TaskComment[]): number =>
|
||||||
|
comments.reduce((total, comment) => total + 1 + countThreadComments(getComments(comment.replies)), 0)
|
||||||
|
|
||||||
const toLabel = (raw: string) => raw.trim().replace(/^#/, "")
|
const toLabel = (raw: string) => raw.trim().replace(/^#/, "")
|
||||||
|
|
||||||
const addUniqueLabel = (existing: string[], raw: string) => {
|
const addUniqueLabel = (existing: string[], raw: string) => {
|
||||||
@ -101,6 +163,8 @@ export default function TaskDetailPage() {
|
|||||||
const [newComment, setNewComment] = useState("")
|
const [newComment, setNewComment] = useState("")
|
||||||
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
||||||
|
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncFromServer()
|
syncFromServer()
|
||||||
@ -111,6 +175,7 @@ export default function TaskDetailPage() {
|
|||||||
setEditedTask({
|
setEditedTask({
|
||||||
...selectedTask,
|
...selectedTask,
|
||||||
tags: getTags(selectedTask),
|
tags: getTags(selectedTask),
|
||||||
|
comments: getComments(selectedTask.comments),
|
||||||
attachments: getAttachments(selectedTask),
|
attachments: getAttachments(selectedTask),
|
||||||
})
|
})
|
||||||
setEditedTaskLabelInput("")
|
setEditedTaskLabelInput("")
|
||||||
@ -119,6 +184,7 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
||||||
const editedTaskAttachments = editedTask ? getAttachments(editedTask) : []
|
const editedTaskAttachments = editedTask ? getAttachments(editedTask) : []
|
||||||
|
const commentCount = editedTask ? countThreadComments(getComments(editedTask.comments)) : 0
|
||||||
|
|
||||||
const allLabels = useMemo(() => {
|
const allLabels = useMemo(() => {
|
||||||
const labels = new Map<string, number>()
|
const labels = new Map<string, number>()
|
||||||
@ -166,23 +232,41 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
setEditedTask({
|
setEditedTask({
|
||||||
...editedTask,
|
...editedTask,
|
||||||
comments: [
|
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), "user")],
|
||||||
...(editedTask.comments || []),
|
|
||||||
{
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
text: newComment.trim(),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
author: "user",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddReply = (parentId: string) => {
|
||||||
|
if (!editedTask) return
|
||||||
|
const text = replyDrafts[parentId]?.trim()
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
setEditedTask({
|
||||||
|
...editedTask,
|
||||||
|
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, "user")),
|
||||||
|
})
|
||||||
|
|
||||||
|
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
|
||||||
|
setOpenReplyEditors((prev) => ({ ...prev, [parentId]: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteComment = (commentId: string) => {
|
||||||
|
if (!editedTask) return
|
||||||
|
|
||||||
|
setEditedTask({
|
||||||
|
...editedTask,
|
||||||
|
comments: removeCommentFromThread(getComments(editedTask.comments), commentId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!editedTask) return
|
if (!editedTask) return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
updateTask(editedTask.id, editedTask)
|
updateTask(editedTask.id, {
|
||||||
|
...editedTask,
|
||||||
|
comments: getComments(editedTask.comments),
|
||||||
|
})
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,6 +277,70 @@ export default function TaskDetailPage() {
|
|||||||
router.push("/")
|
router.push("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] =>
|
||||||
|
comments.map((comment) => {
|
||||||
|
const replies = getComments(comment.replies)
|
||||||
|
const isReplying = !!openReplyEditors[comment.id]
|
||||||
|
const replyDraft = replyDrafts[comment.id] || ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
|
||||||
|
<div className={`p-3 rounded-lg border ${comment.author === "assistant" ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium ${comment.author === "assistant" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}>
|
||||||
|
{comment.author === "assistant" ? "AI" : "You"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-slate-300 font-medium">{comment.author === "assistant" ? "Assistant" : "You"}</span>
|
||||||
|
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenReplyEditors((prev) => ({ ...prev, [comment.id]: !prev[comment.id] }))}
|
||||||
|
className="text-xs text-blue-300 hover:text-blue-200"
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteComment(comment.id)}
|
||||||
|
className="text-slate-600 hover:text-red-400"
|
||||||
|
title="Delete comment"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-200 whitespace-pre-wrap">{comment.text}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReplying && (
|
||||||
|
<div className="flex gap-2" style={{ marginLeft: 20 }}>
|
||||||
|
<Textarea
|
||||||
|
value={replyDraft}
|
||||||
|
onChange={(event) => setReplyDrafts((prev) => ({ ...prev, [comment.id]: event.target.value }))}
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
||||||
|
rows={2}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && event.metaKey) {
|
||||||
|
handleAddReply(comment.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 justify-end">
|
||||||
|
<Button onClick={() => handleAddReply(comment.id)} size="sm">Reply</Button>
|
||||||
|
<Button onClick={() => setOpenReplyEditors((prev) => ({ ...prev, [comment.id]: false }))} variant="ghost" size="sm">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{replies.length > 0 && <div className="space-y-2">{renderThread(replies, depth + 1)}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
|
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
|
||||||
@ -451,55 +599,14 @@ export default function TaskDetailPage() {
|
|||||||
<div className="border-t border-slate-800 pt-6">
|
<div className="border-t border-slate-800 pt-6">
|
||||||
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
Notes & Comments ({editedTask.comments?.length || 0})
|
Comments ({commentCount})
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="space-y-4 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{!editedTask.comments || editedTask.comments.length === 0 ? (
|
{commentCount === 0 ? (
|
||||||
<p className="text-slate-500 text-sm">No notes yet. Add the first one!</p>
|
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
|
||||||
) : (
|
) : (
|
||||||
editedTask.comments.map((comment) => (
|
renderThread(getComments(editedTask.comments))
|
||||||
<div
|
|
||||||
key={comment.id}
|
|
||||||
className={`flex gap-3 p-3 rounded-lg ${
|
|
||||||
comment.author === "assistant" ? "bg-blue-900/20" : "bg-slate-800/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
|
||||||
comment.author === "assistant"
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: "bg-slate-700 text-slate-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{comment.author === "assistant" ? "AI" : "You"}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-sm font-medium text-slate-300">
|
|
||||||
{comment.author === "assistant" ? "Assistant" : "You"}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{new Date(comment.createdAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setEditedTask({
|
|
||||||
...editedTask,
|
|
||||||
comments: editedTask.comments.filter((entry) => entry.id !== comment.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="text-slate-600 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -507,7 +614,7 @@ export default function TaskDetailPage() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(event) => setNewComment(event.target.value)}
|
onChange={(event) => setNewComment(event.target.value)}
|
||||||
placeholder="Add a note or comment..."
|
placeholder="Add a comment..."
|
||||||
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
||||||
rows={2}
|
rows={2}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
|||||||
@ -11,6 +11,14 @@ export interface TaskAttachment {
|
|||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskComment {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: "user" | "assistant";
|
||||||
|
replies?: TaskComment[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -23,7 +31,7 @@ export interface Task {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
comments: { id: string; text: string; createdAt: string; author: "user" | "assistant" }[];
|
comments: TaskComment[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
attachments: TaskAttachment[];
|
attachments: TaskAttachment[];
|
||||||
}
|
}
|
||||||
@ -105,6 +113,26 @@ function normalizeAttachments(attachments: unknown): TaskAttachment[] {
|
|||||||
.filter((attachment): attachment is TaskAttachment => attachment !== null);
|
.filter((attachment): attachment is TaskAttachment => attachment !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeComments(comments: unknown): TaskComment[] {
|
||||||
|
if (!Array.isArray(comments)) return [];
|
||||||
|
|
||||||
|
return comments
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") return null;
|
||||||
|
const value = entry as Partial<TaskComment>;
|
||||||
|
if (typeof value.id !== "string" || typeof value.text !== "string") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
text: value.text,
|
||||||
|
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
|
||||||
|
author: value.author === "assistant" ? "assistant" : "user",
|
||||||
|
replies: normalizeComments(value.replies),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((comment): comment is TaskComment => comment !== null);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTask(task: Partial<Task>): Task {
|
function normalizeTask(task: Partial<Task>): Task {
|
||||||
return {
|
return {
|
||||||
id: String(task.id ?? Date.now()),
|
id: String(task.id ?? Date.now()),
|
||||||
@ -118,7 +146,7 @@ function normalizeTask(task: Partial<Task>): Task {
|
|||||||
createdAt: task.createdAt || new Date().toISOString(),
|
createdAt: task.createdAt || new Date().toISOString(),
|
||||||
updatedAt: task.updatedAt || new Date().toISOString(),
|
updatedAt: task.updatedAt || new Date().toISOString(),
|
||||||
dueDate: task.dueDate || undefined,
|
dueDate: task.dueDate || undefined,
|
||||||
comments: Array.isArray(task.comments) ? task.comments : [],
|
comments: normalizeComments(task.comments),
|
||||||
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||||
attachments: normalizeAttachments(task.attachments),
|
attachments: normalizeAttachments(task.attachments),
|
||||||
};
|
};
|
||||||
@ -343,7 +371,7 @@ export function getData(): DataStore {
|
|||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
updatedAt: task.updatedAt,
|
updatedAt: task.updatedAt,
|
||||||
dueDate: task.dueDate ?? undefined,
|
dueDate: task.dueDate ?? undefined,
|
||||||
comments: safeParseArray(task.comments, []),
|
comments: normalizeComments(safeParseArray(task.comments, [])),
|
||||||
tags: safeParseArray(task.tags, []),
|
tags: safeParseArray(task.tags, []),
|
||||||
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
|
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export interface Comment {
|
|||||||
text: string
|
text: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
author: 'user' | 'assistant'
|
author: 'user' | 'assistant'
|
||||||
|
replies?: Comment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskAttachment {
|
export interface TaskAttachment {
|
||||||
@ -385,6 +386,65 @@ const defaultTasks: Task[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const normalizeComments = (value: unknown): Comment[] => {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') return null
|
||||||
|
const candidate = entry as Partial<Comment>
|
||||||
|
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') return null
|
||||||
|
|
||||||
|
const author = candidate.author === 'assistant' ? 'assistant' : 'user'
|
||||||
|
return {
|
||||||
|
id: candidate.id,
|
||||||
|
text: candidate.text,
|
||||||
|
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
|
||||||
|
author,
|
||||||
|
replies: normalizeComments(candidate.replies),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((comment): comment is Comment => comment !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
|
||||||
|
comments
|
||||||
|
.filter((comment) => comment.id !== targetId)
|
||||||
|
.map((comment) => ({
|
||||||
|
...comment,
|
||||||
|
replies: removeCommentFromThread(normalizeComments(comment.replies), targetId),
|
||||||
|
}))
|
||||||
|
|
||||||
// Helper to sync to server
|
// Helper to sync to server
|
||||||
async function syncToServer(projects: Project[], tasks: Task[], sprints: Sprint[]) {
|
async function syncToServer(projects: Project[], tasks: Task[], sprints: Sprint[]) {
|
||||||
console.log('>>> syncToServer: saving', tasks.length, 'tasks,', projects.length, 'projects,', sprints.length, 'sprints')
|
console.log('>>> syncToServer: saving', tasks.length, 'tasks,', projects.length, 'projects,', sprints.length, 'sprints')
|
||||||
@ -440,7 +500,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
// ALWAYS use server data if API returns successfully
|
// ALWAYS use server data if API returns successfully
|
||||||
set({
|
set({
|
||||||
projects: data.projects || [],
|
projects: data.projects || [],
|
||||||
tasks: data.tasks || [],
|
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
|
||||||
sprints: data.sprints || [],
|
sprints: data.sprints || [],
|
||||||
lastSynced: Date.now(),
|
lastSynced: Date.now(),
|
||||||
})
|
})
|
||||||
@ -511,8 +571,8 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
comments: [],
|
comments: normalizeComments([]),
|
||||||
attachments: task.attachments || [],
|
attachments: normalizeAttachments(task.attachments),
|
||||||
}
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTasks = [...state.tasks, newTask]
|
const newTasks = [...state.tasks, newTask]
|
||||||
@ -525,7 +585,15 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
console.log('updateTask called:', id, updates)
|
console.log('updateTask called:', id, updates)
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTasks = state.tasks.map((t) =>
|
const newTasks = state.tasks.map((t) =>
|
||||||
t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : 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(),
|
||||||
|
} as Task)
|
||||||
|
: t
|
||||||
)
|
)
|
||||||
const updatedTask = newTasks.find(t => t.id === id)
|
const updatedTask = newTasks.find(t => t.id === id)
|
||||||
console.log('updateTask: updated task:', updatedTask)
|
console.log('updateTask: updated task:', updatedTask)
|
||||||
@ -598,11 +666,12 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
text,
|
text,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
author,
|
author,
|
||||||
|
replies: [],
|
||||||
}
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTasks = state.tasks.map((t) =>
|
const newTasks = state.tasks.map((t) =>
|
||||||
t.id === taskId
|
t.id === taskId
|
||||||
? { ...t, comments: [...t.comments, newComment], updatedAt: new Date().toISOString() }
|
? { ...t, comments: [...normalizeComments(t.comments), newComment], updatedAt: new Date().toISOString() }
|
||||||
: t
|
: t
|
||||||
)
|
)
|
||||||
syncToServer(state.projects, newTasks, state.sprints)
|
syncToServer(state.projects, newTasks, state.sprints)
|
||||||
@ -614,7 +683,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const newTasks = state.tasks.map((t) =>
|
const newTasks = state.tasks.map((t) =>
|
||||||
t.id === taskId
|
t.id === taskId
|
||||||
? { ...t, comments: t.comments.filter((c) => c.id !== commentId) }
|
? { ...t, comments: removeCommentFromThread(normalizeComments(t.comments), commentId), updatedAt: new Date().toISOString() }
|
||||||
: t
|
: t
|
||||||
)
|
)
|
||||||
syncToServer(state.projects, newTasks, state.sprints)
|
syncToServer(state.projects, newTasks, state.sprints)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user