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"];
|
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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(() => ({}))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user