feat(KB-662): refresh tasks when the dashboard regains focus

- Add a debounced visibilitychange refresh path to useTasks
- Reuse task normalization when refreshing task data from the API
- Preserve project-scoped fetching during initial and visibility-triggered reloads
- Add hook tests covering visibility refresh, normalization, debounce, and cleanup
This commit is contained in:
gsxdsm
2026-04-01 12:37:06 -07:00
parent 3122cbfa1c
commit 83ab5b0fda
2 changed files with 93 additions and 69 deletions

View File

@@ -837,38 +837,54 @@ describe("useTasks", () => {
});
}
function dispatchVisibilityChange() {
async function dispatchVisibilityChange() {
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await Promise.resolve();
});
}
it("refetches tasks when visibility changes from hidden to visible", async () => {
it("refetches tasks when visibility changes from hidden to visible and normalizes refreshed data", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const initialTask = createMockTask({ id: "FN-001", column: "todo" as Column });
const refreshedTask = createMockTask({
const refreshedTask = {
...createMockTask({
id: "FN-001",
column: "in-progress" as Column,
updatedAt: "2026-01-02T00:00:00Z",
});
}),
dependencies: undefined,
steps: undefined,
log: undefined,
} as unknown as Task;
mockFetchTasks.mockResolvedValueOnce([initialTask]);
mockFetchTasks.mockResolvedValueOnce([initialTask]).mockResolvedValueOnce([refreshedTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
await act(async () => {
await Promise.resolve();
});
expect(result.current.tasks).toHaveLength(1);
});
// Reset mock to return refreshed data
mockFetchTasks.mockResolvedValueOnce([refreshedTask]);
// Simulate tab becoming visible
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
setVisibilityState("hidden");
setVisibilityState("visible");
dispatchVisibilityChange();
await dispatchVisibilityChange();
await waitFor(() => {
expect(result.current.tasks[0].column).toBe("in-progress");
setVisibilityState("visible");
await dispatchVisibilityChange();
await act(async () => {
await Promise.resolve();
});
expect(result.current.tasks[0].column).toBe("in-progress");
expect(result.current.tasks[0].dependencies).toEqual([]);
expect(result.current.tasks[0].steps).toEqual([]);
expect(result.current.tasks[0].log).toEqual([]);
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
});
@@ -878,53 +894,60 @@ describe("useTasks", () => {
renderHook(() => useTasks());
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
await act(async () => {
await Promise.resolve();
});
// Simulate tab becoming hidden
setVisibilityState("visible");
setVisibilityState("hidden");
dispatchVisibilityChange();
mockFetchTasks.mockClear();
// Should not trigger another fetch
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
setVisibilityState("hidden");
await dispatchVisibilityChange();
expect(mockFetchTasks).not.toHaveBeenCalled();
});
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const initialTask = createMockTask({ id: "FN-001" });
mockFetchTasks.mockResolvedValueOnce([initialTask]);
mockFetchTasks.mockResolvedValue([initialTask]);
const { result } = renderHook(() => useTasks());
renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
await act(async () => {
await Promise.resolve();
});
// Wait for 1 second to ensure debounce window has passed from initial fetch
await new Promise((resolve) => setTimeout(resolve, 1100));
// Reset mock to track new calls
mockFetchTasks.mockClear();
// First visibility change should trigger a fetch (1s has passed)
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
setVisibilityState("hidden");
await dispatchVisibilityChange();
setVisibilityState("visible");
dispatchVisibilityChange();
await dispatchVisibilityChange();
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
// Rapid visibility changes immediately after should be debounced
for (let i = 0; i < 5; i++) {
setVisibilityState("hidden");
await dispatchVisibilityChange();
setVisibilityState("visible");
dispatchVisibilityChange();
await dispatchVisibilityChange();
}
// Should still only be 1 call (debounced)
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date("2026-01-01T00:00:02.200Z"));
setVisibilityState("hidden");
await dispatchVisibilityChange();
setVisibilityState("visible");
await dispatchVisibilityChange();
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
});
it("cleans up visibility change listener on unmount", async () => {

View File

@@ -39,42 +39,43 @@ export function useTasks(options?: UseTasksOptions) {
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
// Ref to track last visibility fetch time for debouncing (1 second minimum)
const lastVisibilityFetchRef = useRef<number>(0);
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000;
// Ref to track last visibility refresh time for debouncing (1 second minimum)
const lastVisibilityRefreshRef = useRef<number>(0);
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
// Determine which fetch function to use
const fetchTasksFn = useCallback(() => {
if (projectId) {
return api.fetchProjectTasks(projectId);
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean }) => {
try {
const refreshedTasks = projectId
? await api.fetchProjectTasks(projectId)
: await api.fetchTasks();
setTasks(refreshedTasks.map(normalizeTask));
} catch {
if (options?.clearOnError) {
setTasks([]);
}
}
return api.fetchTasks();
}, [projectId]);
// Fetch initial tasks
useEffect(() => {
fetchTasksFn()
.then((tasks) => setTasks(tasks.map(normalizeTask)))
.catch(() => setTasks([]));
}, [fetchTasksFn]);
void refreshTasks({ clearOnError: true });
}, [refreshTasks]);
// Visibility change listener - refresh tasks when tab becomes visible
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
const now = Date.now();
const timeSinceLastFetch = now - lastVisibilityFetchRef.current;
if (document.visibilityState !== "visible") {
return;
}
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
lastVisibilityFetchRef.current = now;
fetchTasksFn()
.then((tasks) => setTasks(tasks.map(normalizeTask)))
.catch(() => {
// Silently ignore fetch errors on visibility change
});
}
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);
@@ -82,7 +83,7 @@ export function useTasks(options?: UseTasksOptions) {
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [fetchTasksFn]);
}, [refreshTasks]);
// SSE live updates
// Note: In multi-project mode, SSE receives all task events.