Compare commits

..

No commits in common. "c1ae51b1ec3475dd3c91effc8f459563b28cbabc" and "8aaca14e3ad847d029e7962aa9e38170f38cc5c1" have entirely different histories.

8 changed files with 43 additions and 119 deletions

View File

@ -167,21 +167,6 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
- Delete any comment or reply from the thread
- 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 view supports moving tasks between:

View File

@ -85,20 +85,6 @@ A unified CLI that covers all API operations.
./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
```bash

View File

@ -63,8 +63,8 @@ const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
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;
// Field sets are split so board loads can avoid heavy attachment payloads.
const TASK_BASE_FIELDS = [
// Optimized field selection - fetch all fields needed for board and detail display
const TASK_FIELDS = [
"id",
"title",
"type",
@ -80,11 +80,10 @@ const TASK_BASE_FIELDS = [
"due_date",
"tags",
"comments",
"attachments",
"description",
];
const TASK_DETAIL_ONLY_FIELDS = ["attachments"];
class HttpError extends Error {
readonly status: number;
readonly details?: Record<string, unknown>;
@ -272,7 +271,7 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
// GET - fetch all tasks, projects, and sprints
// Uses lightweight fields for faster initial load
export async function GET(request: Request) {
export async function GET() {
try {
const user = await getAuthenticatedUser();
if (!user) {
@ -280,10 +279,6 @@ export async function GET(request: Request) {
}
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
const [
@ -294,7 +289,7 @@ export async function GET(request: Request) {
] = await Promise.all([
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("tasks").select(taskFieldSet.join(", ")).order("updated_at", { ascending: false }),
supabase.from("tasks").select(TASK_FIELDS.join(", ")).order("updated_at", { ascending: false }),
supabase.from("users").select("id, name, email, avatar_url"),
]);
@ -312,7 +307,7 @@ export async function GET(request: Request) {
return NextResponse.json({
projects: (projects || []).map((row) => mapProjectRow(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, includeFullTaskData)),
tasks: ((taskRows as unknown as Record<string, unknown>[] | null) || []).map((row) => mapTaskRow(row, usersById, false)),
currentUser: {
id: user.id,
name: user.name,

View File

@ -468,25 +468,6 @@ export default function Home() {
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}, [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(() => {
let isMounted = true
const loadSession = async () => {
@ -775,7 +756,7 @@ export default function Home() {
const resolveAssignee = (assigneeId: string | undefined) => {
if (!assigneeId) return null
return knownUsersById.get(assigneeId) || null
return assignableUsers.find((user) => user.id === assigneeId) || null
}
const setNewTaskAssignee = (assigneeId: string) => {

View File

@ -256,7 +256,7 @@ export default function TaskDetailPage() {
useEffect(() => {
if (!authReady) return
syncFromServer({ includeFullTaskData: true })
syncFromServer()
}, [authReady, syncFromServer])
useEffect(() => {
@ -330,25 +330,6 @@ export default function TaskDetailPage() {
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}, [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(
() =>
sprints
@ -393,7 +374,7 @@ export default function TaskDetailPage() {
}
}
const handleAddComment = async () => {
const handleAddComment = () => {
if (!editedTask || !newComment.trim()) return
const commentAuthorId = getCurrentUserCommentAuthorId(currentUser)
@ -401,28 +382,14 @@ export default function TaskDetailPage() {
toast.error("You must be signed in to add a comment.")
return
}
const nextTask: Task = {
setEditedTask({
...editedTask,
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), commentAuthorId)],
}
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,
})
}
setNewComment("")
}
const handleAddReply = async (parentId: string) => {
const handleAddReply = (parentId: string) => {
if (!editedTask) return
const text = replyDrafts[parentId]?.trim()
if (!text) return
@ -432,26 +399,13 @@ export default function TaskDetailPage() {
toast.error("You must be signed in to reply.")
return
}
const nextTask: Task = {
setEditedTask({
...editedTask,
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, commentAuthorId)),
}
setEditedTask(nextTask)
})
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
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) => {
@ -465,7 +419,7 @@ export default function TaskDetailPage() {
const resolveAssignee = (assigneeId: string | undefined) => {
if (!assigneeId) return null
return knownUsersById.get(assigneeId) || null
return assignableUsers.find((user) => user.id === assigneeId) || null
}
const setEditedTaskAssignee = (assigneeId: string) => {

View File

@ -392,6 +392,8 @@ export async function revokeSession(token: string): Promise<void> {
}
export async function getUserBySessionToken(token: string): Promise<AuthUser | null> {
await deleteExpiredSessions();
const supabase = getServiceSupabase();
const tokenHash = hashSessionToken(token);
const now = new Date().toISOString();

View File

@ -90,7 +90,7 @@ interface TaskStore {
syncError: string | null
// Sync actions
syncFromServer: (options?: { includeFullTaskData?: boolean }) => Promise<void>
syncFromServer: () => Promise<void>
setCurrentUser: (user: Partial<UserProfile>) => void
// Project actions
@ -326,12 +326,11 @@ export const useTaskStore = create<TaskStore>()(
lastSynced: null,
syncError: null,
syncFromServer: async (options) => {
syncFromServer: async () => {
console.log('>>> syncFromServer START')
set({ isLoading: true, syncError: null })
try {
const query = options?.includeFullTaskData ? '?include=detail' : ''
const res = await fetch(`/api/tasks${query}`, { cache: 'no-store' })
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(() => ({}))

File diff suppressed because one or more lines are too long