@anthusai/papyrus 5.32.0 → 5.33.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.
@@ -4,7 +4,19 @@ import { getNewsroomNavHref } from "./newsroom-nav";
4
4
 
5
5
  export { formatUtc };
6
6
 
7
+ export type AutomationUsage = {
8
+ calls?: number;
9
+ promptTokens?: number;
10
+ completionTokens?: number;
11
+ models?: Record<string, number>;
12
+ complete?: boolean;
13
+ unpricedModels?: string[];
14
+ };
15
+
7
16
  export type AutomationRun = {
17
+ origin: "monitor" | "manual";
18
+ requestedBy: string | null;
19
+ reason: string | null;
8
20
  sourceId: string | null;
9
21
  referenceId: string | null;
10
22
  triggerStatus: string | null;
@@ -20,8 +32,30 @@ export type AutomationRun = {
20
32
  storyTitle: string | null;
21
33
  costUsd: number | null;
22
34
  costNote: string;
35
+ budgetUsd: number | null;
36
+ overBudget: boolean;
37
+ usage: AutomationUsage | null;
23
38
  };
24
39
 
40
+ /** A ProcedureRun of the same procedure that this automation did not start ("Started outside this automation"). */
41
+ export type AutomationOutsideRun = {
42
+ runId: string | null;
43
+ status: string | null;
44
+ startedAt: string | null;
45
+ finishedAt: string | null;
46
+ durationSeconds: number | null;
47
+ error: string | null;
48
+ requestedBy: string | null;
49
+ storyId: string | null;
50
+ storyVersionId: string | null;
51
+ storyTitle: string | null;
52
+ costUsd: number | null;
53
+ costNote: string;
54
+ overBudget: boolean;
55
+ };
56
+
57
+ export type AutomationRunNow = { available: boolean; reason: string | null; needsInput: boolean; inputNames: string[] };
58
+
25
59
  export type AutomationLink = {
26
60
  state: "linked" | "input_differs" | "trigger_not_found" | "publication_not_in_router" | "router_not_found" | "not_applicable";
27
61
  text: string;
@@ -44,6 +78,7 @@ export type AutomationRecord = {
44
78
  inputTemplate: Record<string, string>;
45
79
  limits: { timeoutSeconds?: number; maxConcurrent?: number; budgetUsdPerRun?: number };
46
80
  limitsNote: string;
81
+ runNow: AutomationRunNow;
47
82
  createdAt: string | null;
48
83
  updatedAt: string | null;
49
84
  link: AutomationLink | null;
@@ -51,6 +86,8 @@ export type AutomationRecord = {
51
86
  lastRun: AutomationRun | null;
52
87
  skippedAlreadyHadReference: number;
53
88
  notice: AutomationNotice | null;
89
+ outsideRuns: AutomationOutsideRun[];
90
+ outsideNotice: "outside_unavailable" | null;
54
91
  };
55
92
 
56
93
  export type AutomationOverview = {
@@ -72,7 +109,7 @@ export function triggerText(trigger: AutomationRecord["trigger"]): string {
72
109
  return kind;
73
110
  }
74
111
 
75
- export const ENABLED_NOTE = "display only until the router honours it";
112
+ export const ENABLED_NOTE = "the router starts nothing for a disabled automation";
76
113
 
77
114
  export function enabledText(record: Pick<AutomationRecord, "enabled">): string {
78
115
  return record.enabled ? "Enabled" : "Disabled";
@@ -87,8 +124,13 @@ const STATUS_WORDS: Record<string, string> = {
87
124
  startFailed: "Could not start",
88
125
  notRunnable: "Not runnable",
89
126
  notTriggered: "Not started",
127
+ skipped: "Skipped",
128
+ refused: "Refused",
129
+ deferred: "Deferred, waiting for room",
130
+ };
131
+ const STATUS_MARKS: Record<string, string> = {
132
+ completed: "✓", failed: "✕", startFailed: "✕", notRunnable: "✕", refused: "✕", skipped: "–", deferred: "…", started: "…", starting: "…", running: "…",
90
133
  };
91
- const STATUS_MARKS: Record<string, string> = { completed: "✓", failed: "✕", startFailed: "✕", notRunnable: "✕", started: "…", starting: "…", running: "…" };
92
134
 
93
135
  export function statusText(status: string | null | undefined): string {
94
136
  return status ? (STATUS_WORDS[status] ?? status) : "No runs";
@@ -104,6 +146,56 @@ export function durationText(seconds: number | null | undefined): string {
104
146
  return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
105
147
  }
106
148
 
149
+ export function dollars(value: number): string {
150
+ return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`;
151
+ }
152
+
153
+ /** "Cost: $0.04 (estimated ...)" or "Cost: unknown (why)", with the advisory budget flagged in words. */
154
+ export function costText(run: Pick<AutomationRun, "costUsd" | "costNote" | "overBudget"> & { budgetUsd?: number | null }): string {
155
+ const base = run.costUsd === null ? `Cost: unknown (${run.costNote})` : `Cost: ${dollars(run.costUsd)} (${run.costNote})`;
156
+ return run.overBudget && run.budgetUsd != null ? `${base}. Over the budget of ${dollars(run.budgetUsd)} per run` : base;
157
+ }
158
+
159
+ export function whoText(run: Pick<AutomationRun, "origin" | "requestedBy" | "sourceId">): string {
160
+ return run.origin === "manual" ? `Run now by ${run.requestedBy ?? "someone"}` : `Item: ${run.sourceId ?? "unknown"}`;
161
+ }
162
+
163
+ export const LIMITS_NOTE = "the timeout and the number at a time (when set) are enforced when a run starts; the budget is advisory and only flagged";
164
+
165
+ export function atATimeText(limits: { maxConcurrent?: number }): string {
166
+ return limits.maxConcurrent === undefined ? "no limit on how many at a time" : `${limits.maxConcurrent} at a time`;
167
+ }
168
+
169
+ export type RunNowResult =
170
+ | { state: "started"; automationId: string; requestedBy: string }
171
+ | { state: "refused"; reason: string }
172
+ | { state: "unavailable" }
173
+ | { error: "forbidden" | "bad_request" };
174
+
175
+ export function runNowMessage(result: RunNowResult): string {
176
+ if ("error" in result) return result.error === "forbidden" ? "Run now is for editors and admins." : "The request was not understood.";
177
+ if (result.state === "started") return "Started. The run appears in the list below within a minute; the page refreshes by itself.";
178
+ if (result.state === "refused") return `Not started: ${result.reason}`;
179
+ return "Not started: the request could not be completed right now. Nothing was started.";
180
+ }
181
+
182
+ /** The input a person typed for Run now: a JSON object, or nothing (the record's own input is used). */
183
+ export function parseRunInput(text: string): { ok: true; value: Record<string, unknown> | null } | { ok: false; message: string } {
184
+ if (!text.trim()) return { ok: true, value: null };
185
+ try {
186
+ const parsed = JSON.parse(text);
187
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { ok: false, message: "The input must be a JSON object, for example {\"name\": \"value\"}." };
188
+ return { ok: true, value: parsed as Record<string, unknown> };
189
+ } catch {
190
+ return { ok: false, message: "The input is not valid JSON." };
191
+ }
192
+ }
193
+
194
+ /** A starting point for the input box: the record's input names with empty values. */
195
+ export function inputTemplateText(names: string[]): string {
196
+ return JSON.stringify(Object.fromEntries(names.map((name) => [name, ""])), null, 2);
197
+ }
198
+
107
199
  const LINK_WORDS: Record<AutomationLink["state"], string> = {
108
200
  linked: "Linked: the router's trigger matches this record.",
109
201
  input_differs: "Differs: the router's input template is not the same as this record's.",
@@ -125,6 +217,8 @@ const NOTICE_WORDS: Record<string, string> = {
125
217
  "not yet supported": "Only monitor automations run today. This one is stored but nothing starts it.",
126
218
  };
127
219
 
220
+ export const OUTSIDE_NOTICE = "The procedure's other runs could not be read right now.";
221
+
128
222
  export function noticeText(notice: AutomationNotice | null): string | null {
129
223
  return notice ? (NOTICE_WORDS[notice] ?? notice) : null;
130
224
  }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import { getCurrentUser } from "aws-amplify/auth";
5
+ import {
6
+ answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
7
+ type AnswerInput, type ReviewRequest,
8
+ } from "./review-requests";
9
+
10
+ /**
11
+ * Review requests through the site's own authenticated API (user pool; editors and admins). The condition `state = open`
12
+ * on every change means two people answering at once, or an answer racing the deadline, cannot both win.
13
+ */
14
+
15
+ const FIELDS = "id subject mode reason ask raisedBy raisedAt deadline state answer links history appliedIn updatedAt";
16
+ const MAX_PAGES = 10;
17
+
18
+ type GraphQLResult = { data?: Record<string, any>; errors?: { message?: string; errorType?: string }[] };
19
+
20
+ async function run(query: string, variables: Record<string, unknown>): Promise<GraphQLResult> {
21
+ const client = generateClient({ authMode: "userPool" });
22
+ try {
23
+ return (await client.graphql({ query, variables })) as GraphQLResult;
24
+ } catch (error) {
25
+ // Amplify throws the response when AppSync returns errors.
26
+ return (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
27
+ }
28
+ }
29
+
30
+ export type ReviewLoad = { state: "ready"; requests: ReviewRequest[]; readIds: string[]; userKey: string } | { state: "forbidden" } | { state: "unavailable" };
31
+
32
+ export async function currentUserKey(): Promise<string> {
33
+ return (await getCurrentUser()).userId;
34
+ }
35
+
36
+ /** Loads every request (newest pages first), marks due soft reviews as timed out ("proceeded without review"), and reads this user's marks. */
37
+ export async function loadReviews(now: Date = new Date()): Promise<ReviewLoad> {
38
+ let userKey: string;
39
+ try {
40
+ userKey = await currentUserKey();
41
+ } catch {
42
+ return { state: "unavailable" };
43
+ }
44
+ const rows: Record<string, unknown>[] = [];
45
+ let nextToken: string | null = null;
46
+ for (let page = 0; page < MAX_PAGES; page += 1) {
47
+ const result = await run(
48
+ `query ListReviews($limit: Int, $nextToken: String) { listReviewRequests(limit: $limit, nextToken: $nextToken) { items { ${FIELDS} } nextToken } }`,
49
+ { limit: 500, nextToken },
50
+ );
51
+ if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) return { state: "forbidden" };
52
+ const connection = result.data?.listReviewRequests;
53
+ if (result.errors?.length || !connection) return { state: "unavailable" };
54
+ rows.push(...connection.items.filter(Boolean));
55
+ nextToken = connection.nextToken ?? null;
56
+ if (!nextToken) break;
57
+ }
58
+ const requests = rows.map(normalizeReviewRow);
59
+ for (const [index, request] of requests.entries()) {
60
+ if (!isDue(request, now)) continue;
61
+ const update = timeoutUpdate(request, now);
62
+ const written = await run(
63
+ `mutation Expire($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
64
+ { input: update, condition: { state: { eq: "open" } } },
65
+ );
66
+ const row = written.data?.updateReviewRequest;
67
+ requests[index] = row ? normalizeReviewRow(row) : { ...request, state: "timed_out", updatedAt: String(update.updatedAt), history: JSON.parse(String(update.history)) };
68
+ }
69
+ const marks = await run(
70
+ `query Marks($filter: ModelReviewReadMarkFilterInput, $limit: Int) { listReviewReadMarks(filter: $filter, limit: $limit) { items { requestId } } }`,
71
+ { filter: { userKey: { eq: userKey } }, limit: 5000 },
72
+ );
73
+ const readIds = (marks.data?.listReviewReadMarks?.items ?? []).filter(Boolean).map((mark: { requestId: string }) => mark.requestId);
74
+ return { state: "ready", requests, readIds, userKey };
75
+ }
76
+
77
+ /** Records an answer. Throws an Error with a sentence for the page when the request is closed or the write loses a race. */
78
+ export async function answerReviewRequest(request: ReviewRequest, input: AnswerInput, by: string, now: Date = new Date()): Promise<ReviewRequest> {
79
+ const update = answerUpdate(request, input, by, now);
80
+ const result = await run(
81
+ `mutation Answer($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
82
+ { input: update, condition: { state: { eq: "open" } } },
83
+ );
84
+ if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
85
+ throw new Error("Someone else answered this, or it proceeded at its deadline. Refresh to see the result.");
86
+ }
87
+ if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("Could not save the answer. Nothing was changed.");
88
+ return normalizeReviewRow(result.data.updateReviewRequest);
89
+ }
90
+
91
+ /** This user has seen the request. Create the mark; if it exists, refresh it. */
92
+ export async function markReviewRead(userKey: string, requestId: string, now: Date = new Date()): Promise<void> {
93
+ const input = { id: `${userKey}#${requestId}`, requestId, userKey, readAt: now.toISOString() };
94
+ const created = await run(
95
+ `mutation Mark($input: CreateReviewReadMarkInput!) { createReviewReadMark(input: $input) { id } }`, { input });
96
+ if (created.errors?.length) {
97
+ await run(`mutation Mark($input: UpdateReviewReadMarkInput!) { updateReviewReadMark(input: $input) { id } }`, { input });
98
+ }
99
+ }
@@ -0,0 +1,45 @@
1
+ import type { ReviewRequest } from "./review-requests";
2
+
3
+ /** Demo and test data: one of each mode, a timed-out soft review, and an answered one whose steering was applied. */
4
+ export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
5
+ {
6
+ id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Approve this ruling summary before it is published",
7
+ ask: "Check the holding and the quoted passages.", raisedBy: "distinctive.ttab.ruling-analysis", raisedAt: "2026-10-23T08:00:00Z", deadline: null,
8
+ state: "open", answer: null, links: [], history: [{ at: "2026-10-23T08:00:00Z", event: "raised", by: "distinctive.ttab.ruling-analysis", note: "hard_gate" }],
9
+ appliedIn: null, updatedAt: "2026-10-23T08:00:00Z",
10
+ },
11
+ {
12
+ id: "rr-soft-2", subject: { type: "batch", id: "2026-W43" }, mode: "soft_review", reason: "3 new story ideas to review",
13
+ ask: "Approve to keep them all, reject to drop the batch, or steer with a note.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
14
+ deadline: "2026-10-30T07:00:00Z", state: "open", answer: null,
15
+ links: [{ type: "proposal", id: "proposal-1", label: "story idea 1" }, { type: "proposal", id: "proposal-2", label: "story idea 2" }, { type: "proposal", id: "proposal-3", label: "story idea 3" }],
16
+ history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
17
+ },
18
+ {
19
+ id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend story draft is ready",
20
+ ask: null, raisedBy: "trend-pipeline", raisedAt: "2026-10-22T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "open", answer: null,
21
+ links: [], history: [], appliedIn: null, updatedAt: "2026-10-22T07:00:00Z",
22
+ },
23
+ {
24
+ id: "rr-flag-1", subject: { type: "reference", id: "ref-1" }, mode: "flag", reason: "Two sources disagree about the filing date",
25
+ ask: null, raisedBy: "reference-intake", raisedAt: "2026-10-22T10:00:00Z", deadline: null, state: "open", answer: null, links: [], history: [],
26
+ appliedIn: null, updatedAt: "2026-10-22T10:00:00Z",
27
+ },
28
+ {
29
+ id: "rr-late-1", subject: { type: "story", id: "story-old" }, mode: "soft_review", reason: "Check the headline", ask: null, raisedBy: "copy-editor",
30
+ raisedAt: "2026-10-10T07:00:00Z", deadline: "2026-10-17T07:00:00Z", state: "timed_out", answer: null, links: [],
31
+ history: [{ at: "2026-10-17T07:00:00Z", event: "timed_out", by: "system", note: "proceeded without review" }], appliedIn: null, updatedAt: "2026-10-17T07:00:00Z",
32
+ },
33
+ {
34
+ id: "rr-done-1", subject: { type: "batch", id: "2026-W42" }, mode: "soft_review", reason: "4 new story ideas to review", ask: null, raisedBy: "idea-pipeline",
35
+ raisedAt: "2026-10-16T07:00:00Z", deadline: "2026-10-23T07:00:00Z", state: "answered",
36
+ answer: { decision: "feedback", feedback: "Fewer listicles", steer: "not_this_kind", by: "Ed Itor", at: "2026-10-17T09:00:00Z" },
37
+ links: [], history: [], appliedIn: { runId: "run-w43", by: "idea-pipeline", at: "2026-10-20T07:00:00Z" }, updatedAt: "2026-10-17T09:00:00Z",
38
+ },
39
+ {
40
+ id: "rr-done-2", subject: { type: "proposal", id: "proposal-4" }, mode: "soft_review", reason: "A concept rename was proposed", ask: null, raisedBy: "concept-bot",
41
+ raisedAt: "2026-10-18T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "answered",
42
+ answer: { decision: "feedback", feedback: "Keep the old name for sports", steer: "adjust", by: "Ed Itor", at: "2026-10-19T09:00:00Z" },
43
+ links: [], history: [], appliedIn: null, updatedAt: "2026-10-19T09:00:00Z",
44
+ },
45
+ ];
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The Notification Center's model (PPY-1cd261, PPY-ab6a50): review requests, the Cyclotron items that wait for a person,
3
+ * the auto-decided stream, and the words the page says about each. Pure functions only, so the page and its tests share them.
4
+ *
5
+ * Principle (docs/notification-center.md): everything except a hard gate keeps moving if nobody acts. Each entry says in words
6
+ * what happens next without the reader, and only a hard gate says "Waiting for you".
7
+ */
8
+ import type { ReferenceRecord } from "./category-repository";
9
+ import { getNewsroomNavHref } from "./newsroom-nav";
10
+ import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
11
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
12
+ import type { RelevanceDecisionView } from "./relevance-decisions";
13
+
14
+ export type ReviewMode = "flag" | "soft_review" | "hard_gate";
15
+ export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
16
+ export type ReviewDecision = "approve" | "reject" | "feedback";
17
+ export type ReviewSteer = "adjust" | "not_this_kind";
18
+
19
+ export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string };
20
+ export type ReviewHistoryEntry = { at: string; event: string; by?: string; note?: string };
21
+ export type ReviewLink = { label?: string; url?: string; type?: string; id?: string };
22
+
23
+ export type ReviewRequest = {
24
+ id: string;
25
+ subject: { type: string; id: string };
26
+ mode: ReviewMode;
27
+ reason: string;
28
+ ask: string | null;
29
+ raisedBy: string;
30
+ raisedAt: string;
31
+ deadline: string | null;
32
+ state: ReviewState;
33
+ answer: ReviewAnswer | null;
34
+ links: ReviewLink[];
35
+ history: ReviewHistoryEntry[];
36
+ appliedIn: { runId: string; by: string; at: string; note?: string | null } | null;
37
+ updatedAt: string;
38
+ };
39
+
40
+ export const PROCEEDED_WITHOUT_REVIEW = "proceeded without review";
41
+ const MAX_HISTORY = 50;
42
+
43
+ function parseJson<T>(value: unknown, fallback: T): T {
44
+ if (typeof value === "string") {
45
+ try {
46
+ return JSON.parse(value) as T;
47
+ } catch {
48
+ return fallback;
49
+ }
50
+ }
51
+ return value === null || value === undefined ? fallback : (value as T);
52
+ }
53
+
54
+ /** A row from the API (JSON fields arrive as strings) as a request. */
55
+ export function normalizeReviewRow(row: Record<string, unknown>): ReviewRequest {
56
+ return {
57
+ id: String(row.id),
58
+ subject: parseJson(row.subject, { type: "unknown", id: "" }),
59
+ mode: row.mode as ReviewMode,
60
+ reason: String(row.reason ?? ""),
61
+ ask: typeof row.ask === "string" ? row.ask : null,
62
+ raisedBy: String(row.raisedBy ?? ""),
63
+ raisedAt: String(row.raisedAt ?? ""),
64
+ deadline: typeof row.deadline === "string" ? row.deadline : null,
65
+ state: row.state as ReviewState,
66
+ answer: parseJson<ReviewAnswer | null>(row.answer, null),
67
+ links: parseJson<ReviewLink[]>(row.links, []),
68
+ history: parseJson<ReviewHistoryEntry[]>(row.history, []),
69
+ appliedIn: parseJson(row.appliedIn, null),
70
+ updatedAt: String(row.updatedAt ?? row.raisedAt ?? ""),
71
+ };
72
+ }
73
+
74
+ // --- the deadline (checked on read) ---------------------------------------------------------------------
75
+
76
+ export function isDue(request: ReviewRequest, now: Date): boolean {
77
+ return request.mode === "soft_review" && request.state === "open" && request.deadline !== null && new Date(request.deadline).getTime() <= now.getTime();
78
+ }
79
+
80
+ /** The update that marks a due soft review as timed out, "proceeded without review". Applied with the condition state = open. */
81
+ export function timeoutUpdate(request: ReviewRequest, now: Date): Record<string, unknown> {
82
+ const at = now.toISOString();
83
+ const history = [...request.history, { at, event: "timed_out", by: "system", note: PROCEEDED_WITHOUT_REVIEW }].slice(-MAX_HISTORY);
84
+ return { id: request.id, state: "timed_out", updatedAt: at, history: JSON.stringify(history) };
85
+ }
86
+
87
+ export type AnswerInput = { decision: ReviewDecision; feedback?: string | null; steer?: ReviewSteer | null };
88
+
89
+ /** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
90
+ export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
91
+ if (request.state !== "open") throw new Error(`This request is ${request.state.replace("_", " ")}${request.state === "timed_out" ? ` (${PROCEEDED_WITHOUT_REVIEW})` : ""} and can no longer be answered.`);
92
+ const feedback = input.feedback?.trim() || null;
93
+ const steer = input.steer ?? null;
94
+ if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
95
+ const at = now.toISOString();
96
+ const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at };
97
+ const history = [...request.history, { at, event: "answered", by, note: input.decision }].slice(-MAX_HISTORY);
98
+ return { id: request.id, state: "answered", answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
99
+ }
100
+
101
+ // --- words ------------------------------------------------------------------------------------------------
102
+
103
+ export function formatWhen(iso: string | null | undefined): string {
104
+ if (!iso) return "";
105
+ const date = new Date(iso);
106
+ if (Number.isNaN(date.getTime())) return "";
107
+ return `${date.toISOString().slice(0, 10)} ${date.toISOString().slice(11, 16)} UTC`;
108
+ }
109
+
110
+ export function modeLabel(mode: ReviewMode): string {
111
+ return mode === "hard_gate" ? "Hard gate" : mode === "soft_review" ? "Soft review" : "Flag";
112
+ }
113
+
114
+ /** A mark and the words, never colour alone. */
115
+ export function modeMark(mode: ReviewMode): string {
116
+ return mode === "hard_gate" ? "■" : mode === "soft_review" ? "◐" : "⚑";
117
+ }
118
+
119
+ /** What happens next without the reader. Only a hard gate says "Waiting for you". */
120
+ export function nextText(request: ReviewRequest): string {
121
+ if (request.state === "open") {
122
+ if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you approve or reject.";
123
+ if (request.mode === "soft_review") return `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.`;
124
+ return "Nothing is waiting on this. It stays here until you acknowledge it.";
125
+ }
126
+ if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
127
+ if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
128
+ const answer = request.answer;
129
+ return answer ? `${answerWords(answer)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
130
+ }
131
+
132
+ export function answerWords(answer: ReviewAnswer): string {
133
+ if (answer.steer === "not_this_kind") return "Steered: not this kind of thing";
134
+ if (answer.decision === "approve") return "Approved";
135
+ if (answer.decision === "reject") return "Rejected";
136
+ return "Steered with a note";
137
+ }
138
+
139
+ /** "You steered X, applied in run Y": what became of a person's input. */
140
+ export function steeringText(request: ReviewRequest): string | null {
141
+ const answer = request.answer;
142
+ if (request.state !== "answered" || !answer || !(answer.feedback || answer.steer || answer.decision === "reject")) return null;
143
+ const said = answer.feedback ? `"${answer.feedback}"` : answerWords(answer).toLowerCase();
144
+ if (request.appliedIn) return `You steered ${said}. Applied in run ${request.appliedIn.runId} on ${formatWhen(request.appliedIn.at)}.`;
145
+ return `You steered ${said}. ${request.raisedBy} has not used it yet; it will on its next run.`;
146
+ }
147
+
148
+ export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
149
+ const id = encodeURIComponent(subject.id);
150
+ if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
151
+ if (subject.type === "reference" || subject.type === "cyclotron_decision") return getNewsroomNavHref(`/newsroom/references/${id}`, demo);
152
+ if (subject.type === "proposal") return getNewsroomNavHref(`/newsroom/topics?proposal=${id}`, demo);
153
+ if (subject.type === "automation_run") return getNewsroomNavHref("/newsroom/administration/automations", demo);
154
+ return null;
155
+ }
156
+
157
+ // --- Cyclotron items ---------------------------------------------------------------------------------------
158
+
159
+ export type CyclotronEntry = {
160
+ lineageId: string;
161
+ title: string;
162
+ label: string;
163
+ confidence: number | null;
164
+ decidedAt: string | null;
165
+ reviewDetail: string | null;
166
+ reviewedByPerson: boolean;
167
+ href: string;
168
+ };
169
+
170
+ function cyclotronEntry(reference: ReferenceRecord, decision: RelevanceDecisionView, reviewed: ReadonlySet<string>, demo: boolean): CyclotronEntry {
171
+ const lineageId = referenceLineageId(reference);
172
+ return {
173
+ lineageId, title: referenceDisplayTitle(reference), label: decision.label, confidence: decision.confidence,
174
+ decidedAt: decision.decidedAt, reviewDetail: decision.reviewDetail, reviewedByPerson: reviewed.has(lineageId),
175
+ href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo),
176
+ };
177
+ }
178
+
179
+ /** Items the Cyclotron routed to a person (the References tab's own rule: review recommended and still pending). Least confident first. */
180
+ export function cyclotronHeldForReview(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, demo = false): CyclotronEntry[] {
181
+ const entries: CyclotronEntry[] = [];
182
+ for (const reference of references) {
183
+ const decision = decisions.get(referenceLineageId(reference));
184
+ if (decision?.reviewRecommended && normalizeReferenceCurationStatus(reference.curationStatus) === "pending") {
185
+ entries.push(cyclotronEntry(reference, decision, new Set(), demo));
186
+ }
187
+ }
188
+ return entries.sort((a, b) => (a.confidence ?? 1) - (b.confidence ?? 1));
189
+ }
190
+
191
+ /** Recent classifications the Cyclotron made on its own confidence. Read only here; correcting happens on the reference. Newest first. */
192
+ export function cyclotronAutoDecided(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, reviewed: ReadonlySet<string>, demo = false, limit = 20): CyclotronEntry[] {
193
+ const entries: CyclotronEntry[] = [];
194
+ for (const reference of references) {
195
+ const decision = decisions.get(referenceLineageId(reference));
196
+ if (decision && !decision.reviewRecommended) entries.push(cyclotronEntry(reference, decision, reviewed, demo));
197
+ }
198
+ return entries.sort((a, b) => (b.decidedAt ?? "").localeCompare(a.decidedAt ?? "")).slice(0, limit);
199
+ }
200
+
201
+ // --- the page's shape -------------------------------------------------------------------------------------------
202
+
203
+ export type NotificationSections = {
204
+ hardGates: ReviewRequest[];
205
+ softReviews: ReviewRequest[];
206
+ flags: ReviewRequest[];
207
+ /** Answered, timed out or withdrawn, newest first: what people said and what became of it. */
208
+ decided: ReviewRequest[];
209
+ };
210
+
211
+ export function sectionReviews(requests: ReviewRequest[]): NotificationSections {
212
+ const open = requests.filter((request) => request.state === "open");
213
+ const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
214
+ return {
215
+ hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
216
+ softReviews: open.filter((request) => request.mode === "soft_review").sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? "")),
217
+ flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
218
+ decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
219
+ };
220
+ }
221
+
222
+ export type NotificationSummary = { waiting: number; proceedingSoon: number; decidedBySystem: number; unread: number };
223
+
224
+ /**
225
+ * "N waiting for you · N proceeding soon · N recently decided by the system".
226
+ * Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). Proceeding soon = open soft reviews.
227
+ */
228
+ export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
229
+ return { waiting: sections.hardGates.length + held.length, proceedingSoon: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
230
+ }
231
+
232
+ export function summaryText(summary: NotificationSummary): string {
233
+ return `${summary.waiting} waiting for you · ${summary.proceedingSoon} proceeding soon · ${summary.decidedBySystem} recently decided by the system`;
234
+ }
235
+
236
+ export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
237
+ return !readIds.has(request.id);
238
+ }
package/package.json CHANGED
@@ -76,6 +76,7 @@
76
76
  "./components/archive-shell": "./components/archive-shell.tsx",
77
77
  "./components/article-video": "./components/article-video.tsx",
78
78
  "./components/automation-panel": "./components/automation-panel.tsx",
79
+ "./components/automation-run-now": "./components/automation-run-now.tsx",
79
80
  "./components/automation-view": "./components/automation-view.tsx",
80
81
  "./components/blog-page-background": "./components/blog-page-background.tsx",
81
82
  "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
@@ -114,6 +115,7 @@
114
115
  "./components/newsroom-theme-fonts/refined-newsprint": "./components/newsroom-theme-fonts/refined-newsprint.tsx",
115
116
  "./components/newsroom-theme": "./components/newsroom-theme.tsx",
116
117
  "./components/newsroom-topics-view": "./components/newsroom-topics-view.tsx",
118
+ "./components/notification-center": "./components/notification-center.tsx",
117
119
  "./components/papyrus-console-shell": "./components/papyrus-console-shell.tsx",
118
120
  "./components/pictogram-figure": "./components/pictogram-figure.tsx",
119
121
  "./components/presentation-footer": "./components/presentation-footer.tsx",
@@ -304,6 +306,9 @@
304
306
  "./lib/reporting-story-budget": "./lib/reporting-story-budget.ts",
305
307
  "./lib/revalidate-prewarm": "./lib/revalidate-prewarm.ts",
306
308
  "./lib/revalidate-secret": "./lib/revalidate-secret.ts",
309
+ "./lib/review-client": "./lib/review-client.ts",
310
+ "./lib/review-fixture": "./lib/review-fixture.ts",
311
+ "./lib/review-requests": "./lib/review-requests.ts",
307
312
  "./lib/robots-policy": "./lib/robots-policy.ts",
308
313
  "./lib/root-route-redirect": "./lib/root-route-redirect.ts",
309
314
  "./lib/semantic-graph": "./lib/semantic-graph.ts",
@@ -439,7 +444,7 @@
439
444
  "optional": true
440
445
  }
441
446
  },
442
- "version": "5.32.0",
447
+ "version": "5.33.0",
443
448
  "dependencies": {
444
449
  "@aws-amplify/adapter-nextjs": "^1.7.3",
445
450
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.32.0",
2
+ "version": "5.33.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",