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"];
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<string, unknown>;
@ -280,29 +310,68 @@ 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<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>();
for (const row of users || []) {
const mapped = mapUserRow(row as Record<string, unknown>);
@ -311,8 +380,8 @@ 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)),
sprints: mappedSprints,
tasks: taskRows.map((row) => mapTaskRow(row, usersById, includeFullTaskData)),
currentUser: {
id: user.id,
name: user.name,

View File

@ -399,6 +399,7 @@ export default function Home() {
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
const [authReady, setAuthReady] = useState(false)
const [initialSyncComplete, setInitialSyncComplete] = useState(false)
const [hasLoadedAllTasks, setHasLoadedAllTasks] = useState(false)
const [users, setUsers] = useState<AssignableUser[]>([])
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)

View File

@ -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

View File

@ -90,7 +90,7 @@ interface TaskStore {
syncError: string | null
// 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
// Project actions
@ -330,8 +330,12 @@ export const useTaskStore = create<TaskStore>()(
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(() => ({}))