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).
|
// Inbox page (sub-project 6).
|
||||||
//
|
//
|
||||||
// Full-bleed dark working surface for the four-lane triage workflow.
|
// Full-bleed dark working surface for the four-lane triage workflow.
|
||||||
// Real implementation lands in T14-T17; this placeholder exists so the
|
// The page itself is a thin wrapper: data via useInboxLanes, layout via
|
||||||
// /inbox route resolves during T13.
|
// 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() {
|
export default function Inbox() {
|
||||||
|
const { lanes, loading, error } = useInboxLanes();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen p-8"
|
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"
|
||||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||||
>
|
>
|
||||||
<p className="font-mono text-sm" style={{ color: "var(--tt-ink-dim)" }}>
|
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
||||||
Inbox — coming up in T14–T17
|
<main className="p-4 flex gap-4 items-start flex-wrap">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user