feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown

This commit is contained in:
Nora
2026-07-02 11:44:53 -06:00
parent d8f3fb15f9
commit 1c367ddbe7
5 changed files with 715 additions and 1 deletions
+81
View File
@@ -0,0 +1,81 @@
// ---------------------------------------------------------------------------
// SP28: Inbox "Ack orphans" lane hook (D7).
//
// Fetches the three ack-kind orphan lists (999 / 277ca / ta1) and
// flattens them into a single lane of rows. The backend endpoint
// `/api/inbox/ack-orphans?kind=…` takes one kind at a time so the
// hook fires three parallel requests and merges by source_batch +
// ack_id (a single ack row never appears in two kinds' orphan lists,
// so the merge is just a concat).
//
// Refetch is wired through `useAckOrphans`'s `refetch` callback. The
// Inbox page calls it after a manual match lands (the orphan lane
// can drop a row that just got linked) — the same pattern the
// existing `useInboxLanes` uses for its tail-driven refetch.
// ---------------------------------------------------------------------------
import { useCallback, useEffect, useState } from "react";
import { api, type AckOrphanRow } from "@/lib/api";
export type AckOrphansState = {
loading: boolean;
error: Error | null;
items: AckOrphanRow[];
refetch: () => Promise<void>;
};
export function useAckOrphans(): AckOrphansState {
const [items, setItems] = useState<AckOrphanRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const refetch = useCallback(async () => {
try {
// Fire all three kind queries in parallel — the backend serves
// each independently. Fail-soft per kind: if one of the three
// returns a 5xx we surface the error and skip that kind so the
// operator still sees the other two.
const [a999, a277, aTa1] = await Promise.allSettled([
api.listAckOrphans("999"),
api.listAckOrphans("277ca"),
api.listAckOrphans("ta1"),
]);
const merged: AckOrphanRow[] = [];
const errs: Error[] = [];
for (const r of [a999, a277, aTa1] as const) {
if (r.status === "fulfilled") {
merged.push(...r.value);
} else {
errs.push(
r.reason instanceof Error
? r.reason
: new Error(String(r.reason)),
);
}
}
// Sort newest-first by parsedAt so the lane reads in the same
// direction as the rest of the inbox.
merged.sort((a, b) => {
const at = Date.parse(a.parsedAt);
const bt = Date.parse(b.parsedAt);
return bt - at;
});
setItems(merged);
// Only surface an error if EVERY kind failed; partial failures
// show their rows and silently swallow the broken kind (the
// operator can retry — a future improvement could surface a
// per-kind "fetch failed" pill).
setError(errs.length === 3 ? errs[0] : null);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refetch();
}, [refetch]);
return { items, loading, error, refetch };
}