Compare commits
2 Commits
8aaca14e3a
...
c1ae51b1ec
| Author | SHA1 | Date | |
|---|---|---|---|
| c1ae51b1ec | |||
| 5c6bd134bd |
15
README.md
15
README.md
@ -167,6 +167,21 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- Delete any comment or reply from the thread
|
- Delete any comment or reply from the thread
|
||||||
- Thread data is persisted in Supabase through the existing `/api/tasks` sync flow.
|
- Thread data is persisted in Supabase through the existing `/api/tasks` sync flow.
|
||||||
|
|
||||||
|
Current persisted comment shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"text": "string",
|
||||||
|
"createdAt": "2026-02-23T19:00:00.000Z",
|
||||||
|
"commentAuthorId": "user-uuid-or-assistant",
|
||||||
|
"replies": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `commentAuthorId` is required.
|
||||||
|
- Legacy `author` objects are not supported by the UI parser.
|
||||||
|
|
||||||
### Backlog drag and drop
|
### Backlog drag and drop
|
||||||
|
|
||||||
Backlog view supports moving tasks between:
|
Backlog view supports moving tasks between:
|
||||||
|
|||||||
@ -85,6 +85,20 @@ A unified CLI that covers all API operations.
|
|||||||
./scripts/gantt.sh task attach <task-uuid> ./research.pdf
|
./scripts/gantt.sh task attach <task-uuid> ./research.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Comment payload persisted by the API/CLI:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"text": "string",
|
||||||
|
"createdAt": "2026-02-23T19:00:00.000Z",
|
||||||
|
"commentAuthorId": "user-uuid-or-assistant",
|
||||||
|
"replies": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `commentAuthorId` is the only supported author field.
|
||||||
|
|
||||||
### Project Commands
|
### Project Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -63,8 +63,8 @@ const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
|||||||
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
const SPRINT_STATUSES: Sprint["status"][] = ["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;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
// Optimized field selection - fetch all fields needed for board and detail display
|
// Field sets are split so board loads can avoid heavy attachment payloads.
|
||||||
const TASK_FIELDS = [
|
const TASK_BASE_FIELDS = [
|
||||||
"id",
|
"id",
|
||||||
"title",
|
"title",
|
||||||
"type",
|
"type",
|
||||||
@ -80,10 +80,11 @@ const TASK_FIELDS = [
|
|||||||
"due_date",
|
"due_date",
|
||||||
"tags",
|
"tags",
|
||||||
"comments",
|
"comments",
|
||||||
"attachments",
|
|
||||||
"description",
|
"description",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TASK_DETAIL_ONLY_FIELDS = ["attachments"];
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
readonly details?: Record<string, unknown>;
|
readonly details?: Record<string, unknown>;
|
||||||
@ -271,7 +272,7 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
|
|||||||
|
|
||||||
// GET - fetch all tasks, projects, and sprints
|
// GET - fetch all tasks, projects, and sprints
|
||||||
// Uses lightweight fields for faster initial load
|
// Uses lightweight fields for faster initial load
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const user = await getAuthenticatedUser();
|
const user = await getAuthenticatedUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -279,6 +280,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
|
const includeFullTaskData = new URL(request.url).searchParams.get("include") === "detail";
|
||||||
|
const taskFieldSet = includeFullTaskData
|
||||||
|
? [...TASK_BASE_FIELDS, ...TASK_DETAIL_ONLY_FIELDS]
|
||||||
|
: TASK_BASE_FIELDS;
|
||||||
|
|
||||||
// Use Promise.all for parallel queries with optimized field selection
|
// Use Promise.all for parallel queries with optimized field selection
|
||||||
const [
|
const [
|
||||||
@ -289,7 +294,7 @@ export async function GET() {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
||||||
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
|
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
|
||||||
supabase.from("tasks").select(TASK_FIELDS.join(", ")).order("updated_at", { ascending: false }),
|
supabase.from("tasks").select(taskFieldSet.join(", ")).order("updated_at", { ascending: false }),
|
||||||
supabase.from("users").select("id, name, email, avatar_url"),
|
supabase.from("users").select("id, name, email, avatar_url"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -307,7 +312,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
|
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
|
||||||
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
|
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
|
||||||
tasks: ((taskRows as unknown as Record<string, unknown>[] | null) || []).map((row) => mapTaskRow(row, usersById, false)),
|
tasks: ((taskRows as unknown as Record<string, unknown>[] | null) || []).map((row) => mapTaskRow(row, usersById, includeFullTaskData)),
|
||||||
currentUser: {
|
currentUser: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|||||||
@ -468,6 +468,25 @@ export default function Home() {
|
|||||||
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}, [users, currentUser])
|
}, [users, currentUser])
|
||||||
|
|
||||||
|
const knownUsersById = useMemo(() => {
|
||||||
|
const byId = new Map<string, AssignableUser>()
|
||||||
|
|
||||||
|
const addUser = (id: string | undefined, name: string | undefined, avatarUrl?: string, email?: string) => {
|
||||||
|
if (!id || !name) return
|
||||||
|
if (id.trim().length === 0 || name.trim().length === 0) return
|
||||||
|
byId.set(id, { id, name, avatarUrl, email })
|
||||||
|
}
|
||||||
|
|
||||||
|
assignableUsers.forEach((user) => byId.set(user.id, user))
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
addUser(task.createdById, task.createdByName, task.createdByAvatarUrl)
|
||||||
|
addUser(task.updatedById, task.updatedByName, task.updatedByAvatarUrl)
|
||||||
|
addUser(task.assigneeId, task.assigneeName, task.assigneeAvatarUrl, task.assigneeEmail)
|
||||||
|
})
|
||||||
|
|
||||||
|
return byId
|
||||||
|
}, [assignableUsers, tasks])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
let isMounted = true
|
||||||
const loadSession = async () => {
|
const loadSession = async () => {
|
||||||
@ -756,7 +775,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const resolveAssignee = (assigneeId: string | undefined) => {
|
const resolveAssignee = (assigneeId: string | undefined) => {
|
||||||
if (!assigneeId) return null
|
if (!assigneeId) return null
|
||||||
return assignableUsers.find((user) => user.id === assigneeId) || null
|
return knownUsersById.get(assigneeId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const setNewTaskAssignee = (assigneeId: string) => {
|
const setNewTaskAssignee = (assigneeId: string) => {
|
||||||
|
|||||||
@ -256,7 +256,7 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authReady) return
|
if (!authReady) return
|
||||||
syncFromServer()
|
syncFromServer({ includeFullTaskData: true })
|
||||||
}, [authReady, syncFromServer])
|
}, [authReady, syncFromServer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -330,6 +330,25 @@ export default function TaskDetailPage() {
|
|||||||
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}, [users, currentUser])
|
}, [users, currentUser])
|
||||||
|
|
||||||
|
const knownUsersById = useMemo(() => {
|
||||||
|
const byId = new Map<string, AssignableUser>()
|
||||||
|
|
||||||
|
const addUser = (id: string | undefined, name: string | undefined, avatarUrl?: string, email?: string) => {
|
||||||
|
if (!id || !name) return
|
||||||
|
if (id.trim().length === 0 || name.trim().length === 0) return
|
||||||
|
byId.set(id, { id, name, avatarUrl, email })
|
||||||
|
}
|
||||||
|
|
||||||
|
assignableUsers.forEach((user) => byId.set(user.id, user))
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
addUser(task.createdById, task.createdByName, task.createdByAvatarUrl)
|
||||||
|
addUser(task.updatedById, task.updatedByName, task.updatedByAvatarUrl)
|
||||||
|
addUser(task.assigneeId, task.assigneeName, task.assigneeAvatarUrl, task.assigneeEmail)
|
||||||
|
})
|
||||||
|
|
||||||
|
return byId
|
||||||
|
}, [assignableUsers, tasks])
|
||||||
|
|
||||||
const sortedSprints = useMemo(
|
const sortedSprints = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sprints
|
sprints
|
||||||
@ -374,7 +393,7 @@ export default function TaskDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddComment = () => {
|
const handleAddComment = async () => {
|
||||||
if (!editedTask || !newComment.trim()) return
|
if (!editedTask || !newComment.trim()) return
|
||||||
|
|
||||||
const commentAuthorId = getCurrentUserCommentAuthorId(currentUser)
|
const commentAuthorId = getCurrentUserCommentAuthorId(currentUser)
|
||||||
@ -382,14 +401,28 @@ export default function TaskDetailPage() {
|
|||||||
toast.error("You must be signed in to add a comment.")
|
toast.error("You must be signed in to add a comment.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEditedTask({
|
|
||||||
|
const nextTask: Task = {
|
||||||
...editedTask,
|
...editedTask,
|
||||||
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), commentAuthorId)],
|
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), commentAuthorId)],
|
||||||
})
|
|
||||||
setNewComment("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddReply = (parentId: string) => {
|
setEditedTask(nextTask)
|
||||||
|
setNewComment("")
|
||||||
|
|
||||||
|
const success = await updateTask(nextTask.id, {
|
||||||
|
...nextTask,
|
||||||
|
comments: getComments(nextTask.comments),
|
||||||
|
})
|
||||||
|
if (!success) {
|
||||||
|
toast.error("Failed to save comment", {
|
||||||
|
description: "Comment was added locally but could not sync to the server.",
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddReply = async (parentId: string) => {
|
||||||
if (!editedTask) return
|
if (!editedTask) return
|
||||||
const text = replyDrafts[parentId]?.trim()
|
const text = replyDrafts[parentId]?.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
@ -399,13 +432,26 @@ export default function TaskDetailPage() {
|
|||||||
toast.error("You must be signed in to reply.")
|
toast.error("You must be signed in to reply.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEditedTask({
|
const nextTask: Task = {
|
||||||
...editedTask,
|
...editedTask,
|
||||||
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, commentAuthorId)),
|
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, commentAuthorId)),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
setEditedTask(nextTask)
|
||||||
|
|
||||||
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
|
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
|
||||||
setOpenReplyEditors((prev) => ({ ...prev, [parentId]: false }))
|
setOpenReplyEditors((prev) => ({ ...prev, [parentId]: false }))
|
||||||
|
|
||||||
|
const success = await updateTask(nextTask.id, {
|
||||||
|
...nextTask,
|
||||||
|
comments: getComments(nextTask.comments),
|
||||||
|
})
|
||||||
|
if (!success) {
|
||||||
|
toast.error("Failed to save reply", {
|
||||||
|
description: "Reply was added locally but could not sync to the server.",
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteComment = (commentId: string) => {
|
const handleDeleteComment = (commentId: string) => {
|
||||||
@ -419,7 +465,7 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
const resolveAssignee = (assigneeId: string | undefined) => {
|
const resolveAssignee = (assigneeId: string | undefined) => {
|
||||||
if (!assigneeId) return null
|
if (!assigneeId) return null
|
||||||
return assignableUsers.find((user) => user.id === assigneeId) || null
|
return knownUsersById.get(assigneeId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const setEditedTaskAssignee = (assigneeId: string) => {
|
const setEditedTaskAssignee = (assigneeId: string) => {
|
||||||
|
|||||||
@ -392,8 +392,6 @@ export async function revokeSession(token: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserBySessionToken(token: string): Promise<AuthUser | null> {
|
export async function getUserBySessionToken(token: string): Promise<AuthUser | null> {
|
||||||
await deleteExpiredSessions();
|
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const tokenHash = hashSessionToken(token);
|
const tokenHash = hashSessionToken(token);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|||||||
@ -90,7 +90,7 @@ interface TaskStore {
|
|||||||
syncError: string | null
|
syncError: string | null
|
||||||
|
|
||||||
// Sync actions
|
// Sync actions
|
||||||
syncFromServer: () => Promise<void>
|
syncFromServer: (options?: { includeFullTaskData?: boolean }) => Promise<void>
|
||||||
setCurrentUser: (user: Partial<UserProfile>) => void
|
setCurrentUser: (user: Partial<UserProfile>) => void
|
||||||
|
|
||||||
// Project actions
|
// Project actions
|
||||||
@ -326,11 +326,12 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
lastSynced: null,
|
lastSynced: null,
|
||||||
syncError: null,
|
syncError: null,
|
||||||
|
|
||||||
syncFromServer: async () => {
|
syncFromServer: async (options) => {
|
||||||
console.log('>>> syncFromServer START')
|
console.log('>>> syncFromServer START')
|
||||||
set({ isLoading: true, syncError: null })
|
set({ isLoading: true, syncError: null })
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tasks', { cache: 'no-store' })
|
const query = options?.includeFullTaskData ? '?include=detail' : ''
|
||||||
|
const res = await fetch(`/api/tasks${query}`, { cache: 'no-store' })
|
||||||
console.log('>>> syncFromServer: API response status:', res.status)
|
console.log('>>> syncFromServer: API response status:', res.status)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorPayload = await res.json().catch(() => ({}))
|
const errorPayload = await res.json().catch(() => ({}))
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user