From 51b9da9eb7494439b7456f8c9c4c3c38ee644ea8 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 23 Feb 2026 17:59:23 -0600 Subject: [PATCH] Signed-off-by: Max --- src/app/api/tasks/route.ts | 85 +++++++++++++++++++++++++++++---- src/app/page.tsx | 27 +++++++++-- src/app/tasks/[taskId]/page.tsx | 4 +- src/stores/useTaskStore.ts | 10 ++-- 4 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 5c29c8a..3e81085 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -85,6 +85,36 @@ const TASK_BASE_FIELDS = [ const TASK_DETAIL_ONLY_FIELDS = ["attachments"]; +type TaskQueryScope = "all" | "active-sprint"; + +function parseSprintStart(value: string): Date { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 0, 0, 0, 0); + } + return new Date(value); +} + +function parseSprintEnd(value: string): Date { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 23, 59, 59, 999); + } + const parsed = new Date(value); + parsed.setHours(23, 59, 59, 999); + return parsed; +} + +function isSprintInProgress(startDate: string, endDate: string, now: Date = new Date()): boolean { + const sprintStart = parseSprintStart(startDate); + const sprintEnd = parseSprintEnd(endDate); + return sprintStart <= now && sprintEnd >= now; +} + +function parseTaskQueryScope(raw: string | null): TaskQueryScope { + return raw === "active-sprint" ? "active-sprint" : "all"; +} + class HttpError extends Error { readonly status: number; readonly details?: Record; @@ -280,39 +310,78 @@ export async function GET(request: Request) { } const supabase = getServiceSupabase(); - const includeFullTaskData = new URL(request.url).searchParams.get("include") === "detail"; + const url = new URL(request.url); + const includeFullTaskData = url.searchParams.get("include") === "detail"; + const scope = parseTaskQueryScope(url.searchParams.get("scope")); + const requestedTaskId = toNonEmptyString(url.searchParams.get("taskId")); + if (requestedTaskId && !isUuid(requestedTaskId)) { + throw new HttpError(400, "taskId must be a UUID", { taskId: requestedTaskId }); + } + const taskFieldSet = includeFullTaskData ? [...TASK_BASE_FIELDS, ...TASK_DETAIL_ONLY_FIELDS] : TASK_BASE_FIELDS; - // Use Promise.all for parallel queries with optimized field selection + // Keep non-task entities parallel; task query may be scoped by current sprint. const [ { data: projects, error: projectsError }, { data: sprints, error: sprintsError }, - { data: taskRows, error: tasksError }, { data: users, error: usersError } ] = 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("users").select("id, name, email, avatar_url"), ]); if (projectsError) throwQueryError("projects", projectsError); if (sprintsError) throwQueryError("sprints", sprintsError); - if (tasksError) throwQueryError("tasks", tasksError); if (usersError) throwQueryError("users", usersError); + const mappedSprints = (sprints || []).map((row) => mapSprintRow(row as Record)); + + let taskRows: Record[] = []; + if (requestedTaskId) { + const { data, error } = await supabase + .from("tasks") + .select(taskFieldSet.join(", ")) + .eq("id", requestedTaskId) + .maybeSingle(); + if (error) throwQueryError("tasks", error); + taskRows = data ? [data as unknown as Record] : []; + } else if (scope === "all") { + const { data, error } = await supabase + .from("tasks") + .select(taskFieldSet.join(", ")) + .order("updated_at", { ascending: false }); + if (error) throwQueryError("tasks", error); + taskRows = (data as unknown as Record[] | null) || []; + } else { + const now = new Date(); + const currentSprint = + mappedSprints.find((sprint) => sprint.status === "active" && isSprintInProgress(sprint.startDate, sprint.endDate, now)) ?? + mappedSprints.find((sprint) => sprint.status !== "completed" && isSprintInProgress(sprint.startDate, sprint.endDate, now)); + + if (currentSprint?.id) { + const { data, error } = await supabase + .from("tasks") + .select(taskFieldSet.join(", ")) + .eq("sprint_id", currentSprint.id) + .order("updated_at", { ascending: false }); + if (error) throwQueryError("tasks", error); + taskRows = (data as unknown as Record[] | null) || []; + } + } + const usersById = new Map(); for (const row of users || []) { const mapped = mapUserRow(row as Record); usersById.set(mapped.id, mapped); } - + return NextResponse.json({ projects: (projects || []).map((row) => mapProjectRow(row as Record)), - sprints: (sprints || []).map((row) => mapSprintRow(row as Record)), - tasks: ((taskRows as unknown as Record[] | null) || []).map((row) => mapTaskRow(row, usersById, includeFullTaskData)), + sprints: mappedSprints, + tasks: taskRows.map((row) => mapTaskRow(row, usersById, includeFullTaskData)), currentUser: { id: user.id, name: user.name, diff --git a/src/app/page.tsx b/src/app/page.tsx index cdf0bba..22830fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -399,6 +399,7 @@ export default function Home() { const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState(null) const [authReady, setAuthReady] = useState(false) const [initialSyncComplete, setInitialSyncComplete] = useState(false) + const [hasLoadedAllTasks, setHasLoadedAllTasks] = useState(false) const [users, setUsers] = useState([]) const [searchQuery, setSearchQuery] = useState("") const debouncedSearchQuery = useDebounce(searchQuery, 300) @@ -523,8 +524,11 @@ export default function Home() { setInitialSyncComplete(false) const runInitialSync = async () => { - await syncFromServer() - if (active) setInitialSyncComplete(true) + await syncFromServer({ scope: 'active-sprint' }) + if (active) { + setInitialSyncComplete(true) + setHasLoadedAllTasks(false) + } } void runInitialSync() @@ -533,6 +537,21 @@ export default function Home() { } }, [authReady, syncFromServer]) + useEffect(() => { + if (!authReady || hasLoadedAllTasks || viewMode === 'kanban') return + + let active = true + const loadFullTaskSet = async () => { + await syncFromServer({ scope: 'all' }) + if (active) setHasLoadedAllTasks(true) + } + + void loadFullTaskSet() + return () => { + active = false + } + }, [authReady, hasLoadedAllTasks, viewMode, syncFromServer]) + useEffect(() => { if (!authReady) return let isMounted = true @@ -635,7 +654,7 @@ export default function Home() { // Auto-rollover: Move incomplete tasks from ended sprints to next sprint useEffect(() => { - if (!authReady || sprints.length === 0) return + if (!authReady || !hasLoadedAllTasks || sprints.length === 0) return const now = new Date() const endedSprints = sprints.filter((s) => { @@ -674,7 +693,7 @@ export default function Home() { updateSprint(endedSprint.id, { status: 'completed' }) } }) - }, [authReady, sprints, tasks, updateTask, updateSprint]) + }, [authReady, hasLoadedAllTasks, sprints, tasks, updateTask, updateSprint]) const activeKanbanTask = activeKanbanTaskId ? sprintTasks.find((task) => task.id === activeKanbanTaskId) diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index 8e5550c..8f73470 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -256,8 +256,8 @@ export default function TaskDetailPage() { useEffect(() => { if (!authReady) return - syncFromServer({ includeFullTaskData: true }) - }, [authReady, syncFromServer]) + syncFromServer({ includeFullTaskData: true, taskId }) + }, [authReady, syncFromServer, taskId]) useEffect(() => { if (!authReady) return diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index b2d6d2e..b122372 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -90,7 +90,7 @@ interface TaskStore { syncError: string | null // Sync actions - syncFromServer: (options?: { includeFullTaskData?: boolean }) => Promise + syncFromServer: (options?: { includeFullTaskData?: boolean; scope?: "all" | "active-sprint"; taskId?: string }) => Promise setCurrentUser: (user: Partial) => void // Project actions @@ -330,8 +330,12 @@ export const useTaskStore = create()( 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 params = new URLSearchParams() + if (options?.includeFullTaskData) params.set('include', 'detail') + if (options?.scope && options.scope !== 'all') params.set('scope', options.scope) + if (options?.taskId) params.set('taskId', options.taskId) + const query = params.toString() + const res = await fetch(`/api/tasks${query ? `?${query}` : ''}`, { cache: 'no-store' }) console.log('>>> syncFromServer: API response status:', res.status) if (!res.ok) { const errorPayload = await res.json().catch(() => ({}))