Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
parent
c1ae51b1ec
commit
51b9da9eb7
@ -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,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<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>);
|
||||
usersById.set(mapped.id, mapped);
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => ({}))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user