Signed-off-by: Max <ai-agent@topdoglabs.com>

This commit is contained in:
Max 2026-02-23 17:59:23 -06:00
parent c1ae51b1ec
commit 51b9da9eb7
4 changed files with 109 additions and 17 deletions

View File

@ -85,6 +85,36 @@ const TASK_BASE_FIELDS = [
const TASK_DETAIL_ONLY_FIELDS = ["attachments"]; 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 { class HttpError extends Error {
readonly status: number; readonly status: number;
readonly details?: Record<string, unknown>; readonly details?: Record<string, unknown>;
@ -280,29 +310,68 @@ export async function GET(request: Request) {
} }
const supabase = getServiceSupabase(); 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 const taskFieldSet = includeFullTaskData
? [...TASK_BASE_FIELDS, ...TASK_DETAIL_ONLY_FIELDS] ? [...TASK_BASE_FIELDS, ...TASK_DETAIL_ONLY_FIELDS]
: TASK_BASE_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 [ const [
{ data: projects, error: projectsError }, { data: projects, error: projectsError },
{ data: sprints, error: sprintsError }, { data: sprints, error: sprintsError },
{ data: taskRows, error: tasksError },
{ data: users, error: usersError } { data: users, error: usersError }
] = 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(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"),
]); ]);
if (projectsError) throwQueryError("projects", projectsError); if (projectsError) throwQueryError("projects", projectsError);
if (sprintsError) throwQueryError("sprints", sprintsError); if (sprintsError) throwQueryError("sprints", sprintsError);
if (tasksError) throwQueryError("tasks", tasksError);
if (usersError) throwQueryError("users", usersError); if (usersError) throwQueryError("users", usersError);
const mappedSprints = (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>));
let taskRows: Record<string, unknown>[] = [];
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<string, unknown>] : [];
} 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<string, unknown>[] | 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<string, unknown>[] | null) || [];
}
}
const usersById = new Map<string, UserProfile>(); const usersById = new Map<string, UserProfile>();
for (const row of users || []) { for (const row of users || []) {
const mapped = mapUserRow(row as Record<string, unknown>); const mapped = mapUserRow(row as Record<string, unknown>);
@ -311,8 +380,8 @@ export async function GET(request: Request) {
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: mappedSprints,
tasks: ((taskRows as unknown as Record<string, unknown>[] | null) || []).map((row) => mapTaskRow(row, usersById, includeFullTaskData)), tasks: taskRows.map((row) => mapTaskRow(row, usersById, includeFullTaskData)),
currentUser: { currentUser: {
id: user.id, id: user.id,
name: user.name, name: user.name,

View File

@ -399,6 +399,7 @@ export default function Home() {
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null) const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [initialSyncComplete, setInitialSyncComplete] = useState(false) const [initialSyncComplete, setInitialSyncComplete] = useState(false)
const [hasLoadedAllTasks, setHasLoadedAllTasks] = useState(false)
const [users, setUsers] = useState<AssignableUser[]>([]) const [users, setUsers] = useState<AssignableUser[]>([])
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const debouncedSearchQuery = useDebounce(searchQuery, 300) const debouncedSearchQuery = useDebounce(searchQuery, 300)
@ -523,8 +524,11 @@ export default function Home() {
setInitialSyncComplete(false) setInitialSyncComplete(false)
const runInitialSync = async () => { const runInitialSync = async () => {
await syncFromServer() await syncFromServer({ scope: 'active-sprint' })
if (active) setInitialSyncComplete(true) if (active) {
setInitialSyncComplete(true)
setHasLoadedAllTasks(false)
}
} }
void runInitialSync() void runInitialSync()
@ -533,6 +537,21 @@ export default function Home() {
} }
}, [authReady, syncFromServer]) }, [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(() => { useEffect(() => {
if (!authReady) return if (!authReady) return
let isMounted = true let isMounted = true
@ -635,7 +654,7 @@ export default function Home() {
// Auto-rollover: Move incomplete tasks from ended sprints to next sprint // Auto-rollover: Move incomplete tasks from ended sprints to next sprint
useEffect(() => { useEffect(() => {
if (!authReady || sprints.length === 0) return if (!authReady || !hasLoadedAllTasks || sprints.length === 0) return
const now = new Date() const now = new Date()
const endedSprints = sprints.filter((s) => { const endedSprints = sprints.filter((s) => {
@ -674,7 +693,7 @@ export default function Home() {
updateSprint(endedSprint.id, { status: 'completed' }) updateSprint(endedSprint.id, { status: 'completed' })
} }
}) })
}, [authReady, sprints, tasks, updateTask, updateSprint]) }, [authReady, hasLoadedAllTasks, sprints, tasks, updateTask, updateSprint])
const activeKanbanTask = activeKanbanTaskId const activeKanbanTask = activeKanbanTaskId
? sprintTasks.find((task) => task.id === activeKanbanTaskId) ? sprintTasks.find((task) => task.id === activeKanbanTaskId)

View File

@ -256,8 +256,8 @@ export default function TaskDetailPage() {
useEffect(() => { useEffect(() => {
if (!authReady) return if (!authReady) return
syncFromServer({ includeFullTaskData: true }) syncFromServer({ includeFullTaskData: true, taskId })
}, [authReady, syncFromServer]) }, [authReady, syncFromServer, taskId])
useEffect(() => { useEffect(() => {
if (!authReady) return if (!authReady) return

View File

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