feat(sp6): Inbox page + useInboxLanes hook
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 T14–T17
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user