@anthusai/papyrus 5.47.0 → 5.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -113,9 +113,9 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
113
113
  }
114
114
 
115
115
  /** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
116
- export function ReferenceCapturePanel({ capture }: { capture: CaptureView }) {
116
+ export function ReferenceCapturePanel({ capture, startEditing = false }: { capture: CaptureView; startEditing?: boolean }) {
117
117
  const [expanded, setExpanded] = useState(false);
118
- const [editing, setEditing] = useState(false);
118
+ const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
119
119
  const [savedNote, setSavedNote] = useState(false);
120
120
  const line = captureLine(capture.status);
121
121
  const Icon = ICON[line.state];
@@ -35,7 +35,7 @@ export const FEED_SURFACE_STYLE = {} as CSSProperties;
35
35
  /** Spread on every feed surface: the tone variables, and the hook the newsroom stylesheet uses to allow round corners here. */
36
36
  export const FEED_SURFACE_PROPS = { "data-feed-surface": "", style: FEED_SURFACE_STYLE } as const;
37
37
 
38
- const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
38
+ export const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
39
39
  out_of_scope: "Out of scope",
40
40
  policy_exclusion: "Policy exclusion",
41
41
  duplicate: "Duplicate",
@@ -270,7 +270,10 @@ export function ReviewQueueBar({
270
270
  onPrevious,
271
271
  position,
272
272
  total,
273
+ anchored = false,
273
274
  }: {
275
+ /** On a wide screen, pin the bar to the bottom-right corner of the viewport (the detail column's width), whatever scrolls. A sheet's own bar is not anchored. */
276
+ anchored?: boolean;
274
277
  caughtUp?: boolean;
275
278
  onNext: () => void;
276
279
  onPrevious: () => void;
@@ -282,7 +285,8 @@ export function ReviewQueueBar({
282
285
  return (
283
286
  <nav
284
287
  aria-label="Review queue"
285
- className="sticky bottom-0 z-10 flex shrink-0 items-center gap-2 border-t border-border bg-card px-3 py-2"
288
+ className={cn("sticky bottom-0 z-10 flex shrink-0 items-center gap-2 bg-[var(--feed-card)] px-3 py-2", anchored && "md:fixed md:bottom-0 md:right-0 md:w-[calc(24rem+1rem)] md:pr-7 lg:w-[calc(28rem+1rem)]")}
289
+ data-review-queue-anchored={anchored || undefined}
286
290
  data-review-queue-bar
287
291
  >
288
292
  <Button
@@ -3109,7 +3109,8 @@ function NewsDeskDashboard({
3109
3109
  loading={!dashboard.isDemo && !hasHydratedReferences}
3110
3110
  newsroomSections={newsroomSections}
3111
3111
  notificationFixture={dashboard.notificationFixture ?? null}
3112
- onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, options?.reasonCode,
3112
+ onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
3113
+ onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
3113
3114
  options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
3114
3115
  predictionState={referenceReviewData.state}
3115
3116
  referenceAttachments={referenceAttachments}
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /** True while the media query matches. False on the server and before the first effect, so the first paint is the wide layout. */
6
+ export function useMediaQuery(query: string): boolean {
7
+ const [matches, setMatches] = useState(false);
8
+ useEffect(() => {
9
+ const mediaQuery = window.matchMedia(query);
10
+ const update = () => setMatches(mediaQuery.matches);
11
+ update();
12
+ mediaQuery.addEventListener("change", update);
13
+ return () => mediaQuery.removeEventListener("change", update);
14
+ }, [query]);
15
+ return matches;
16
+ }
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
5
+ import { referenceLineageId } from "../lib/newsroom-references";
6
+ import { loadReferenceAttachmentsForLineageId, loadStoragePathUrl } from "./news-desk-taxonomy-client";
7
+
8
+ function normalizeReferenceDetailHttpUri(value: string | null | undefined): string | null {
9
+ const trimmed = value?.trim();
10
+ if (!trimmed) return null;
11
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
12
+ return null;
13
+ }
14
+
15
+ /** The attachments of one Reference (seeded, else read), with stored files turned into links. Shared by the References detail pane and the Needs me detail pane. */
16
+ export function useReferencePreviewAttachments(
17
+ reference: ReferenceRecord | null,
18
+ referenceAttachments: ReferenceAttachmentRecord[],
19
+ ) {
20
+ const lineageId = reference ? referenceLineageId(reference) : "";
21
+ const seededAttachments = useMemo(
22
+ () => referenceAttachments.filter((attachment) => attachment.referenceLineageId === lineageId),
23
+ [lineageId, referenceAttachments],
24
+ );
25
+ const [attachments, setAttachments] = useState<ReferenceAttachmentRecord[]>(seededAttachments);
26
+ const [attachmentLinksById, setAttachmentLinksById] = useState<Record<string, string>>({});
27
+
28
+ useEffect(() => {
29
+ setAttachments(seededAttachments);
30
+ }, [seededAttachments]);
31
+
32
+ useEffect(() => {
33
+ if (!lineageId) return;
34
+ if (seededAttachments.length > 0) return;
35
+ let active = true;
36
+ void loadReferenceAttachmentsForLineageId(lineageId)
37
+ .then((loaded) => {
38
+ if (!active || !loaded.length) return;
39
+ setAttachments(loaded);
40
+ })
41
+ .catch(() => undefined);
42
+ return () => {
43
+ active = false;
44
+ };
45
+ }, [lineageId, seededAttachments.length]);
46
+
47
+ useEffect(() => {
48
+ if (!attachments.length) {
49
+ setAttachmentLinksById({});
50
+ return;
51
+ }
52
+ let active = true;
53
+ const staticLinks = new Map<string, string>();
54
+ const storageLookups: Array<{ id: string; storagePath: string }> = [];
55
+
56
+ for (const attachment of attachments) {
57
+ if (attachment.storagePath) {
58
+ storageLookups.push({ id: attachment.id, storagePath: attachment.storagePath });
59
+ continue;
60
+ }
61
+ const sourceHref = normalizeReferenceDetailHttpUri(attachment.sourceUri);
62
+ if (sourceHref) staticLinks.set(attachment.id, sourceHref);
63
+ }
64
+
65
+ if (!storageLookups.length) {
66
+ setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
67
+ return () => {
68
+ active = false;
69
+ };
70
+ }
71
+
72
+ setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
73
+ void Promise.all(storageLookups.map(async (lookup) => {
74
+ const result = await loadStoragePathUrl(lookup.storagePath);
75
+ return { id: lookup.id, url: result.url };
76
+ }))
77
+ .then((resolved) => {
78
+ if (!active) return;
79
+ const nextLinks = Object.fromEntries(staticLinks.entries());
80
+ for (const entry of resolved) {
81
+ if (entry.url) nextLinks[entry.id] = entry.url;
82
+ }
83
+ setAttachmentLinksById(nextLinks);
84
+ })
85
+ .catch(() => undefined);
86
+
87
+ return () => {
88
+ active = false;
89
+ };
90
+ }, [attachments]);
91
+
92
+ const previewAttachments = useMemo(
93
+ () => attachments.map((attachment) => ({
94
+ ...attachment,
95
+ sourceUri: attachmentLinksById[attachment.id] ?? attachment.sourceUri,
96
+ })),
97
+ [attachmentLinksById, attachments],
98
+ );
99
+
100
+ return previewAttachments;
101
+ }
@@ -41,18 +41,29 @@ export type RatingController = {
41
41
  * A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
42
42
  * kept in the store until it is saved and is never dropped by a failed save.
43
43
  */
44
- export function useRatingStore({ save, saved, seed }: {
44
+ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
45
45
  save: RatingSave;
46
46
  saved: (id: string) => ReviewChoice | null;
47
47
  seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
48
+ /** Whether a Reject on this item must wait for a reason code. Default yes (a Reference); an AI draft or an email asks nothing. */
49
+ needsReason?: (id: string) => boolean;
50
+ /**
51
+ * True for an item whose decision, once made, cannot be sent again (an AI draft moves its Proposal, a review request closes, a held email is
52
+ * queued). A reason typed after such a decision goes to `saveNote` instead, onto the decision that exists.
53
+ */
54
+ sticky?: (id: string) => boolean;
55
+ /** Saves the reason of an item that is already decided and sticky. Throws a sentence when it cannot. */
56
+ saveNote?: (id: string, note: string) => Promise<void>;
48
57
  }): RatingController {
49
58
  const [items, setItems] = useState<Record<string, ItemRating>>({});
50
59
  const itemsRef = useRef(items);
51
- const latest = useRef({ save, saved, seed });
52
- latest.current = { save, saved, seed };
60
+ const latest = useRef({ save, saved, seed, needsReason, sticky, saveNote });
61
+ latest.current = { save, saved, seed, needsReason, sticky, saveNote };
53
62
  const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
54
63
  const inFlight = useRef<Set<string>>(new Set());
55
64
  const dirty = useRef<Set<string>>(new Set());
65
+ const decidedHere = useRef<Set<string>>(new Set());
66
+ const noteInFlight = useRef<Set<string>>(new Set());
56
67
 
57
68
  const patch = useCallback((id: string, change: Partial<ItemRating>) => {
58
69
  const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
@@ -73,9 +84,34 @@ export function useRatingStore({ save, saved, seed }: {
73
84
  return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
74
85
  };
75
86
 
87
+ /** True once a sticky item has its decision: from then on only its reason can change. */
88
+ const finalDecision = (id: string) => Boolean(latest.current.sticky?.(id) && latest.current.saveNote && (decidedHere.current.has(id) || latest.current.saved(id)));
89
+
90
+ /** A reason typed after the decision: saved onto it. The text stays in the store until it is saved, and a failed save never drops it. */
91
+ const saveNoteOnly = useCallback(async (id: string): Promise<void> => {
92
+ if (noteInFlight.current.has(id)) { dirty.current.add(`note:${id}`); return; }
93
+ noteInFlight.current.add(id);
94
+ dirty.current.delete(`note:${id}`);
95
+ const started = itemsRef.current[id]?.note;
96
+ const { note } = values(id);
97
+ patch(id, { feedback: { phase: "saving" } });
98
+ try {
99
+ await latest.current.saveNote!(id, note.trim());
100
+ const now = itemsRef.current[id];
101
+ patch(id, { feedback: { phase: "saved" }, ...(now?.note === started ? { note: undefined } : {}) });
102
+ } catch (failure) {
103
+ dirty.current.add(`note:${id}`); // still unsaved: tapping away, or the next pause, tries again
104
+ patch(id, { feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your note could not be saved. It is kept here; keep typing or tap away to try again." } });
105
+ } finally {
106
+ noteInFlight.current.delete(id);
107
+ }
108
+ if (itemsRef.current[id]?.feedback.phase !== "error" && dirty.current.delete(`note:${id}`)) void saveNoteOnly(id);
109
+ }, [patch]);
110
+
76
111
  const commit = useCallback(async (id: string, choice: ReviewChoice) => {
112
+ if (finalDecision(id)) { void saveNoteOnly(id); return; }
77
113
  const { note, reasonCode } = values(id);
78
- if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
114
+ if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
79
115
  if (inFlight.current.has(id)) { dirty.current.add(id); return; }
80
116
  inFlight.current.add(id);
81
117
  dirty.current.delete(id);
@@ -83,6 +119,7 @@ export function useRatingStore({ save, saved, seed }: {
83
119
  patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
84
120
  try {
85
121
  await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
122
+ if (latest.current.sticky?.(id)) decidedHere.current.add(id);
86
123
  // What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
87
124
  const now = itemsRef.current[id] ?? EMPTY;
88
125
  patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
@@ -93,11 +130,12 @@ export function useRatingStore({ save, saved, seed }: {
93
130
  inFlight.current.delete(id);
94
131
  }
95
132
  if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
96
- }, [patch]);
133
+ }, [patch, saveNoteOnly]);
97
134
 
98
135
  const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
99
136
 
100
137
  const choose = useCallback((id: string, choice: ReviewChoice) => {
138
+ if (finalDecision(id)) return; // a decision that cannot be sent again is only ever given a reason, never a second vote
101
139
  if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
102
140
  void commit(id, choice);
103
141
  }, [commit]);
@@ -126,7 +164,20 @@ export function useRatingStore({ save, saved, seed }: {
126
164
 
127
165
  const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
128
166
 
129
- useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
167
+ // Leaving the page (or closing the view) saves what was typed and not yet saved, rather than dropping it.
168
+ const leaving = useRef<() => void>(() => undefined);
169
+ leaving.current = () => {
170
+ for (const key of [...dirty.current]) {
171
+ if (!key.startsWith("note:")) continue;
172
+ const id = key.slice(5);
173
+ const choice = effective(id);
174
+ if (choice && dirty.current.delete(key)) void commit(id, choice);
175
+ }
176
+ };
177
+ useEffect(() => () => {
178
+ for (const timer of Object.values(timers.current)) clearTimeout(timer);
179
+ leaving.current();
180
+ }, []);
130
181
 
131
182
  return { get, choose, setReason, setNote, commitNote, cancelReason };
132
183
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The adapters that turn what already exists into `NeedsMeItem`s, the one model the "What needs me" feed ranks and renders.
3
+ * Each adapter only reads; deciding is routed back to the existing action for that source (see components/notification-center.tsx).
4
+ *
5
+ * reference a pending Reference reads the Reference, the Cyclotron's decision on it, its preview
6
+ * incomplete_capture a pending Reference whose captured text is reads the same, plus the Reference's capture status (the
7
+ * missing or cut off `curation` card, else the extracted-text attachments)
8
+ * draft one AI draft in an open draft review reads the review request and the Proposal (headline, dek, sources)
9
+ * stage_review an open hard gate or soft review that is reads the review request (reason, ask, deadline, subject) and the
10
+ * not about drafts subject story's title and excerpt
11
+ * flag an open flag reads the review request
12
+ * held_email an email submission held for review reads the monitoring email summary (subject, sender, verdicts)
13
+ * concept_proposal a proposed taxonomy concept or reader STUB: typed, returns nothing until those sources reach the page
14
+ * proposal
15
+ */
16
+ import type { ReferenceRecord } from "./category-repository";
17
+ import type { MonitoringEmailSummary } from "./monitoring-overview";
18
+ import { buildNotificationItems, draftPicture, requestSource, type DraftItem, type RequestItem } from "./notification-feed";
19
+ import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
20
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
21
+ import { dekAddsInformation, referenceDek, referenceSourceHost, type ReferencePreview } from "./reference-preview";
22
+ import { referenceArrivalTime, referenceDisplayTime } from "./reference-feed-ranking";
23
+ import type { CaptureStatus } from "./reference-capture";
24
+ import type { RelevanceDecisionView } from "./relevance-decisions";
25
+ import type { DraftView } from "./review-drafts";
26
+ import { nextText, type ReviewRequest } from "./review-requests";
27
+ import { predictionOf, uncertaintyOf, type NeedsMeCounts, type NeedsMeDecisionState, type NeedsMeItem } from "./needs-me-ranking";
28
+
29
+ export type NeedsMePayload =
30
+ | { kind: "reference"; reference: ReferenceRecord; lineageId: string; decision: RelevanceDecisionView | undefined; preview: ReferencePreview | undefined; capture: CaptureStatus | undefined; displayTime: number | null }
31
+ | { kind: "draft"; item: DraftItem; loading: boolean }
32
+ | { kind: "request"; request: ReviewRequest; storyTitle: string | null; excerpt: string | null; storyDeleted: boolean }
33
+ | { kind: "email"; summary: MonitoringEmailSummary }
34
+ | { kind: "concept"; row: ConceptProposalRow };
35
+
36
+ export type NeedsMeEntry = NeedsMeItem<NeedsMePayload>;
37
+
38
+ const ACCEPT_WORD = { accept: "Accept", reject: "Reject" } as const;
39
+
40
+ function timeOf(value: string | null | undefined): number | null {
41
+ const parsed = value ? Date.parse(value) : NaN;
42
+ return Number.isFinite(parsed) ? parsed : null;
43
+ }
44
+
45
+ function percent(confidence: number | null): string {
46
+ return confidence === null ? "" : ` ${Math.round(confidence * 100)}%`;
47
+ }
48
+
49
+ // --- references and captures ----------------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Whether a capture was attempted and came up short, and nobody has said otherwise: text that is thin (too short, or showing signs of
53
+ * being cut off or paywalled), or a curation card that records a failed capture. A Reference nobody has tried to capture yet (the
54
+ * address only, as monitoring discovers it) is not "incomplete": there is nothing to check.
55
+ */
56
+ export function captureIsIncomplete(capture: CaptureStatus | undefined): boolean {
57
+ if (!capture || capture.assessment) return false;
58
+ return capture.state === "thin" || (capture.state === "none" && capture.source === "curation");
59
+ }
60
+
61
+ export function referenceDefaultOutcome(decision: RelevanceDecisionView | undefined): string {
62
+ const prediction = predictionOf(decision);
63
+ return prediction
64
+ ? `If you leave it, it stays pending with the Cyclotron's suggestion: ${ACCEPT_WORD[prediction.label]}${percent(prediction.confidence)}.`
65
+ : "If you leave it, it stays pending. Nothing uses it until someone rates it.";
66
+ }
67
+
68
+ export type ReferenceSources = {
69
+ references: ReferenceRecord[];
70
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined;
71
+ previews: ReadonlyMap<string, ReferencePreview>;
72
+ captures: ReadonlyMap<string, CaptureStatus> | undefined;
73
+ /** Lineage ids rated in this visit: they keep their place showing the answer. */
74
+ visitRated: ReadonlySet<string>;
75
+ };
76
+
77
+ /** Pending References (and the ones rated in this visit), as `reference` items, or `incomplete_capture` when the capture is cut off. */
78
+ export function referenceItems(input: ReferenceSources): NeedsMeEntry[] {
79
+ const items: NeedsMeEntry[] = [];
80
+ for (const reference of input.references) {
81
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
82
+ const lineageId = referenceLineageId(reference);
83
+ if (status === "archived") continue;
84
+ const decided = status === "accepted" || status === "rejected";
85
+ if (decided && !input.visitRated.has(lineageId)) continue;
86
+ const decision = input.decisions?.get(lineageId);
87
+ const preview = input.previews.get(lineageId);
88
+ const capture = input.captures?.get(lineageId);
89
+ const prediction = predictionOf(decision);
90
+ const title = referenceDisplayTitle(reference);
91
+ const dek = referenceDek(reference, preview);
92
+ const host = referenceSourceHost(reference);
93
+ const incomplete = !decided && captureIsIncomplete(capture);
94
+ items.push({
95
+ type: incomplete ? "incomplete_capture" : "reference",
96
+ id: lineageId,
97
+ title,
98
+ dek: incomplete ? "The captured text looks cut off or missing, so the Cyclotron cannot judge it well." : dekAddsInformation(title, dek, preview?.imageUrl ?? null) ? dek : null,
99
+ image: preview?.imageUrl ?? null,
100
+ source: preview?.siteName ?? host ?? "Unknown source",
101
+ host,
102
+ createdAt: referenceArrivalTime(reference) ?? referenceDisplayTime(reference),
103
+ ...(prediction ? { prediction } : {}),
104
+ importance: 1,
105
+ uncertainty: uncertaintyOf(prediction?.pAccept),
106
+ decisionState: status === "accepted" ? "accepted" : status === "rejected" ? "rejected" : "open",
107
+ defaultOutcome: incomplete ? "If you leave it, the Reference stays pending with the text it has." : referenceDefaultOutcome(decision),
108
+ hardGate: false,
109
+ askedForReview: Boolean(decision?.reviewRecommended),
110
+ payload: { kind: "reference", reference, lineageId, decision, preview, capture, displayTime: referenceDisplayTime(reference) },
111
+ });
112
+ }
113
+ return items;
114
+ }
115
+
116
+ // --- drafts, stories, flags -------------------------------------------------------------------------------------
117
+
118
+ function choiceState(choice: "accept" | "reject" | null | undefined): NeedsMeDecisionState {
119
+ return choice === "accept" ? "accepted" : choice === "reject" ? "rejected" : "open";
120
+ }
121
+
122
+ export function draftItem(item: DraftItem, bySource: ReadonlyMap<string, ReferencePreview>): NeedsMeEntry {
123
+ const { view, request } = item;
124
+ const picture = draftPicture(view, bySource);
125
+ const link = request.links.find((entry) => entry.id === item.draftId);
126
+ return {
127
+ type: "draft",
128
+ id: item.key,
129
+ title: view?.headline ?? link?.label ?? "Draft",
130
+ dek: view ? view.dek : "Loading the draft…",
131
+ image: picture.imageUrl,
132
+ source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "AI draft"),
133
+ host: picture.host,
134
+ createdAt: timeOf(request.raisedAt),
135
+ importance: 1,
136
+ uncertainty: uncertaintyOf(undefined),
137
+ decisionState: choiceState(item.decision?.choice),
138
+ defaultOutcome: nextText(request),
139
+ hardGate: request.mode === "hard_gate",
140
+ askedForReview: true,
141
+ payload: { kind: "draft", item, loading: !view },
142
+ };
143
+ }
144
+
145
+ function requestDecisionState(request: ReviewRequest): NeedsMeDecisionState {
146
+ if (request.state === "open") return "open";
147
+ const answer = request.answer;
148
+ if (!answer) return "accepted";
149
+ if (answer.decision === "acknowledge" || (answer.decision === "approve" && request.mode === "flag")) return "acknowledged";
150
+ if (answer.decision === "reject" || (answer.decision === "feedback" && answer.steer === "not_this_kind")) return "rejected";
151
+ return "accepted";
152
+ }
153
+
154
+ export function requestItem(
155
+ item: RequestItem,
156
+ context: { titles: Readonly<Record<string, string>>; excerpts: Readonly<Record<string, string>>; deleted: ReadonlySet<string> },
157
+ ): NeedsMeEntry {
158
+ const { request } = item;
159
+ const storyId = request.subject.type === "story" ? request.subject.id : null;
160
+ const flag = request.mode === "flag";
161
+ return {
162
+ type: flag ? "flag" : "stage_review",
163
+ id: request.id,
164
+ title: request.reason,
165
+ dek: request.ask,
166
+ image: null,
167
+ source: requestSource(request),
168
+ host: null,
169
+ createdAt: timeOf(request.raisedAt),
170
+ importance: 1,
171
+ uncertainty: uncertaintyOf(undefined),
172
+ decisionState: requestDecisionState(request),
173
+ defaultOutcome: nextText(request),
174
+ hardGate: request.mode === "hard_gate",
175
+ askedForReview: true,
176
+ payload: {
177
+ kind: "request",
178
+ request,
179
+ storyTitle: storyId ? context.titles[storyId] ?? null : null,
180
+ excerpt: storyId ? context.excerpts[storyId] ?? null : null,
181
+ storyDeleted: storyId ? context.deleted.has(storyId) : false,
182
+ },
183
+ };
184
+ }
185
+
186
+ // --- held email --------------------------------------------------------------------------------------------------
187
+
188
+ function senderDomain(summary: MonitoringEmailSummary): string | null {
189
+ const from = summary.headerFrom || summary.envelopeSender || "";
190
+ const address = from.match(/@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/);
191
+ return address ? address[1].toLowerCase() : null;
192
+ }
193
+
194
+ /** The decision recorded on a receipt, else the one made in this visit. */
195
+ export function emailDecisionState(summary: MonitoringEmailSummary, decided: ReadonlyMap<string, "accept" | "reject">): NeedsMeDecisionState {
196
+ const made = decided.get(summary.receiptId) ?? summary.quarantine?.decision?.action;
197
+ return choiceState(made);
198
+ }
199
+
200
+ /** Held email submissions: untrusted, so only the subject and sender facts are used; the body is read in the detail pane as inert text. */
201
+ export function heldEmailItems(summaries: readonly MonitoringEmailSummary[], decided: ReadonlyMap<string, "accept" | "reject">): NeedsMeEntry[] {
202
+ const items: NeedsMeEntry[] = [];
203
+ for (const summary of summaries) {
204
+ const held = summary.quarantine?.state === "quarantined";
205
+ const decision = emailDecisionState(summary, decided);
206
+ if (!held && !decided.has(summary.receiptId)) continue;
207
+ const domain = senderDomain(summary);
208
+ items.push({
209
+ type: "held_email",
210
+ id: `email#${summary.receiptId}`,
211
+ title: summary.subject?.trim() || "(no subject)",
212
+ dek: summary.warnings.length ? `Held for review: ${summary.warnings.join(", ")}.` : `Held for review. ${summary.linkCount} candidate ${summary.linkCount === 1 ? "link" : "links"}.`,
213
+ image: null,
214
+ source: domain ?? "Email submission",
215
+ host: domain,
216
+ createdAt: timeOf(summary.observedAt),
217
+ importance: 1,
218
+ uncertainty: uncertaintyOf(undefined),
219
+ decisionState: decision,
220
+ defaultOutcome: "If you leave it, it stays held privately. Nothing is routed from it unless you accept it.",
221
+ hardGate: false,
222
+ askedForReview: true,
223
+ payload: { kind: "email", summary },
224
+ });
225
+ }
226
+ return items;
227
+ }
228
+
229
+ // --- stubs ---------------------------------------------------------------------------------------------------------
230
+
231
+ /** A proposed taxonomy concept or a reader's proposal. Not on the page yet: the source is typed so the adapter has a place to land. */
232
+ export type ConceptProposalRow = { id: string; title: string; summary: string | null; proposedBy: "cyclotron" | "reader"; createdAt: string | null };
233
+
234
+ export function conceptProposalItems(_rows: readonly ConceptProposalRow[]): NeedsMeEntry[] {
235
+ return [];
236
+ }
237
+
238
+ // --- all of it -----------------------------------------------------------------------------------------------------
239
+
240
+ export type NeedsMeSources = ReferenceSources & {
241
+ requests: ReviewRequest[];
242
+ drafts: Readonly<Record<string, DraftView | undefined>>;
243
+ missingDrafts: ReadonlySet<string>;
244
+ draftsState: "loading" | "ready" | "failed";
245
+ /** Draft keys and request ids decided in this visit. */
246
+ stay: ReadonlySet<string>;
247
+ bySource: ReadonlyMap<string, ReferencePreview>;
248
+ storyTitles: Readonly<Record<string, string>>;
249
+ storyExcerpts: Readonly<Record<string, string>>;
250
+ deletedStories: ReadonlySet<string>;
251
+ emails: readonly MonitoringEmailSummary[];
252
+ emailsDecided: ReadonlyMap<string, "accept" | "reject">;
253
+ concepts?: readonly ConceptProposalRow[];
254
+ };
255
+
256
+ /** Every source, as one list of items (unordered: `rankNeedsMe` orders it). */
257
+ export function buildNeedsMeItems(sources: NeedsMeSources): NeedsMeEntry[] {
258
+ const fromRequests = buildNotificationItems({
259
+ requests: sources.requests, drafts: sources.drafts, missingDrafts: sources.missingDrafts, draftsState: sources.draftsState, stay: sources.stay,
260
+ });
261
+ const context = { titles: sources.storyTitles, excerpts: sources.storyExcerpts, deleted: sources.deletedStories };
262
+ return [
263
+ ...referenceItems(sources),
264
+ ...fromRequests.drafts.map((item) => draftItem(item, sources.bySource)),
265
+ ...fromRequests.reviews.map((item) => requestItem(item, context)),
266
+ ...fromRequests.flags.map((item) => requestItem(item, context)),
267
+ ...heldEmailItems(sources.emails, sources.emailsDecided),
268
+ ...conceptProposalItems(sources.concepts ?? []),
269
+ ];
270
+ }
271
+
272
+ function plural(count: number, one: string, many = `${one}s`): string {
273
+ return `${count} ${count === 1 ? one : many}`;
274
+ }
275
+
276
+ /** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
277
+ export function waitingTextFor(counts: NeedsMeCounts): string {
278
+ const parts = [
279
+ counts.drafts ? plural(counts.drafts, "draft") : null,
280
+ counts.references ? plural(counts.references, "reference") : null,
281
+ counts.stories ? plural(counts.stories, "story review") : null,
282
+ counts.email ? plural(counts.email, "held email", "held emails") : null,
283
+ counts.captures ? plural(counts.captures, "capture to check", "captures to check") : null,
284
+ counts.other ? plural(counts.other, "other item") : null,
285
+ ].filter((part): part is string => part !== null);
286
+ if (!parts.length) return "All caught up";
287
+ const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
288
+ return `${list} waiting for you`;
289
+ }