@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41
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.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +16 -8
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/content-actions/requirements.txt +2 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/auto-accepted-badge.tsx +21 -0
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +174 -0
- package/components/decision-control.tsx +306 -0
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/markdown-context.tsx +412 -0
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +302 -0
- package/components/needs-me-detail.tsx +24 -0
- package/components/needs-me-feed.tsx +594 -0
- package/components/needs-me-reference-detail.tsx +79 -0
- package/components/needs-me-shared.tsx +48 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +253 -31
- package/components/newsroom-access-gate.tsx +13 -20
- package/components/newsroom-board-skeleton.tsx +20 -0
- package/components/newsroom-board-view.tsx +2 -6
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +88 -73
- package/components/newsroom-ops-shell.tsx +8 -28
- package/components/newsroom-organizer.css +127 -3
- package/components/newsroom-references-view.tsx +334 -158
- package/components/notification-cards.tsx +370 -0
- package/components/notification-center.tsx +376 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-client.ts +138 -0
- package/components/reference-capture-panel.tsx +268 -0
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +514 -0
- package/components/reference-review-card.tsx +62 -197
- package/components/reference-source-preview.tsx +27 -5
- package/components/reference-stories.tsx +34 -0
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/story-reporting-trail-client.ts +45 -8
- package/components/story-reporting-trail.tsx +24 -13
- package/components/topic-steering-workspace.tsx +50 -439
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-conversation.ts +84 -0
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +189 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +20 -0
- package/lib/conversation.ts +154 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-seed.ts +24 -2
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-all-pages.ts +21 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/newsroom-references.ts +25 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +294 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +236 -0
- package/lib/reference-policy.ts +90 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-client.ts +71 -5
- package/lib/review-drafts.ts +277 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +89 -24
- package/lib/site-brand.ts +2 -0
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/lib/story-reporting-trail.ts +33 -2
- package/package.json +45 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the "What needs me" feed is ordered, and which items the Cyclotron is confident enough about to decide on its own.
|
|
3
|
+
*
|
|
4
|
+
* The feed holds several kinds of item (a Reference, an AI draft, a story review, a held email, a capture that may be cut off, a flag).
|
|
5
|
+
* What they share is one question: where would a person's answer change the most? In plain words, as the page says them:
|
|
6
|
+
* 1. Hard gates first. Nothing continues until a person decides one, so it leads, oldest first.
|
|
7
|
+
* 2. Then the interim "value of your input" score:
|
|
8
|
+
* value = uncertainty x importance x freshness
|
|
9
|
+
* - uncertainty is 1 - |2P - 1|, where P is the Cyclotron's probability that you would Accept. A coin toss (P = 0.5) is 1, a
|
|
10
|
+
* certainty is 0. An item with no prediction is 0.5: we do not know how unsure we are.
|
|
11
|
+
* - importance is the item's own priority (a relevance score, or a story or section priority). It is 1 until a source gives one.
|
|
12
|
+
* - freshness is exp(-age / tau), the weight the References feed already uses; tau is learned from how old the References you
|
|
13
|
+
* accepted were when you accepted them (reference-feed-ranking.ts). With too few examples there is no decay.
|
|
14
|
+
* 3. Types are interleaved, never more than two of one type in a row while another type is waiting, so a long run of References
|
|
15
|
+
* does not bury the drafts and emails behind it.
|
|
16
|
+
* 4. Confident items do not ask for you. An item whose uncertainty is below the threshold goes to "Decided automatically", where it
|
|
17
|
+
* can be overruled. The threshold is the median uncertainty of the predictions you have historically confirmed; with fewer than
|
|
18
|
+
* 10 such examples there is no threshold and nothing is skipped. A hard gate, an item the Cyclotron itself sent to review, and
|
|
19
|
+
* an item with no prediction are never skipped.
|
|
20
|
+
*
|
|
21
|
+
* The scoring sits behind one interface, `AttentionModel`. A future Cyclotron "attention" decision (a model that predicts the value of
|
|
22
|
+
* an editor's input directly) replaces `interimAttention` and nothing else in the page changes.
|
|
23
|
+
*/
|
|
24
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
25
|
+
import { referenceLineageId } from "./newsroom-references";
|
|
26
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
27
|
+
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
28
|
+
import { acceptProbability, MINIMUM_FRESHNESS_EXAMPLES, type FeedFreshness } from "./reference-feed-ranking";
|
|
29
|
+
|
|
30
|
+
export type NeedsMeType = "reference" | "draft" | "stage_review" | "held_email" | "incomplete_capture" | "concept_proposal" | "flag";
|
|
31
|
+
|
|
32
|
+
export const NEEDS_ME_TYPES: readonly NeedsMeType[] = ["reference", "draft", "stage_review", "held_email", "incomplete_capture", "concept_proposal", "flag"];
|
|
33
|
+
|
|
34
|
+
/** The pills above the feed. */
|
|
35
|
+
export type NeedsMeFilter = "all" | "references" | "drafts" | "stories" | "email" | "captures" | "other";
|
|
36
|
+
export const NEEDS_ME_FILTERS: readonly NeedsMeFilter[] = ["all", "references", "drafts", "stories", "email", "captures", "other"];
|
|
37
|
+
export const NEEDS_ME_FILTER_LABELS: Record<NeedsMeFilter, string> = {
|
|
38
|
+
all: "All", references: "References", drafts: "Drafts", stories: "Stories", email: "Email", captures: "Captures", other: "Other",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const FILTER_OF: Record<NeedsMeType, Exclude<NeedsMeFilter, "all">> = {
|
|
42
|
+
reference: "references", draft: "drafts", stage_review: "stories", held_email: "email", incomplete_capture: "captures", concept_proposal: "other", flag: "other",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function filterOfType(type: NeedsMeType): Exclude<NeedsMeFilter, "all"> {
|
|
46
|
+
return FILTER_OF[type];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function matchesFilter(item: Pick<NeedsMeItem, "type">, filter: NeedsMeFilter): boolean {
|
|
50
|
+
return filter === "all" || FILTER_OF[item.type] === filter;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The words for a type, used on the card and in the detail pane. */
|
|
54
|
+
export const NEEDS_ME_TYPE_LABELS: Record<NeedsMeType, string> = {
|
|
55
|
+
reference: "Reference", draft: "AI draft", stage_review: "Story review", held_email: "Held email", incomplete_capture: "Capture check", concept_proposal: "Concept proposal", flag: "For your information",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type NeedsMePrediction = {
|
|
59
|
+
label: "accept" | "reject";
|
|
60
|
+
/** P(the editor accepts). */
|
|
61
|
+
pAccept: number;
|
|
62
|
+
/** The confidence the Cyclotron recorded, when it did. */
|
|
63
|
+
confidence: number | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Open until a person decides; the three answers are the page's one vocabulary (a flag is acknowledged). */
|
|
67
|
+
export type NeedsMeDecisionState = "open" | "accepted" | "rejected" | "acknowledged";
|
|
68
|
+
|
|
69
|
+
export type NeedsMeItem<Payload = unknown> = {
|
|
70
|
+
type: NeedsMeType;
|
|
71
|
+
/** Unique across types. */
|
|
72
|
+
id: string;
|
|
73
|
+
title: string;
|
|
74
|
+
dek: string | null;
|
|
75
|
+
/** The picture for the card, or null for the source's fallback tile. */
|
|
76
|
+
image: string | null;
|
|
77
|
+
source: string;
|
|
78
|
+
host: string | null;
|
|
79
|
+
/** When it reached the newsroom, in milliseconds; null when unknown. */
|
|
80
|
+
createdAt: number | null;
|
|
81
|
+
prediction?: NeedsMePrediction;
|
|
82
|
+
/** The item's own priority. 1 until a source supplies one (a relevance score, a story or section priority). */
|
|
83
|
+
importance: number;
|
|
84
|
+
/** 1 - |2P - 1| with a prediction, 0.5 without. */
|
|
85
|
+
uncertainty: number;
|
|
86
|
+
decisionState: NeedsMeDecisionState;
|
|
87
|
+
/** What happens if the editor ignores it, in words. */
|
|
88
|
+
defaultOutcome: string;
|
|
89
|
+
/** Nothing continues until a person decides it. */
|
|
90
|
+
hardGate: boolean;
|
|
91
|
+
/** The Cyclotron or the step that raised it asked for a person: never decided automatically. */
|
|
92
|
+
askedForReview: boolean;
|
|
93
|
+
payload: Payload;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// --- the scoring --------------------------------------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export const UNKNOWN_UNCERTAINTY = 0.5;
|
|
99
|
+
|
|
100
|
+
/** 1 - |2P - 1|: 1 at a coin toss, 0 at a certainty. 0.5 when there is no prediction. */
|
|
101
|
+
export function uncertaintyOf(pAccept: number | undefined): number {
|
|
102
|
+
if (pAccept === undefined || !Number.isFinite(pAccept)) return UNKNOWN_UNCERTAINTY;
|
|
103
|
+
return 1 - Math.abs(2 * Math.min(1, Math.max(0, pAccept)) - 1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function predictionOf(decision: RelevanceDecisionView | undefined): NeedsMePrediction | undefined {
|
|
107
|
+
if (!decision) return undefined;
|
|
108
|
+
const pAccept = acceptProbability(decision);
|
|
109
|
+
return { label: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", pAccept, confidence: decision.confidence };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DAY_MILLISECONDS = 86_400_000;
|
|
113
|
+
|
|
114
|
+
/** exp(-age / tau) at `now`; 1 without a learned tau or an unknown arrival. The References feed's own weight. */
|
|
115
|
+
export function freshnessAt(createdAt: number | null, freshness: FeedFreshness, now: number): number {
|
|
116
|
+
if (freshness.tauDays === null || createdAt === null) return 1;
|
|
117
|
+
return Math.exp(-Math.max(now - createdAt, 0) / DAY_MILLISECONDS / freshness.tauDays);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type AttentionContext = { now: number; freshness: FeedFreshness };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The value of this editor's input on one item. Higher is earlier. The interim model below is a formula; a Cyclotron "attention"
|
|
124
|
+
* decision implements the same interface and is passed to `rankNeedsMe` in its place.
|
|
125
|
+
*/
|
|
126
|
+
export interface AttentionModel {
|
|
127
|
+
readonly id: string;
|
|
128
|
+
value(item: NeedsMeItem, context: AttentionContext): number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const interimAttention: AttentionModel = {
|
|
132
|
+
id: "interim-value-of-input",
|
|
133
|
+
value: (item, { now, freshness }) => item.uncertainty * item.importance * freshnessAt(item.createdAt, freshness, now),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// --- confident items ----------------------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export const MINIMUM_CONFIRMED_EXAMPLES = MINIMUM_FRESHNESS_EXAMPLES;
|
|
139
|
+
|
|
140
|
+
export type ConfidenceThreshold = {
|
|
141
|
+
/** Null with fewer than MINIMUM_CONFIRMED_EXAMPLES confirmed predictions: nothing is guessed, nothing is skipped. */
|
|
142
|
+
threshold: number | null;
|
|
143
|
+
/** How many predictions the editor has confirmed. */
|
|
144
|
+
examples: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Uncertainty below which the Cyclotron decides without you: the median uncertainty of the predictions you confirmed (the Reference you
|
|
149
|
+
* accepted or rejected agrees with what the Cyclotron said). Half of what you tend to confirm is then confident enough to skip.
|
|
150
|
+
* `ignore` keeps the item an editor just rated from moving the very feed they are looking at.
|
|
151
|
+
*/
|
|
152
|
+
export function learnConfidenceThreshold(
|
|
153
|
+
references: ReferenceRecord[],
|
|
154
|
+
decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
|
|
155
|
+
ignore: ReadonlySet<string> = new Set(),
|
|
156
|
+
): ConfidenceThreshold {
|
|
157
|
+
const confirmed: number[] = [];
|
|
158
|
+
for (const reference of references) {
|
|
159
|
+
const lineageId = referenceLineageId(reference);
|
|
160
|
+
const decision = decisions?.get(lineageId);
|
|
161
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
162
|
+
if (!decision || ignore.has(lineageId) || (status !== "accepted" && status !== "rejected")) continue;
|
|
163
|
+
const prediction = predictionOf(decision);
|
|
164
|
+
if (prediction && prediction.label === (status === "accepted" ? "accept" : "reject")) confirmed.push(uncertaintyOf(prediction.pAccept));
|
|
165
|
+
}
|
|
166
|
+
if (confirmed.length < MINIMUM_CONFIRMED_EXAMPLES) return { threshold: null, examples: confirmed.length };
|
|
167
|
+
confirmed.sort((left, right) => left - right);
|
|
168
|
+
const middle = Math.floor(confirmed.length / 2);
|
|
169
|
+
return { threshold: confirmed.length % 2 ? confirmed[middle] : (confirmed[middle - 1] + confirmed[middle]) / 2, examples: confirmed.length };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True when the Cyclotron is sure enough that the item does not need a person. */
|
|
173
|
+
export function isConfident(item: NeedsMeItem, threshold: number | null): boolean {
|
|
174
|
+
return threshold !== null && item.decisionState === "open" && !item.hardGate && !item.askedForReview && item.prediction !== undefined && item.uncertainty < threshold;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- the order ----------------------------------------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export const MAXIMUM_RUN = 2;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Apple-News-style mixing: keep the order, but never more than `maxRun` of one type in a row while another type is waiting. When a run
|
|
183
|
+
* is full, the highest-ranked item of a different type goes next. `prior` is what already sits in front of this list.
|
|
184
|
+
*/
|
|
185
|
+
export function interleaveByType<T extends { type: NeedsMeType }>(ordered: readonly T[], maxRun = MAXIMUM_RUN, prior: readonly NeedsMeType[] = []): T[] {
|
|
186
|
+
const remaining = [...ordered];
|
|
187
|
+
const placed: T[] = [];
|
|
188
|
+
const history = [...prior];
|
|
189
|
+
while (remaining.length) {
|
|
190
|
+
const last = history[history.length - 1];
|
|
191
|
+
let run = 0;
|
|
192
|
+
for (let index = history.length - 1; index >= 0 && history[index] === last; index -= 1) run += 1;
|
|
193
|
+
let pick = 0;
|
|
194
|
+
if (last !== undefined && run >= maxRun) {
|
|
195
|
+
const alternative = remaining.findIndex((item) => item.type !== last);
|
|
196
|
+
if (alternative >= 0) pick = alternative;
|
|
197
|
+
}
|
|
198
|
+
const [next] = remaining.splice(pick, 1);
|
|
199
|
+
placed.push(next);
|
|
200
|
+
history.push(next.type);
|
|
201
|
+
}
|
|
202
|
+
return placed;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type NeedsMeRanking<P = unknown> = {
|
|
206
|
+
/** Hard gates first, then everything that asks for a person, interleaved. */
|
|
207
|
+
feed: NeedsMeItem<P>[];
|
|
208
|
+
/** Confident items: decided by the Cyclotron unless overruled. Newest first. */
|
|
209
|
+
auto: NeedsMeItem<P>[];
|
|
210
|
+
scores: ReadonlyMap<string, number>;
|
|
211
|
+
threshold: ConfidenceThreshold;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function newestFirst(left: NeedsMeItem, right: NeedsMeItem): number {
|
|
215
|
+
return (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.id.localeCompare(right.id);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function rankNeedsMe<P>(
|
|
219
|
+
items: readonly NeedsMeItem<P>[],
|
|
220
|
+
options: { now: number; freshness: FeedFreshness; threshold: ConfidenceThreshold; attention?: AttentionModel },
|
|
221
|
+
): NeedsMeRanking<P> {
|
|
222
|
+
const attention = options.attention ?? interimAttention;
|
|
223
|
+
const context: AttentionContext = { now: options.now, freshness: options.freshness };
|
|
224
|
+
const scores = new Map(items.map((item) => [item.id, attention.value(item, context)]));
|
|
225
|
+
const score = (item: NeedsMeItem) => scores.get(item.id) ?? 0;
|
|
226
|
+
const gates = items.filter((item) => item.hardGate).sort((left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0) || left.id.localeCompare(right.id));
|
|
227
|
+
const rest = items.filter((item) => !item.hardGate);
|
|
228
|
+
const auto = rest.filter((item) => isConfident(item, options.threshold.threshold)).sort(newestFirst);
|
|
229
|
+
const autoIds = new Set(auto.map((item) => item.id));
|
|
230
|
+
const asking = rest.filter((item) => !autoIds.has(item.id)).sort((left, right) => score(right) - score(left) || newestFirst(left, right));
|
|
231
|
+
const mixed = interleaveByType(asking, MAXIMUM_RUN, gates.map((item) => item.type));
|
|
232
|
+
return { feed: [...gates, ...mixed], auto, scores, threshold: options.threshold };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- one order per visit ------------------------------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The order of the feed, taken once per visit when it has loaded and then left alone: selecting, deciding, a background refresh and a
|
|
239
|
+
* cache revalidation never re-rank it. Items that arrive later are counted as new and wait behind an "N new items: show" pill.
|
|
240
|
+
* The same rule as the References feed.
|
|
241
|
+
*/
|
|
242
|
+
export type NeedsMeSnapshot = { known: ReadonlySet<string>; feed: string[]; auto: string[] };
|
|
243
|
+
|
|
244
|
+
export function takeNeedsMeSnapshot(ranking: Pick<NeedsMeRanking, "feed" | "auto">): NeedsMeSnapshot {
|
|
245
|
+
return {
|
|
246
|
+
known: new Set([...ranking.feed, ...ranking.auto].map((item) => item.id)),
|
|
247
|
+
feed: ranking.feed.map((item) => item.id),
|
|
248
|
+
auto: ranking.auto.map((item) => item.id),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The live items placed where the snapshot put them. Their content stays current; their places do not. */
|
|
253
|
+
export function applyNeedsMeSnapshot<P>(snapshot: NeedsMeSnapshot, live: Pick<NeedsMeRanking<P>, "feed" | "auto">): { feed: NeedsMeItem<P>[]; auto: NeedsMeItem<P>[]; newCount: number } {
|
|
254
|
+
const byId = new Map<string, NeedsMeItem<P>>();
|
|
255
|
+
for (const item of [...live.auto, ...live.feed]) byId.set(item.id, item);
|
|
256
|
+
const pick = (ids: string[]) => ids.flatMap((id) => { const item = byId.get(id); return item ? [item] : []; });
|
|
257
|
+
const newCount = [...live.feed, ...live.auto].filter((item) => !snapshot.known.has(item.id) && item.decisionState === "open").length;
|
|
258
|
+
return { feed: pick(snapshot.feed), auto: pick(snapshot.auto), newCount };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- counts and "you're shaping this" -----------------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
export type NeedsMeCounts = Record<NeedsMeFilter, number>;
|
|
264
|
+
|
|
265
|
+
/** What waits for a person, by pill: only open items count. */
|
|
266
|
+
export function countNeedsMe(items: readonly Pick<NeedsMeItem, "type" | "decisionState">[]): NeedsMeCounts {
|
|
267
|
+
const counts: NeedsMeCounts = { all: 0, references: 0, drafts: 0, stories: 0, email: 0, captures: 0, other: 0 };
|
|
268
|
+
for (const item of items) {
|
|
269
|
+
if (item.decisionState !== "open") continue;
|
|
270
|
+
counts.all += 1;
|
|
271
|
+
counts[FILTER_OF[item.type]] += 1;
|
|
272
|
+
}
|
|
273
|
+
return counts;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Monday 00:00 UTC of the week that contains `now`: "this week" is a calendar week, not a rolling window. */
|
|
277
|
+
export function weekStartUtc(now: number): number {
|
|
278
|
+
const date = new Date(now);
|
|
279
|
+
const day = (date.getUTCDay() + 6) % 7; // Monday = 0
|
|
280
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() - day);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type ShapingStats = {
|
|
284
|
+
/** Everything you decided this week, of any type. */
|
|
285
|
+
decisions: number;
|
|
286
|
+
/** Of the References you rated this week that had a prediction: you agreed with the Cyclotron. */
|
|
287
|
+
agreed: number;
|
|
288
|
+
/** ... and you overruled it. */
|
|
289
|
+
overruled: number;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** The "You're shaping this" strip. `decidedAt` lists when each decision was made (see recentDecisions in notification-feed.ts). */
|
|
293
|
+
export function shapingThisWeek(input: {
|
|
294
|
+
decidedAt: readonly string[];
|
|
295
|
+
references: ReferenceRecord[];
|
|
296
|
+
decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined;
|
|
297
|
+
now: number;
|
|
298
|
+
}): ShapingStats {
|
|
299
|
+
const since = weekStartUtc(input.now);
|
|
300
|
+
const thisWeek = (iso: string | null | undefined) => {
|
|
301
|
+
const time = iso ? Date.parse(iso) : NaN;
|
|
302
|
+
return Number.isFinite(time) && time >= since && time <= input.now;
|
|
303
|
+
};
|
|
304
|
+
let agreed = 0;
|
|
305
|
+
let overruled = 0;
|
|
306
|
+
for (const reference of input.references) {
|
|
307
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
308
|
+
if ((status !== "accepted" && status !== "rejected") || !thisWeek(reference.curationStatusUpdatedAt)) continue;
|
|
309
|
+
const prediction = predictionOf(input.decisions?.get(referenceLineageId(reference)));
|
|
310
|
+
if (!prediction) continue;
|
|
311
|
+
if (prediction.label === (status === "accepted" ? "accept" : "reject")) agreed += 1;
|
|
312
|
+
else overruled += 1;
|
|
313
|
+
}
|
|
314
|
+
return { decisions: input.decidedAt.filter(thisWeek).length, agreed, overruled };
|
|
315
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Every row a paged newsroom read returns, following `nextToken` to the end; rows repeated across pages are kept once. */
|
|
2
|
+
export async function loadAllPages<T extends { id?: string | null }>(
|
|
3
|
+
loadPage: (nextToken: string | null) => Promise<{ items: T[]; nextToken?: string | null; hasMore: boolean }>,
|
|
4
|
+
): Promise<T[]> {
|
|
5
|
+
const rows: T[] = [];
|
|
6
|
+
const seenIds = new Set<string>();
|
|
7
|
+
const seenTokens = new Set<string>();
|
|
8
|
+
let nextToken: string | null = null;
|
|
9
|
+
do {
|
|
10
|
+
const page = await loadPage(nextToken);
|
|
11
|
+
for (const row of page.items) {
|
|
12
|
+
if (!row?.id || seenIds.has(row.id)) continue;
|
|
13
|
+
seenIds.add(row.id);
|
|
14
|
+
rows.push(row);
|
|
15
|
+
}
|
|
16
|
+
nextToken = page.hasMore ? page.nextToken ?? null : null;
|
|
17
|
+
if (nextToken && seenTokens.has(nextToken)) throw new Error("The list repeated a page; reload to try again.");
|
|
18
|
+
if (nextToken) seenTokens.add(nextToken);
|
|
19
|
+
} while (nextToken);
|
|
20
|
+
return rows;
|
|
21
|
+
}
|
|
@@ -2,6 +2,7 @@ import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
|
2
2
|
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
3
3
|
import type { NextRequest } from "next/server";
|
|
4
4
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
5
|
+
import { STAFF_GROUPS } from "./staff-groups";
|
|
5
6
|
|
|
6
7
|
export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
|
|
7
8
|
export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
|
|
@@ -14,7 +15,7 @@ export function newsroomChatIdentityResolver(
|
|
|
14
15
|
userPoolId: auth.user_pool_id,
|
|
15
16
|
clientId: auth.user_pool_client_id,
|
|
16
17
|
tokenUse: "access",
|
|
17
|
-
groups: [
|
|
18
|
+
groups: [...STAFF_GROUPS],
|
|
18
19
|
}, jwksCache ? { jwksCache } : undefined);
|
|
19
20
|
return async (request) => {
|
|
20
21
|
const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
|
|
@@ -167,3 +167,21 @@ export function parseAssignmentIdFromNewsroomPathname(pathname: string | null |
|
|
|
167
167
|
return segment;
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
/** The Notification Center's deep link to one item: `/newsroom/needs-me/<id>` (public `/needs-me/<id>` on a root-path newsroom). */
|
|
172
|
+
export function buildNeedsMeItemPath(itemId: string): string {
|
|
173
|
+
return toPublicNewsroomPath(`/newsroom/needs-me/${encodeURIComponent(itemId)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Item id from `/newsroom/needs-me/<id>`, else null. */
|
|
177
|
+
export function parseNeedsMeItemIdFromNewsroomPathname(pathname: string | null | undefined): string | null {
|
|
178
|
+
const internalPathname = pathname ? browserPathToInternalNewsroomPath(pathname) : null;
|
|
179
|
+
if (!internalPathname?.startsWith("/newsroom/needs-me/")) return null;
|
|
180
|
+
const segment = internalPathname.slice("/newsroom/needs-me/".length).split("/")[0]?.trim() ?? "";
|
|
181
|
+
if (!segment) return null;
|
|
182
|
+
try {
|
|
183
|
+
return decodeURIComponent(segment);
|
|
184
|
+
} catch {
|
|
185
|
+
return segment;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/lib/newsroom-nav.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
|
|
24
|
-
{ id: "overview", label: "
|
|
24
|
+
{ id: "overview", label: "Needs me", detail: "Newsroom home", href: newsroomHref() },
|
|
25
25
|
{ id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
|
|
26
26
|
{ id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
|
|
27
27
|
{ id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
|
|
@@ -8,9 +8,10 @@ export type OrganizerDestination = {
|
|
|
8
8
|
};
|
|
9
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
10
10
|
{ title: "Editorial work", items: [
|
|
11
|
-
{ key: "overview", label: "
|
|
11
|
+
{ key: "overview", label: "Needs me", description: "See what needs you: drafts, references and reviews.", path: "/newsroom", primary: true },
|
|
12
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
13
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
14
|
+
{ key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
|
|
14
15
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
15
16
|
] },
|
|
16
17
|
{ title: "Research & knowledge", items: [
|
|
@@ -96,7 +96,31 @@ export function formatReferenceListDate(reference: ReferenceRecord): string {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export function referenceDisplayTitle(reference: ReferenceRecord): string {
|
|
99
|
-
|
|
99
|
+
const title = reference.title?.trim();
|
|
100
|
+
if (!title) return reference.externalItemId;
|
|
101
|
+
let host: string | null = null;
|
|
102
|
+
try { host = reference.sourceUri ? new URL(reference.sourceUri).hostname : null; } catch { host = null; }
|
|
103
|
+
return withoutSiteName(title, host);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const TITLE_SEPARATORS = [" | ", " – ", " — ", " - "] as const;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A page title without the site's name tacked on: "Auditing employment algorithms | Brookings" reads "Auditing employment
|
|
110
|
+
* algorithms". Only a last segment that names the site (its host's name, such as "brookings" in brookings.edu) is dropped,
|
|
111
|
+
* and with a pipe, which sites use for furniture, the section names before it go too ("… | Poverty and Development | Al Jazeera").
|
|
112
|
+
*/
|
|
113
|
+
export function withoutSiteName(title: string, host: string | null): string {
|
|
114
|
+
const label = (host ?? "").toLowerCase().replace(/^www\./, "").split(".").slice(0, -1).filter((part) => part.length >= 4).at(-1);
|
|
115
|
+
if (!label) return title;
|
|
116
|
+
const plain = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
117
|
+
// The separator whose last segment is shortest wins, so "… groups – report - Amnesty International" keeps "– report".
|
|
118
|
+
const match = TITLE_SEPARATORS.map((separator) => ({ separator, parts: title.split(separator) }))
|
|
119
|
+
.filter(({ parts }) => parts.length > 1 && plain(parts.at(-1)!).includes(label))
|
|
120
|
+
.sort((left, right) => left.parts.at(-1)!.length - right.parts.at(-1)!.length)[0];
|
|
121
|
+
if (!match) return title;
|
|
122
|
+
const kept = match.separator === " | " ? match.parts[0] : match.parts.slice(0, -1).join(match.separator);
|
|
123
|
+
return kept.trim() || title;
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
export function referenceStatusLabel(status: string): string {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Notification Center as a feed of cards ("What needs me?"): which cards there are, how they are counted, filtered and
|
|
3
|
+
* worded. Pure functions, so the page and its tests share them. A review request that covers several AI drafts is not shown as a
|
|
4
|
+
* batch: each draft is its own card, with a small caption saying which run it came from. The request still closes itself when
|
|
5
|
+
* its last draft is decided (components/notification-center.tsx).
|
|
6
|
+
*/
|
|
7
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
8
|
+
import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
|
|
9
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
10
|
+
import type { ReferencePreview } from "./reference-preview";
|
|
11
|
+
import { draftDecisionOf, type DraftView } from "./review-drafts";
|
|
12
|
+
import { draftIdsOf, isDraftReview, proposalHref, sectionReviews, type DraftDecision, type ReviewRequest } from "./review-requests";
|
|
13
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
14
|
+
|
|
15
|
+
export type DraftItem = {
|
|
16
|
+
kind: "draft";
|
|
17
|
+
/** `<request id>#<draft id>`. */
|
|
18
|
+
key: string;
|
|
19
|
+
request: ReviewRequest;
|
|
20
|
+
draftId: string;
|
|
21
|
+
/** Undefined while the Proposal is still loading. */
|
|
22
|
+
view: DraftView | undefined;
|
|
23
|
+
decision: DraftDecision | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A request that is decided as a whole: a hard gate, a soft review of something that is not a draft, or a flag. */
|
|
27
|
+
export type RequestItem = { kind: "request"; key: string; request: ReviewRequest };
|
|
28
|
+
|
|
29
|
+
export type NotificationItems = {
|
|
30
|
+
drafts: DraftItem[];
|
|
31
|
+
/** Hard gates first, then the other soft reviews, soonest deadline first. */
|
|
32
|
+
reviews: RequestItem[];
|
|
33
|
+
/** For your information: flags. */
|
|
34
|
+
flags: RequestItem[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
|
38
|
+
|
|
39
|
+
/** "Oct 27", in UTC so the words do not change with the reader's time zone. */
|
|
40
|
+
export function shortDate(iso: string | null | undefined): string {
|
|
41
|
+
if (!iso) return "";
|
|
42
|
+
const date = new Date(iso);
|
|
43
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
44
|
+
return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sentenceCase(slug: string): string {
|
|
48
|
+
const spaced = slug.replace(/[-_.]+/g, " ").trim();
|
|
49
|
+
return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where a request came from, in words: the run its batch names ("Monitor stories run"), else the step that raised it. */
|
|
53
|
+
export function requestSource(request: Pick<ReviewRequest, "subject" | "raisedBy">): string {
|
|
54
|
+
if (request.subject.type === "batch") {
|
|
55
|
+
const named = request.subject.id.replace(/-\d{8}t\d{6}z.*$/i, "").replace(/-[0-9a-f]{6,}$/i, "");
|
|
56
|
+
if (/[a-z]{3,}/i.test(named)) return sentenceCase(named);
|
|
57
|
+
}
|
|
58
|
+
return sentenceCase(request.raisedBy) || "An automated step";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The small caption on a card that came out of a request: "from Monitor stories run · Oct 27". */
|
|
62
|
+
export function requestCaption(request: Pick<ReviewRequest, "subject" | "raisedBy" | "raisedAt">): string {
|
|
63
|
+
const when = shortDate(request.raisedAt);
|
|
64
|
+
return `from ${requestSource(request)}${when ? ` · ${when}` : ""}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NO_FALLBACK = { by: "", at: "" } as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The cards for review requests. A draft review becomes one card per draft: undecided drafts of open requests, plus drafts decided in
|
|
71
|
+
* this visit (`stay`, keyed `<request id>#<draft id>`) so a card does not vanish under the editor's thumb. Drafts that are not Proposals
|
|
72
|
+
* on this site cannot be opened here; when none can, the request itself is one card that takes Accept or Reject.
|
|
73
|
+
*/
|
|
74
|
+
export function buildNotificationItems(input: {
|
|
75
|
+
requests: ReviewRequest[];
|
|
76
|
+
drafts: Readonly<Record<string, DraftView | undefined>>;
|
|
77
|
+
missingDrafts: ReadonlySet<string>;
|
|
78
|
+
draftsState: "loading" | "ready" | "failed";
|
|
79
|
+
/** Draft keys and request ids decided in this visit. */
|
|
80
|
+
stay: ReadonlySet<string>;
|
|
81
|
+
}): NotificationItems {
|
|
82
|
+
const sections = sectionReviews(input.requests);
|
|
83
|
+
const closedHere = input.requests.filter((request) => request.state !== "open" && input.stay.has(request.id));
|
|
84
|
+
const draftItems: DraftItem[] = [];
|
|
85
|
+
const requestLevel: ReviewRequest[] = [];
|
|
86
|
+
const draftRequests = [...sections.draftBatches, ...input.requests.filter((request) => request.state !== "open" && isDraftReview(request) && draftIdsOf(request).some((id) => input.stay.has(`${request.id}#${id}`)))];
|
|
87
|
+
for (const request of draftRequests) {
|
|
88
|
+
const available = draftIdsOf(request).filter((id) => !input.missingDrafts.has(id));
|
|
89
|
+
if (request.state === "open" && input.draftsState === "ready" && available.length === 0) {
|
|
90
|
+
requestLevel.push(request);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const draftId of available) {
|
|
94
|
+
const key = `${request.id}#${draftId}`;
|
|
95
|
+
const view = input.drafts[draftId];
|
|
96
|
+
const decision = draftDecisionOf(request.links.find((link) => link.id === draftId), view, NO_FALLBACK);
|
|
97
|
+
const waiting = request.state === "open" && !decision;
|
|
98
|
+
if (waiting || input.stay.has(key)) draftItems.push({ kind: "draft", key, request, draftId, view, decision });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const asItem = (request: ReviewRequest): RequestItem => ({ kind: "request", key: request.id, request });
|
|
102
|
+
const standing = (request: ReviewRequest) => request.state === "open" || input.stay.has(request.id);
|
|
103
|
+
const reviews = [
|
|
104
|
+
...sections.hardGates,
|
|
105
|
+
...sections.softReviews,
|
|
106
|
+
...requestLevel,
|
|
107
|
+
...closedHere.filter((request) => request.mode !== "flag" && !isDraftReview(request)),
|
|
108
|
+
].filter(standing).map(asItem);
|
|
109
|
+
const flags = [...sections.flags, ...closedHere.filter((request) => request.mode === "flag")].filter(standing).map(asItem);
|
|
110
|
+
return {
|
|
111
|
+
drafts: draftItems,
|
|
112
|
+
reviews: [...new Map(reviews.map((item) => [item.key, item])).values()],
|
|
113
|
+
flags: [...new Map(flags.map((item) => [item.key, item])).values()],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- sources, pictures ----------------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function normalizedAddress(address: string): string {
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(address);
|
|
122
|
+
return `${url.hostname.replace(/^www\./i, "").toLowerCase()}${url.pathname.replace(/\/+$/, "")}${url.search}`;
|
|
123
|
+
} catch {
|
|
124
|
+
return address.trim().toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The previews of References by the address they were saved from, so a draft's cited source finds its picture. */
|
|
129
|
+
export function previewsBySourceAddress(references: ReferenceRecord[], previews: ReadonlyMap<string, ReferencePreview>): Map<string, ReferencePreview> {
|
|
130
|
+
const found = new Map<string, ReferencePreview>();
|
|
131
|
+
for (const reference of references) {
|
|
132
|
+
const preview = previews.get(referenceLineageId(reference));
|
|
133
|
+
if (preview && reference.sourceUri) found.set(normalizedAddress(reference.sourceUri), preview);
|
|
134
|
+
}
|
|
135
|
+
return found;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The first cited source that has a picture, with the site name its preview gives. */
|
|
139
|
+
export function draftPicture(view: DraftView | undefined, bySource: ReadonlyMap<string, ReferencePreview>): { imageUrl: string | null; siteName: string | null; host: string | null } {
|
|
140
|
+
const sources = view?.sources ?? [];
|
|
141
|
+
const host = sources.find((source) => source.url)?.url ? sourceHost(sources.find((source) => source.url)!.url!) : null;
|
|
142
|
+
for (const source of sources) {
|
|
143
|
+
const preview = source.url ? bySource.get(normalizedAddress(source.url)) : undefined;
|
|
144
|
+
if (preview?.imageUrl) return { imageUrl: preview.imageUrl, siteName: preview.siteName, host: source.url ? sourceHost(source.url) : host };
|
|
145
|
+
}
|
|
146
|
+
return { imageUrl: null, siteName: null, host };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function sourceHost(address: string): string | null {
|
|
150
|
+
try {
|
|
151
|
+
return new URL(address).hostname.replace(/^www\./i, "") || null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- what you decided ------------------------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export type RecentDecision = {
|
|
160
|
+
key: string;
|
|
161
|
+
kind: "draft" | "reference" | "request";
|
|
162
|
+
word: "Accepted" | "Rejected" | "Acknowledged" | "Answered";
|
|
163
|
+
choice: "accept" | "reject" | "acknowledge" | "answer";
|
|
164
|
+
title: string;
|
|
165
|
+
at: string;
|
|
166
|
+
href: string | null;
|
|
167
|
+
/** The editor's why, when there is one. */
|
|
168
|
+
note: string | null;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function requestDecision(request: ReviewRequest): Pick<RecentDecision, "word" | "choice"> | null {
|
|
172
|
+
const answer = request.answer;
|
|
173
|
+
if (request.state !== "answered" || !answer) return null;
|
|
174
|
+
if (answer.decision === "acknowledge" || (answer.decision === "approve" && request.mode === "flag")) return { word: "Acknowledged", choice: "acknowledge" };
|
|
175
|
+
if (answer.decision === "approve") return { word: "Accepted", choice: "accept" };
|
|
176
|
+
if (answer.decision === "reject") return { word: "Rejected", choice: "reject" };
|
|
177
|
+
return { word: "Answered", choice: "answer" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** What the editor decided most recently, newest first: drafts, other requests and References. "Last few", so a small default. */
|
|
181
|
+
export function recentDecisions(requests: ReviewRequest[], references: ReferenceRecord[], demo = false, limit = 5): RecentDecision[] {
|
|
182
|
+
const found = new Map<string, RecentDecision>();
|
|
183
|
+
for (const request of requests) {
|
|
184
|
+
for (const link of request.links) {
|
|
185
|
+
if (!link.decision || !link.id) continue;
|
|
186
|
+
found.set(`${request.id}#${link.id}`, {
|
|
187
|
+
key: `${request.id}#${link.id}`, kind: "draft", word: link.decision.choice === "accept" ? "Accepted" : "Rejected", choice: link.decision.choice,
|
|
188
|
+
title: link.decision.title ?? link.label ?? link.id, at: link.decision.at, href: proposalHref(link.id, demo), note: link.decision.why ?? null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
for (const entry of request.answer?.drafts ?? []) {
|
|
192
|
+
found.set(`${request.id}#${entry.id}`, {
|
|
193
|
+
key: `${request.id}#${entry.id}`, kind: "draft", word: entry.choice === "accept" ? "Accepted" : "Rejected", choice: entry.choice,
|
|
194
|
+
title: entry.title ?? entry.id, at: entry.at, href: proposalHref(entry.id, demo), note: entry.why ?? null,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const whole = requestDecision(request);
|
|
198
|
+
if (whole && !request.answer?.drafts?.length) {
|
|
199
|
+
found.set(request.id, { key: request.id, kind: "request", ...whole, title: request.reason, at: request.answer!.at, href: null, note: request.answer!.feedback });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const reference of references) {
|
|
203
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
204
|
+
if ((status !== "accepted" && status !== "rejected") || !reference.curationStatusUpdatedAt) continue;
|
|
205
|
+
const lineageId = referenceLineageId(reference);
|
|
206
|
+
found.set(`reference#${lineageId}`, {
|
|
207
|
+
key: `reference#${lineageId}`, kind: "reference", word: status === "accepted" ? "Accepted" : "Rejected", choice: status === "accepted" ? "accept" : "reject",
|
|
208
|
+
title: referenceDisplayTitle(reference), at: reference.curationStatusUpdatedAt, href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo), note: null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return [...found.values()].filter((entry) => entry.at).sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit);
|
|
212
|
+
}
|