import { useState, useEffect, useCallback, useRef } from "react"; import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core"; import * as api from "../api"; const RECONNECT_DELAY_MS = 3000; /** If no SSE message (including heartbeat events) arrives within this window, force reconnect. */ const HEARTBEAT_TIMEOUT_MS = 45_000; function normalizeTask(task: Task): Task { return { ...task, dependencies: Array.isArray(task.dependencies) ? task.dependencies : [], steps: Array.isArray(task.steps) ? task.steps : [], log: Array.isArray((task as Task & { log?: unknown }).log) ? (task as Task & { log?: Task["log"] }).log! : [], }; } /** * Compare two ISO timestamp strings. * Returns positive if a is newer than b, negative if b is newer, 0 if equal. */ function compareTimestamps(a: string | undefined, b: string | undefined): number { if (!a && !b) return 0; if (!a) return -1; // b is newer if a has no timestamp if (!b) return 1; // a is newer if b has no timestamp return a.localeCompare(b); } export interface UseTasksOptions { /** * When provided, fetches tasks only for this project. * SSE events from other project contexts are ignored. */ projectId?: string; /** * When provided, fetches tasks matching this search query. * Server-side full-text search across title, ID, description, and comments. */ searchQuery?: string; } export function useTasks(options?: UseTasksOptions) { const projectId = options?.projectId; const searchQuery = options?.searchQuery; const [tasks, setTasks] = useState([]); const [connectionNonce, setConnectionNonce] = useState(0); // Once the user expands the archived column, we keep including archived tasks // in subsequent refreshes for the lifetime of this hook instance. const [includeArchived, setIncludeArchived] = useState(false); const includeArchivedRef = useRef(includeArchived); includeArchivedRef.current = includeArchived; const tasksRef = useRef(tasks); const fetchVersionRef = useRef(0); const lastVisibilityRefreshRef = useRef(0); const searchQueryRef = useRef(searchQuery); const refreshTasksRef = useRef(null!); // Tracks when task data was last confirmed fresh by the server. // Used to prevent false positives in stuck detection when tab has been in background. const lastFetchTimeMs = useRef(undefined); // Tracks the project context version to detect stale SSE events after project switches. // Incremented whenever projectId changes, invalidating any in-flight SSE handlers. const projectContextVersionRef = useRef(0); // Track previous projectId to detect changes const previousProjectIdRef = useRef(projectId); tasksRef.current = tasks; searchQueryRef.current = searchQuery; // Detect project changes and invalidate SSE context if (previousProjectIdRef.current !== projectId) { previousProjectIdRef.current = projectId; projectContextVersionRef.current++; // Clear tasks immediately on project change so prior-project rows are not rendered // during the fetch gap. This is scoped to project-context transitions only. setTasks([]); } const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string; includeArchivedOverride?: boolean }) => { const requestVersion = ++fetchVersionRef.current; const requestProjectId = projectId; // Capture the projectId for this request const query = options?.searchQueryOverride ?? searchQuery; const wantArchived = options?.includeArchivedOverride ?? includeArchivedRef.current; try { const fetchedTasks = await api.fetchTasks(undefined, undefined, requestProjectId, query, wantArchived); // Reject if project changed (compare against the projectId at request time) or version is stale if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) { return; } setTasks(fetchedTasks.map(normalizeTask)); // Record when we received fresh server data for stuck detection lastFetchTimeMs.current = Date.now(); } catch { // Reject if project changed or version is stale if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) { return; } if (options?.clearOnError) { setTasks([]); return; } setTasks((current) => current); } }, [projectId, searchQuery]); refreshTasksRef.current = refreshTasks; /** Lazy-load archived tasks. Called by the Board when the archived column is first expanded. */ const loadArchivedTasks = useCallback(async () => { if (includeArchivedRef.current) return; setIncludeArchived(true); includeArchivedRef.current = true; await refreshTasksRef.current({ includeArchivedOverride: true }); }, []); // Debounced search effect - separate from refreshTasks to avoid dependency cycle useEffect(() => { if (searchQuery === undefined) return; const timer = setTimeout(() => { void refreshTasks({ searchQueryOverride: searchQuery }); }, 300); return () => clearTimeout(timer); }, [searchQuery]); // intentionally NOT including refreshTasks in deps // Fetch initial tasks and recover when the tab becomes visible again. useEffect(() => { void refreshTasks({ clearOnError: true }); const handleVisibilityChange = () => { if (document.visibilityState !== "visible") { return; } const now = Date.now(); const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current; if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) { return; } lastVisibilityRefreshRef.current = now; void refreshTasks(); }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [refreshTasks]); // SSE live updates // Note: SSE events from stale project contexts are ignored via projectContextVersionRef. // This prevents tasks from the previous project from appearing during project switches. useEffect(() => { let closedByCleanup = false; let reconnectTimer: ReturnType | null = null; let heartbeatTimer: ReturnType | null = null; if (connectionNonce > 0) { void refreshTasksRef.current(); } // Capture the project context version at effect start. // Any event from a stale SSE connection (created before a project switch) // will have a different context version and will be ignored. const contextVersionAtStart = projectContextVersionRef.current; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const es = new EventSource(`/api/events${query}`); /** Reset the heartbeat watchdog. Called on every incoming SSE message. */ const resetHeartbeat = () => { if (heartbeatTimer) clearTimeout(heartbeatTimer); heartbeatTimer = setTimeout(() => { // No message received within the timeout — connection is likely dead. if (!closedByCleanup) { handleError(); } }, HEARTBEAT_TIMEOUT_MS); }; // Start the watchdog immediately — if the connection never opens we still want to time out. resetHeartbeat(); const handleCreated = (e: MessageEvent) => { // Guard: reject events from stale project contexts if (projectContextVersionRef.current !== contextVersionAtStart) { return; } resetHeartbeat(); const task = normalizeTask(JSON.parse(e.data) as Task); // When search is active, re-fetch to get server-filtered results if (searchQueryRef.current) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } // Add the task if it doesn't already exist setTasks((prev) => { // Avoid duplicates if (prev.some((t) => t.id === task.id)) return prev; return [...prev, task]; }); }; const handleMoved = (e: MessageEvent) => { // Guard: reject events from stale project contexts if (projectContextVersionRef.current !== contextVersionAtStart) { return; } resetHeartbeat(); // When search is active, re-fetch to get server-filtered results if (searchQueryRef.current) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data); const normalizedTask = normalizeTask(task); setTasks((prev) => prev.map((t) => t.id === normalizedTask.id ? { ...normalizedTask, column: to } : t ) ); // Record when we received fresh server data for stuck detection lastFetchTimeMs.current = Date.now(); }; const handleUpdated = (e: MessageEvent) => { // Guard: reject events from stale project contexts if (projectContextVersionRef.current !== contextVersionAtStart) { return; } resetHeartbeat(); // When search is active, re-fetch to get server-filtered results if (searchQueryRef.current) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } const incoming = normalizeTask(JSON.parse(e.data) as Task); setTasks((prev) => prev.map((t) => { if (t.id !== incoming.id) return t; const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt); if (updatedAtCompare < 0) { return t; } if (t.column === incoming.column) { return incoming; } const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt); if (t.columnMovedAt && !incoming.columnMovedAt) { return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt }; } if (columnTimestampCompare > 0) { return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt }; } return incoming; }) ); // Record when we received fresh server data for stuck detection lastFetchTimeMs.current = Date.now(); }; const handleDeleted = (e: MessageEvent) => { // Guard: reject events from stale project contexts if (projectContextVersionRef.current !== contextVersionAtStart) { return; } resetHeartbeat(); // When search is active, re-fetch to get server-filtered results if (searchQueryRef.current) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } const task = normalizeTask(JSON.parse(e.data) as Task); setTasks((prev) => prev.filter((t) => t.id !== task.id)); }; const handleMerged = (e: MessageEvent) => { // Guard: reject events from stale project contexts if (projectContextVersionRef.current !== contextVersionAtStart) { return; } resetHeartbeat(); // When search is active, re-fetch to get server-filtered results if (searchQueryRef.current) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } const { task }: { task: Task } = JSON.parse(e.data); const normalizedTask = normalizeTask(task); setTasks((prev) => prev.map((t) => t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t ) ); }; const cleanup = () => { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; } es.removeEventListener("heartbeat", handleHeartbeat); es.removeEventListener("task:created", handleCreated); es.removeEventListener("task:moved", handleMoved); es.removeEventListener("task:updated", handleUpdated); es.removeEventListener("task:deleted", handleDeleted); es.removeEventListener("task:merged", handleMerged); es.removeEventListener("error", handleError); es.close(); }; const handleError = () => { if (closedByCleanup) return; cleanup(); reconnectTimer = setTimeout(() => { setConnectionNonce((current) => current + 1); }, RECONNECT_DELAY_MS); }; /** Server heartbeat (named event, not comment) — just resets the watchdog. */ const handleHeartbeat = () => { resetHeartbeat(); }; es.addEventListener("open", () => resetHeartbeat()); es.addEventListener("heartbeat", handleHeartbeat); es.addEventListener("task:created", handleCreated); es.addEventListener("task:moved", handleMoved); es.addEventListener("task:updated", handleUpdated); es.addEventListener("task:deleted", handleDeleted); es.addEventListener("task:merged", handleMerged); es.addEventListener("error", handleError); return () => { closedByCleanup = true; cleanup(); }; // Note: searchQuery and refreshTasks are accessed via refs inside handlers // to avoid tearing down/rebuilding the EventSource on search changes. // The EventSource URL only depends on projectId. }, [connectionNonce, projectId]); const createTask = useCallback(async (input: TaskCreateInput): Promise => { const task = normalizeTask(await api.createTask(input, projectId)); setTasks((prev) => { if (prev.some((t) => t.id === task.id)) return prev; return [...prev, task]; }); return task; }, [projectId]); const moveTask = useCallback(async (id: string, column: Column): Promise => { return normalizeTask(await api.moveTask(id, column, projectId)); }, [projectId]); const deleteTask = useCallback(async (id: string): Promise => { return normalizeTask(await api.deleteTask(id, projectId)); }, [projectId]); const mergeTask = useCallback(async (id: string): Promise => { return api.mergeTask(id, projectId); }, [projectId]); const retryTask = useCallback(async (id: string): Promise => { return normalizeTask(await api.retryTask(id, projectId)); }, [projectId]); const duplicateTask = useCallback(async (id: string): Promise => { const task = normalizeTask(await api.duplicateTask(id, projectId)); setTasks((prev) => { if (prev.some((t) => t.id === task.id)) return prev; return [...prev, task]; }); return task; }, [projectId]); const updateTask = useCallback(async ( id: string, updates: { title?: string; description?: string; dependencies?: string[] } ): Promise => { const previousTask = tasksRef.current.find((t) => t.id === id); const optimisticTask = previousTask ? { ...previousTask, ...updates, updatedAt: new Date().toISOString() } : undefined; if (optimisticTask) { setTasks((prev) => prev.map((t) => (t.id === id ? optimisticTask : t)) ); } try { const updatedTask = normalizeTask(await api.updateTask(id, updates, projectId)); setTasks((prev) => prev.map((t) => (t.id === id ? updatedTask : t)) ); return updatedTask; } catch (err) { if (previousTask) { setTasks((prev) => prev.map((t) => (t.id === id ? previousTask : t)) ); } throw err; } }, [projectId]); const archiveTask = useCallback(async (id: string): Promise => { const task = normalizeTask(await api.archiveTask(id, projectId)); setTasks((prev) => prev.map((t) => (t.id === id ? task : t)) ); return task; }, [projectId]); const unarchiveTask = useCallback(async (id: string): Promise => { const task = normalizeTask(await api.unarchiveTask(id, projectId)); setTasks((prev) => prev.map((t) => (t.id === id ? task : t)) ); return task; }, [projectId]); const archiveAllDone = useCallback(async (): Promise => { const archived = await api.archiveAllDone(projectId); const normalized = archived.map(normalizeTask); setTasks((prev) => prev.map((t) => { const updated = normalized.find((archived) => archived.id === t.id); return updated || t; }) ); return normalized; }, [projectId]); return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, lastFetchTimeMs: lastFetchTimeMs.current }; }