feat(sp6): Inbox page + useInboxLanes hook

This commit is contained in:
Tyler
2026-06-20 18:41:44 -06:00
parent 610e580844
commit b607e4c459
4 changed files with 162 additions and 6 deletions
+45
View File
@@ -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/);
});
});
+41
View File
@@ -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<InboxLanes>({
rejected: [],
candidates: [],
unmatched: [],
done_today: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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 };
}
+33
View File
@@ -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(<Inbox />);
await waitFor(() => {
expect(container.textContent).toContain("REJECTED");
expect(container.textContent).toContain("CANDIDATES");
expect(container.textContent).toContain("UNMATCHED");
expect(container.textContent).toContain("DONE");
});
});
});
+43 -6
View File
@@ -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 (
<div
className="min-h-screen p-8 font-mono"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)" }}
>
loading
</div>
);
}
if (error) {
return (
<div
className="min-h-screen p-8 font-mono"
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)" }}
>
error: {error.message}
</div>
);
}
const needEyes =
lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length;
const noop = (_r: LaneRow) => {};
return (
<div
className="min-h-screen p-8"
className="min-h-screen"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<p className="font-mono text-sm" style={{ color: "var(--tt-ink-dim)" }}>
Inbox coming up in T14T17
</p>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
<main className="p-4 flex gap-4 items-start flex-wrap">
<Lane name="REJECTED" accent="oxblood" rows={lanes.rejected} onRowClick={noop} />
<Lane name="CANDIDATES" accent="amber" rows={lanes.candidates} onRowClick={noop} />
<Lane name="UNMATCHED" accent="ink-blue" rows={lanes.unmatched} onRowClick={noop} />
<Lane name="DONE" accent="muted" rows={lanes.done_today} onRowClick={noop} />
</main>
</div>
);
}