From b607e4c4595d973414c88900713320bda313d8a3 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 18:41:44 -0600 Subject: [PATCH] feat(sp6): Inbox page + useInboxLanes hook --- src/hooks/useInboxLanes.test.ts | 45 ++++++++++++++++++++++++++++++ src/hooks/useInboxLanes.ts | 41 +++++++++++++++++++++++++++ src/pages/Inbox.test.tsx | 33 ++++++++++++++++++++++ src/pages/Inbox.tsx | 49 +++++++++++++++++++++++++++++---- 4 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 src/hooks/useInboxLanes.test.ts create mode 100644 src/hooks/useInboxLanes.ts create mode 100644 src/pages/Inbox.test.tsx diff --git a/src/hooks/useInboxLanes.test.ts b/src/hooks/useInboxLanes.test.ts new file mode 100644 index 0000000..3935ed3 --- /dev/null +++ b/src/hooks/useInboxLanes.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { useInboxLanes } from "./useInboxLanes"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("useInboxLanes", () => { + it("loads lanes on mount", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }), + ); + const { result } = renderHook(() => useInboxLanes()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.lanes).toEqual({ + rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }); + }); + + it("exposes an error when fetch fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }), + ); + const { result } = renderHook(() => useInboxLanes()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.message).toMatch(/500/); + }); +}); diff --git a/src/hooks/useInboxLanes.ts b/src/hooks/useInboxLanes.ts new file mode 100644 index 0000000..5e2a0c9 --- /dev/null +++ b/src/hooks/useInboxLanes.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchInboxLanes, type InboxLanes } from "@/lib/inbox-api"; + +/** Default poll interval. Tail-integration (T19) will replace this. */ +const POLL_INTERVAL_MS = 5_000; + +export function useInboxLanes() { + const [lanes, setLanes] = useState({ + rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const pollRef = useRef | null>(null); + + const refetch = useCallback(async () => { + try { + const next = await fetchInboxLanes(); + setLanes(next); + setError(null); + } catch (e) { + setError(e as Error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refetch(); + pollRef.current = setInterval(() => { + void refetch(); + }, POLL_INTERVAL_MS); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [refetch]); + + return { lanes, loading, error, refetch }; +} diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx new file mode 100644 index 0000000..07549d7 --- /dev/null +++ b/src/pages/Inbox.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import Inbox from "./Inbox"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("Inbox page", () => { + it("renders the four lane headers", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }), + ); + const { container } = render(); + await waitFor(() => { + expect(container.textContent).toContain("REJECTED"); + expect(container.textContent).toContain("CANDIDATES"); + expect(container.textContent).toContain("UNMATCHED"); + expect(container.textContent).toContain("DONE"); + }); + }); +}); diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 4423e8b..30785bb 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -2,19 +2,56 @@ // Inbox page (sub-project 6). // // Full-bleed dark working surface for the four-lane triage workflow. -// Real implementation lands in T14-T17; this placeholder exists so the -// /inbox route resolves during T13. +// The page itself is a thin wrapper: data via useInboxLanes, layout via +// Lane / InboxHeader. Bulk actions are wired in T18. // --------------------------------------------------------------------------- +import { Lane, type LaneRow } from "@/components/inbox/Lane"; +import { InboxHeader } from "@/components/inbox/InboxHeader"; +import { useInboxLanes } from "@/hooks/useInboxLanes"; + export default function Inbox() { + const { lanes, loading, error } = useInboxLanes(); + + if (loading) { + return ( +
+ loading… +
+ ); + } + + if (error) { + return ( +
+ error: {error.message} +
+ ); + } + + const needEyes = + lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length; + + const noop = (_r: LaneRow) => {}; + return (
-

- Inbox — coming up in T14–T17 -

+ +
+ + + + +
); }