src/features/inbox/inbox.tsx
Linked repository file from this build.
"use client";
import { LoadingPreview } from "@/components/loading-preview";
import { ElapsedTime } from "@/components/elapsed-time";
import { useEffect, useState } from "react";
import { NavigationLink as Link } from "@/components/navigation-link";
import { BellRing, Sparkles } from "lucide-react";
import { useReview } from "@/features/reviews/provider";
import { workflowLabels } from "@/features/reviews/types";
import { formatAppointmentEastern } from "@/features/reviews/search.helpers";
import { eventLabels, eventTime, type InboxResponse, type InboxReview } from "./types";
import { OperationsPreview } from "./operations-preview";
import { CompactList, useInboxPageSize } from "./compact-list";
import { clampInboxPages } from "./pagination";
import { fetchJson } from "@/lib/fetch-json";
import { useRemoteLoad } from "@/lib/use-remote-load";
const VISIT_ERROR = "Home loaded, but its visit time could not be saved.";
/** Record when the user last saw Home. Returns false if the server did not save it. */
async function saveVisit(asOf: string, signal?: AbortSignal) {
try {
const response = await fetch("/api/inbox/seen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ asOf }),
signal,
});
return response.ok;
} catch {
return Boolean(signal?.aborted);
}
}
/** A one-section answer replaces only that list and its total. */
function mergeInbox(current: InboxResponse | null, result: InboxResponse): InboxResponse {
if (!current || result.section === "all") return result;
const listKey = (
{
new: "newReviews",
touched: "lastTouched",
queue: "awaitingReview",
events: "events",
} as const
)[result.section];
return {
...current,
...result,
newReviews: listKey === "newReviews" ? result.newReviews : current.newReviews,
lastTouched: listKey === "lastTouched" ? result.lastTouched : current.lastTouched,
awaitingReview: listKey === "awaitingReview" ? result.awaitingReview : current.awaitingReview,
events: listKey === "events" ? result.events : current.events,
totals: { ...current.totals, [listKey]: result.totals[listKey] },
};
}
const firstPages = { newPage: 0, touchedPage: 0, eventsPage: 0, queuePage: 0 };
const linkClass =
"flex min-h-12 min-w-0 items-center gap-2 px-3 text-xs transition-colors hover:bg-accent/60 focus-visible:bg-accent/60 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-ring";
function greeting(date: Date): string {
const hour = date.getHours();
return hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening";
}
function ReviewTask({ row }: { row: InboxReview }) {
const detail = `${row.clientName || "Unnamed client"}, review ${row.reviewNumber}, FFY ${row.fiscalYear}, case ${row.caseNumber}, examiner ${row.examiner}, ${workflowLabels[row.state]}${row.appointment ? `, ${formatAppointmentEastern(row.appointment)}` : ""}${row.touchedAt ? `, touched ${eventTime(row.touchedAt)}` : ""}`;
return (
<Link
prefetch={false}
href={`/reviews/${row.reviewKey}`}
className={linkClass}
title={detail}
aria-label={detail}
>
<span
aria-hidden
className={`size-1.5 shrink-0 rounded-full ${row.state === "needs_changes" ? "bg-destructive" : row.state === "released_to_reporting" ? "bg-success" : "bg-muted-foreground"}`}
/>
<span className="min-w-0 flex-1 truncate font-medium">
<span className="block truncate">{row.clientName || "Unnamed client"}</span>
<span className="mt-0.5 block truncate text-[0.7rem] font-normal text-muted-foreground">
{workflowLabels[row.state]}
</span>
</span>
<span className="shrink-0 font-mono text-[0.7rem] tabular-nums text-muted-foreground">
{row.reviewNumber} · {row.fiscalYear}
</span>
</Link>
);
}
type InitialInbox = {
scope: string;
pageSize: number;
data: InboxResponse;
};
export function Inbox({ initial }: { initial?: InitialInbox }) {
const { actor } = useReview();
return (
<InboxContent
key={JSON.stringify(actor)}
initial={initial?.scope === JSON.stringify(actor) ? initial : undefined}
/>
);
}
function InboxContent({ initial }: { initial?: InitialInbox }) {
const { pageSize, ready: viewportReady } = useInboxPageSize(initial?.pageSize);
const [pages, setPages] = useState(firstPages);
const [data, setData] = useState<InboxResponse | null>(initial?.data ?? null);
const [visitError, setVisitError] = useState("");
const [refresh, setRefresh] = useState(0);
const [section, setSection] = useState<NonNullable<InboxResponse["section"]>>("all");
// The window of "new since last visit". A null window starts a new visit on the next load.
const [visit, setVisit] = useState<{ since: string; asOf: string } | null>(
initial
? {
since: initial.data.since || initial.data.lastInboxSeenAt || "1970-01-01T00:00:00.000Z",
asOf: initial.data.asOf,
}
: null,
);
useEffect(() => {
const timer = setInterval(() => {
if (document.hidden) return;
setVisit(null);
setSection("all");
setRefresh((value) => value + 1);
}, 300_000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (!initial) return;
void saveVisit(initial.data.asOf).then((saved) => {
if (!saved) setVisitError(VISIT_ERROR);
});
}, [initial]);
const [today, setToday] = useState<{ greeting: string; date: string } | null>(null);
useEffect(() => {
const now = new Date();
// oxlint-disable-next-line react/set-state-in-effect -- The local date must wait for hydration.
setToday({
greeting: greeting(now),
date: now.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
}),
});
}, []);
// A new page size starts every list on its first page, reset during render.
const [sizedFor, setSizedFor] = useState(pageSize);
if (sizedFor !== pageSize) {
setSizedFor(pageSize);
setSection("all");
setPages(firstPages);
}
const {
loading: pending,
error: loadError,
refreshedAt,
} = useRemoteLoad(
async (signal) => {
const firstVisit = !visit;
const params = new URLSearchParams({
pageSize: String(pageSize),
section,
...Object.fromEntries(Object.entries(pages).map(([key, value]) => [key, String(value)])),
...visit,
});
const result = await fetchJson<InboxResponse>(`/api/inbox?${params}`, {
signal,
fallback: "Could not load Home. It will retry automatically.",
});
if (signal.aborted) return;
const reachablePages =
result.section === "all" ? clampInboxPages(pages, result.totals, pageSize) : pages;
if (reachablePages !== pages) {
setPages(reachablePages);
return;
}
setData((current) => mergeInbox(current, result));
if (!firstVisit) return;
setVisit({
since: result.since || result.lastInboxSeenAt || "1970-01-01T00:00:00.000Z",
asOf: result.asOf,
});
// Showing the fetched rows does not wait for the visit time to save.
void saveVisit(result.asOf, signal).then((saved) => {
if (!saved && !signal.aborted) setVisitError(VISIT_ERROR);
});
},
[pageSize, pages, refresh, section],
{
enabled: viewportReady,
skipFirstRun: Boolean(initial),
initialLoading: !initial,
initialRefreshedAt: initial?.data.asOf ?? null,
},
);
const error = loadError || visitError;
function changePage(key: keyof typeof pages, value: number) {
setSection(
(
{
newPage: "new",
touchedPage: "touched",
queuePage: "queue",
eventsPage: "events",
} as const
)[key],
);
setPages((current) => ({ ...current, [key]: value }));
}
return (
<InboxView
data={data ?? (!error ? placeholderInbox(pageSize) : null)}
pageSize={pageSize}
pages={pages}
pending={pending}
error={error}
refreshedAt={refreshedAt}
today={today}
changePage={changePage}
loading={!data && !error}
/>
);
}
function placeholderInbox(count: number): InboxResponse {
const reviews: InboxReview[] = Array.from({ length: count }, (_, i) => ({
reviewKey: `loading-${i}`,
fiscalYear: 2000,
reviewNumber: "00000",
caseNumber: "000000000",
clientName: "Loading client name",
examiner: "Loading examiner",
state: "new_active",
appointment: null,
}));
return {
asOf: "2000-01-01T12:00:00Z",
since: "",
lastInboxSeenAt: null,
section: "all",
newReviews: reviews,
lastTouched: reviews.map((row) => ({ ...row, touchedAt: null })),
awaitingReview: reviews,
events: reviews.map((row) => ({
...row,
id: row.reviewKey,
staffId: "",
kind: "distributed",
actorRole: "examiner",
createdAt: "2000-01-01T12:00:00Z",
readAt: null,
})),
totals: { newReviews: count, lastTouched: count, awaitingReview: count, events: count },
};
}
export function InboxLoading() {
const { pageSize } = useInboxPageSize();
return (
<InboxView
data={placeholderInbox(pageSize)}
pageSize={pageSize}
pages={firstPages}
pending
error=""
refreshedAt={null}
today={null}
changePage={() => {}}
loading
/>
);
}
function InboxView({
data,
pageSize,
pages,
pending,
error,
refreshedAt,
today,
changePage,
loading,
}: {
data: InboxResponse | null;
pageSize: number;
pages: typeof firstPages;
pending: boolean;
error: string;
refreshedAt: string | null;
today: { greeting: string; date: string } | null;
changePage(key: keyof typeof firstPages, value: number): void;
loading: boolean;
}) {
const { actor } = useReview();
const queueTitle =
actor.role === "examiner"
? "With reviewers"
: actor.role === "supervisor" || actor.role === "pec"
? "Awaiting my review"
: "Awaiting decisions";
const common = { pageSize, pending };
return (
<LoadingPreview loading={loading} label="Loading Home…">
<div className="mx-auto w-full max-w-[83rem] space-y-5">
<header className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{today ? `${today.greeting}, ${actor.name.split(" ")[0]} · ${today.date}` : "Welcome"}
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">Home</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<ElapsedTime at={refreshedAt} />
</div>
</header>
{error && (
<p
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</p>
)}
{(actor.role === "statistics" || actor.role === "admin") && (
<OperationsPreview operationsFirst />
)}
{data && (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),20rem))] items-start gap-3">
<CompactList
{...common}
title="New"
icon={<Sparkles className="size-3.5" aria-hidden />}
rows={data.newReviews}
total={data.totals.newReviews}
page={pages.newPage}
onPage={(value) => changePage("newPage", value)}
rowKey={(row) => row.reviewKey}
render={(row) => <ReviewTask row={row} />}
empty="No unopened reviews."
/>
<CompactList
{...common}
title="Last touched"
rows={data.lastTouched}
total={data.totals.lastTouched}
page={pages.touchedPage}
onPage={(value) => changePage("touchedPage", value)}
rowKey={(row) => row.reviewKey}
render={(row) => <ReviewTask row={row} />}
empty="Open a review to start working."
/>
<CompactList
{...common}
title="Since last visit"
icon={<BellRing className="size-3.5" aria-hidden />}
rows={data.events}
total={data.totals.events}
page={pages.eventsPage}
onPage={(value) => changePage("eventsPage", value)}
rowKey={(row) => row.id}
render={(event) => (
<Link
href={`/reviews/${event.reviewKey}`}
className={linkClass}
title={`${eventLabels[event.kind] || event.kind}, review ${event.reviewNumber}, FFY ${event.fiscalYear}, ${eventTime(event.createdAt)}`}
>
<span className="min-w-0 flex-1 truncate">
<span className="block truncate">{eventLabels[event.kind] || event.kind}</span>
<span className="mt-0.5 block text-[0.7rem] text-muted-foreground">
{eventTime(event.createdAt)}
</span>
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{event.reviewNumber} · {event.fiscalYear}
</span>
<span className="sr-only">{eventTime(event.createdAt)}</span>
</Link>
)}
empty="You are up to date."
/>
<CompactList
{...common}
title={queueTitle}
rows={data.awaitingReview}
total={data.totals.awaitingReview}
page={pages.queuePage}
onPage={(value) => changePage("queuePage", value)}
rowKey={(row) => row.reviewKey}
render={(row) => <ReviewTask row={row} />}
empty="No decisions pending."
/>
</div>
)}
{actor.role !== "statistics" && actor.role !== "admin" && (
<OperationsPreview operationsFirst={false} />
)}
</div>
</LoadingPreview>
);
}