@teamscala/service-boot 0.23.481 → 0.23.492
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/package.json +1 -261
- package/src/agreement-gate/signing-session.ts +1 -1
- package/src/build-client-assets/styles-build.ts +11 -1
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-page-server-client.ts +1 -15
- package/src/register-ws-routes.ts +1 -1
- package/src/fn-routers/agreement-certificate.ts +0 -224
- package/src/fn-routers/agreement-completion-email.ts +0 -114
- package/src/fn-routers/agreement-send-router.ts +0 -303
- package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
- package/src/fn-routers/agreement-signing-evidence.ts +0 -105
- package/src/fn-routers/assessment-report-data.test.ts +0 -404
- package/src/fn-routers/assessment-report-data.ts +0 -485
- package/src/fn-routers/assessment-report-email.ts +0 -256
- package/src/fn-routers/assessment-report-pair.test.ts +0 -136
- package/src/fn-routers/assessment-report-pair.ts +0 -155
- package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
- package/src/fn-routers/assessment-research-report-data.ts +0 -424
- package/src/fn-routers/assessment-scoring.ts +0 -105
- package/src/fn-routers/auth-router.ts +0 -65
- package/src/fn-routers/chat-session-router-shared.ts +0 -122
- package/src/fn-routers/chat-session-router.ts +0 -356
- package/src/fn-routers/conversation-ownership.ts +0 -24
- package/src/fn-routers/email-defaults.ts +0 -28
- package/src/fn-routers/email-link-ttl.ts +0 -28
- package/src/fn-routers/index.ts +0 -76
- package/src/fn-routers/linkedin-identity.test.ts +0 -27
- package/src/fn-routers/linkedin-identity.ts +0 -36
- package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
- package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
- package/src/fn-routers/panel-data-router.ts +0 -104
- package/src/fn-routers/persist-chat-messages.test.ts +0 -86
- package/src/fn-routers/persist-chat-messages.ts +0 -98
- package/src/fn-routers/proposal-send-router.ts +0 -207
- package/src/fn-routers/public-agreement-router.ts +0 -407
- package/src/fn-routers/public-assessment-router.test.ts +0 -437
- package/src/fn-routers/public-assessment-router.ts +0 -268
- package/src/fn-routers/public-gpt-session-router.ts +0 -378
- package/src/fn-routers/resend-client.ts +0 -39
- package/src/fn-routers/sign-in-flow-router.ts +0 -196
- package/src/fn-routers/user-preferences-router.ts +0 -130
- package/src/fn-routers/work-items-router.ts +0 -342
- package/src/page-server-orpc/audit-revert-router.ts +0 -102
- package/src/page-server-orpc/puck-pages-router.ts +0 -170
- package/src/page-server-orpc/user-preferences-router.ts +0 -132
|
@@ -1,485 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten @indivisible-unit owner=lane/idle-pools-hold-the-ceiling reason="one report-shaping unit: band selection, narrative and chart shaping change together"
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* report-data.ts — quiz-results → report data generation.
|
|
7
|
-
*
|
|
8
|
-
* Shapes a stored assessment response into the data the report puck_page
|
|
9
|
-
|
|
10
|
-
* renders. The response already carries total_score + category_scores
|
|
11
|
-
* (computed at submit time), so this is pure shaping — no re-scoring and no
|
|
12
|
-
* authored copy. The `computeReportData` resolver_fn (a resource_loader
|
|
13
|
-
* source, key `reportData`) runs this at SSR; the report blocks bind its
|
|
14
|
-
* fields through the unified page-data surface (`dataSource:"page-data"` +
|
|
15
|
-
* `fieldPath:"reportData.<key>"`).
|
|
16
|
-
*
|
|
17
|
-
* @see scala-docs/curated/apps/messaging.md is unrelated; the stage model
|
|
18
|
-
* lives on `assessments.stage_bands` (migration 560/561).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import type { ReportResearchInput, ResearchReportData } from "./assessment-research-report-data.ts";
|
|
22
|
-
import {
|
|
23
|
-
buildResearchReportData,
|
|
24
|
-
mergeNextSteps,
|
|
25
|
-
readResearchFindings,
|
|
26
|
-
} from "./assessment-research-report-data.ts";
|
|
27
|
-
|
|
28
|
-
/** A chart data point — structural match for @teamscala/chart-block's ChartPoint. */
|
|
29
|
-
export interface ReportChartPoint {
|
|
30
|
-
label: string;
|
|
31
|
-
value: number;
|
|
32
|
-
color?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** A derived pillar position for the Positioning card (label + advisory summary). */
|
|
36
|
-
export interface PillarPosition {
|
|
37
|
-
label: string;
|
|
38
|
-
summary: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** One roadmap phase — a focus word plus its actions. */
|
|
42
|
-
export interface RoadmapPhase {
|
|
43
|
-
focus: string;
|
|
44
|
-
actions: string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A list item carrying its own ordinal. The number lives WITH the item so a
|
|
49
|
-
* fixed-slot layout cannot print a badge for an item that does not exist: a
|
|
50
|
-
* stage authoring four strengths into six slots leaves slots five and six with
|
|
51
|
-
* no number and no text, rather than a bare numeral beside empty space. Numbers
|
|
52
|
-
* spelled in the page instead would be a second copy of the list's length.
|
|
53
|
-
*/
|
|
54
|
-
export interface NumberedItem {
|
|
55
|
-
index: string;
|
|
56
|
-
text: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** A titled action with its explanatory line. */
|
|
60
|
-
export interface TitledAction {
|
|
61
|
-
title: string;
|
|
62
|
-
description: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* One stage band, as authored on `assessments.stage_bands`. Every field is
|
|
67
|
-
* copy the business owns; nothing here is computed. `max` is the inclusive
|
|
68
|
-
* upper bound of the band — the final band omits it and catches the rest.
|
|
69
|
-
*/
|
|
70
|
-
export interface StageBand {
|
|
71
|
-
name: string;
|
|
72
|
-
max?: number;
|
|
73
|
-
tagline?: string;
|
|
74
|
-
executiveSummary?: string;
|
|
75
|
-
strongestAreas?: string[];
|
|
76
|
-
priorityActions?: TitledAction[];
|
|
77
|
-
strategicFocus?: string;
|
|
78
|
-
strengths?: string[];
|
|
79
|
-
opportunities?: string[];
|
|
80
|
-
twelveMonthTarget?: string;
|
|
81
|
-
/**
|
|
82
|
-
* The remaining elements of Anna's five-page template. Every one is COPY,
|
|
83
|
-
* authored per stage like the rest — none can be derived from the response.
|
|
84
|
-
* Wiring them here makes each a row edit rather than a build task: the page
|
|
85
|
-
* binds them and hides the section (`visibleWhenPath`) until a stage fills
|
|
86
|
-
* it in, so Anna can land them one at a time without a deploy.
|
|
87
|
-
*/
|
|
88
|
-
whoYouHelp?: string;
|
|
89
|
-
knownFor?: string;
|
|
90
|
-
insights?: string[];
|
|
91
|
-
contentThemes?: TitledAction[];
|
|
92
|
-
contentRhythm?: string;
|
|
93
|
-
northStar?: string;
|
|
94
|
-
priorityFocus?: string;
|
|
95
|
-
priorityFocusSummary?: string;
|
|
96
|
-
recommendedTier?: string;
|
|
97
|
-
tierSummary?: string;
|
|
98
|
-
whyItFits?: string;
|
|
99
|
-
whatItSolves?: string;
|
|
100
|
-
implementationFocus?: string;
|
|
101
|
-
expectedOutcome?: string;
|
|
102
|
-
finalWord?: string;
|
|
103
|
-
roadmap?: Record<string, RoadmapPhase>;
|
|
104
|
-
pillarNotes?: Record<string, string>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* One pillar as the report presents it: the authored label and subtitle, this
|
|
109
|
-
* person's score, the stage's note, and the pillar's colour. Held in the
|
|
110
|
-
* assessment's own category order (not sorted by score) because the design
|
|
111
|
-
* shows the three pillars in a fixed sequence with fixed colours.
|
|
112
|
-
*/
|
|
113
|
-
export interface PillarCard {
|
|
114
|
-
label: string;
|
|
115
|
-
subtitle: string;
|
|
116
|
-
value: number;
|
|
117
|
-
max: number;
|
|
118
|
-
note: string;
|
|
119
|
-
color: string;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Per-category display authored on `assessments.category_descriptions`. */
|
|
123
|
-
export interface CategoryDisplay {
|
|
124
|
-
description?: string;
|
|
125
|
-
subtitle?: string;
|
|
126
|
-
color?: string;
|
|
127
|
-
max?: number;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** The shape a report resource_loader stamps into loaderData for the blocks. */
|
|
131
|
-
export interface ReportData {
|
|
132
|
-
/** the three pillars in authored order, for the pillar cards */
|
|
133
|
-
pillarCards: PillarCard[];
|
|
134
|
-
reportScore: ReportChartPoint[];
|
|
135
|
-
reportPillars: ReportChartPoint[];
|
|
136
|
-
narrative: string;
|
|
137
|
-
totalScore: number;
|
|
138
|
-
/** overall band label — the Snapshot stage badge */
|
|
139
|
-
stage: string;
|
|
140
|
-
/** band advice — the Snapshot stage summary */
|
|
141
|
-
stageSummary: string;
|
|
142
|
-
/** highest-scoring pillar — the Positioning "Strongest Pillar" column */
|
|
143
|
-
strongest: PillarPosition | null;
|
|
144
|
-
/** mid pillar — the Positioning "Biggest Opportunity" column */
|
|
145
|
-
opportunity: PillarPosition | null;
|
|
146
|
-
/** lowest pillar — the Positioning "Authority Gap" column */
|
|
147
|
-
authorityGap: PillarPosition | null;
|
|
148
|
-
/** section 4 — what this person already has going for them */
|
|
149
|
-
strongestAreas: string[];
|
|
150
|
-
/** section 6 — the numbered actions for this stage */
|
|
151
|
-
priorityActions: TitledAction[];
|
|
152
|
-
/** section 7 */
|
|
153
|
-
strategicFocus: string;
|
|
154
|
-
/** section 8, left column */
|
|
155
|
-
strengths: string[];
|
|
156
|
-
/** section 8, right column */
|
|
157
|
-
opportunities: string[];
|
|
158
|
-
/** section 8 left, numbered for badge rendering */
|
|
159
|
-
strengthsList: NumberedItem[];
|
|
160
|
-
/** section 8 right, numbered for badge rendering */
|
|
161
|
-
opportunitiesList: NumberedItem[];
|
|
162
|
-
/** section 9 */
|
|
163
|
-
twelveMonthTarget: string;
|
|
164
|
-
/** section 10 — three phases, each a focus word plus four actions */
|
|
165
|
-
roadmap: RoadmapPhase[];
|
|
166
|
-
/** the research half of the report — `{present:false}` when no research pass exists */
|
|
167
|
-
research: ResearchReportData;
|
|
168
|
-
/** Anna's remaining template elements — empty until a stage authors them. */
|
|
169
|
-
whoYouHelp: string;
|
|
170
|
-
knownFor: string;
|
|
171
|
-
insights: string[];
|
|
172
|
-
contentThemes: TitledAction[];
|
|
173
|
-
contentRhythm: string;
|
|
174
|
-
northStar: string;
|
|
175
|
-
priorityFocus: string;
|
|
176
|
-
priorityFocusSummary: string;
|
|
177
|
-
recommendedTier: string;
|
|
178
|
-
tierSummary: string;
|
|
179
|
-
whyItFits: string;
|
|
180
|
-
whatItSolves: string;
|
|
181
|
-
implementationFocus: string;
|
|
182
|
-
expectedOutcome: string;
|
|
183
|
-
finalWord: string;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Minimal view of the stored response — total_score + category_scores. */
|
|
187
|
-
export interface ReportResponseInput {
|
|
188
|
-
total_score?: number | null;
|
|
189
|
-
category_scores?: Record<string, number> | null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* The response fields the paired report needs beyond the score: the person's
|
|
194
|
-
* names (prepared-for line) and the LinkedIn URL the research pairing
|
|
195
|
-
* normalises.
|
|
196
|
-
*/
|
|
197
|
-
export interface ReportPairResponseShape extends ReportResponseInput {
|
|
198
|
-
id?: string;
|
|
199
|
-
assessment_id?: string;
|
|
200
|
-
first_name?: string | null;
|
|
201
|
-
last_name?: string | null;
|
|
202
|
-
linkedin_url?: string | null;
|
|
203
|
-
website?: string | null;
|
|
204
|
-
completed_at?: Date | string | null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Minimal view of the assessment metadata — title plus the stage model. */
|
|
208
|
-
export interface ReportAssessmentInput {
|
|
209
|
-
title?: string | null;
|
|
210
|
-
stage_bands?: unknown;
|
|
211
|
-
/** authored pillar order, e.g. ["Be Seen", "Be Heard", "Be Chosen"] */
|
|
212
|
-
categories?: unknown;
|
|
213
|
-
/** per-pillar display: subtitle, colour, description */
|
|
214
|
-
category_descriptions?: unknown;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** The fallback pillar colour when the assessment authors none. */
|
|
218
|
-
const DEFAULT_PILLAR_COLOR = "var(--color-primary)";
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Match a pillar label to its authored stage note.
|
|
222
|
-
*
|
|
223
|
-
* The stage designs key these notes by the distinctive word — `seen`, `heard`,
|
|
224
|
-
* `chosen` — while the assessment names the pillars "Be Seen", "Be Heard",
|
|
225
|
-
* "Be Chosen". A plain lowercase lookup therefore missed EVERY note, and the
|
|
226
|
-
* positioning cards silently fell back to their generic role lines: present,
|
|
227
|
-
* plausible, and identical for every respondent, so nothing looked broken.
|
|
228
|
-
|
|
229
|
-
* Tries the whole label first, then the last word, so either authoring style
|
|
230
|
-
* resolves and neither has to know about the other.
|
|
231
|
-
*/
|
|
232
|
-
export function pillarNote(band: StageBand | null, label: string): string {
|
|
233
|
-
const notes = band?.pillarNotes;
|
|
234
|
-
if (!notes) return "";
|
|
235
|
-
const whole = label.trim().toLowerCase();
|
|
236
|
-
const lastWord = whole.split(/\s+/).pop() ?? whole;
|
|
237
|
-
return (notes[whole] ?? notes[lastWord] ?? "").trim();
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Build the pillar cards in the assessment's OWN category order. Sorting by
|
|
242
|
-
* score is right for "strongest / gap" positioning and wrong here: a reader
|
|
243
|
-
* comparing two reports needs the pillars in the same place every time, and
|
|
244
|
-
* each pillar keeps one colour across every report.
|
|
245
|
-
*/
|
|
246
|
-
export function buildPillarCards(
|
|
247
|
-
assessment: ReportAssessmentInput | null | undefined,
|
|
248
|
-
categoryScores: Record<string, number>,
|
|
249
|
-
band: StageBand | null,
|
|
250
|
-
): PillarCard[] {
|
|
251
|
-
const categories = Array.isArray(assessment?.categories)
|
|
252
|
-
? (assessment.categories as unknown[]).filter(
|
|
253
|
-
(c): c is string => typeof c === "string" && c.length > 0,
|
|
254
|
-
)
|
|
255
|
-
: Object.keys(categoryScores);
|
|
256
|
-
const display = (
|
|
257
|
-
assessment?.category_descriptions && typeof assessment.category_descriptions === "object"
|
|
258
|
-
? assessment.category_descriptions
|
|
259
|
-
: {}
|
|
260
|
-
) as Record<string, CategoryDisplay | string>;
|
|
261
|
-
return categories.map((label) => {
|
|
262
|
-
const raw = display[label];
|
|
263
|
-
const cfg: CategoryDisplay = typeof raw === "string" ? { description: raw } : (raw ?? {});
|
|
264
|
-
const score = Number(categoryScores[label] ?? 0);
|
|
265
|
-
return {
|
|
266
|
-
label,
|
|
267
|
-
subtitle: cfg.subtitle ?? "",
|
|
268
|
-
value: Number.isFinite(score) ? Math.max(0, Math.round(score)) : 0,
|
|
269
|
-
max: typeof cfg.max === "number" && cfg.max > 0 ? cfg.max : 100,
|
|
270
|
-
note: pillarNote(band, label) || cfg.description || "",
|
|
271
|
-
color: cfg.color || DEFAULT_PILLAR_COLOR,
|
|
272
|
-
};
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Read the authored bands off the assessment row. Returns [] when the column
|
|
278
|
-
* is null or malformed, which is the signal to fall back rather than throw —
|
|
279
|
-
* a report that renders a score with no stage copy is degraded; one that 500s
|
|
280
|
-
* because a JSON column is empty is broken.
|
|
281
|
-
*/
|
|
282
|
-
export function readStageBands(assessment?: ReportAssessmentInput | null): StageBand[] {
|
|
283
|
-
const raw = assessment?.stage_bands;
|
|
284
|
-
if (!raw || typeof raw !== "object") return [];
|
|
285
|
-
const bands = (raw as { bands?: unknown }).bands;
|
|
286
|
-
if (!Array.isArray(bands)) return [];
|
|
287
|
-
return bands.filter(
|
|
288
|
-
(band): band is StageBand =>
|
|
289
|
-
!!band && typeof band === "object" && typeof (band as StageBand).name === "string",
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Select the band a score falls in. Bands are ascending by `max`; the first
|
|
295
|
-
* whose `max` the score does not exceed wins, and a band without `max` is the
|
|
296
|
-
* catch-all. Authored order is respected rather than re-sorted, so a
|
|
297
|
-
* mis-ordered row is visible in the output instead of silently corrected.
|
|
298
|
-
*/
|
|
299
|
-
export function selectStageBand(bands: StageBand[], score: number): StageBand | null {
|
|
300
|
-
for (const band of bands) {
|
|
301
|
-
if (typeof band.max !== "number") return band;
|
|
302
|
-
if (score <= band.max) return band;
|
|
303
|
-
}
|
|
304
|
-
return bands.length > 0 ? (bands[bands.length - 1] ?? null) : null;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* The narrative is authored per band and carries `{{score}}` where the design
|
|
309
|
-
* spelled a number, so one band serves every score inside it. With no band,
|
|
310
|
-
* the fallback states the score and nothing it cannot support.
|
|
311
|
-
*/
|
|
312
|
-
export function generateNarrative(
|
|
313
|
-
totalScore: number,
|
|
314
|
-
band: StageBand | null,
|
|
315
|
-
assessment?: ReportAssessmentInput | null,
|
|
316
|
-
): string {
|
|
317
|
-
const authored = band?.executiveSummary?.trim();
|
|
318
|
-
if (authored) return authored.replaceAll("{{score}}", String(totalScore));
|
|
319
|
-
const title = assessment?.title?.trim() || "your assessment";
|
|
320
|
-
return `You scored ${totalScore} on ${title}.`;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** Pair each item with its 1-based ordinal for badge rendering. */
|
|
324
|
-
export function numberItems(items: string[] | undefined): NumberedItem[] {
|
|
325
|
-
if (!Array.isArray(items)) return [];
|
|
326
|
-
return items
|
|
327
|
-
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
|
328
|
-
.map((text, i) => ({ index: String(i + 1), text: text.trim() }));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/** Flatten the authored `{month1, month2, month3}` map into render order. */
|
|
332
|
-
export function orderRoadmap(roadmap: Record<string, RoadmapPhase> | undefined): RoadmapPhase[] {
|
|
333
|
-
if (!roadmap || typeof roadmap !== "object") return [];
|
|
334
|
-
return Object.keys(roadmap)
|
|
335
|
-
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
|
|
336
|
-
.map((key) => roadmap[key])
|
|
337
|
-
.filter((phase): phase is RoadmapPhase => !!phase && Array.isArray(phase.actions));
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Resolver call shape: the resolver_fn executor invokes every resolver as
|
|
342
|
-
* `fn({ input, client, params })` (see resolveByResolverFn in
|
|
343
|
-
* @teamscala/service-boot/resolve-from-loader-config). computeReportData is
|
|
344
|
-
* registered as the `computeReportData` resolver_fn row, so it receives that
|
|
345
|
-
* context — input.response / input.research / input.assessment come from the
|
|
346
|
-
* loader's resolver_input, already PAIRED by the resolveReportHalves resolver
|
|
347
|
-
* (research attaches to a response by normalised LinkedIn identity — both
|
|
348
|
-
* halves resolved, or the research half alone for a research-only person).
|
|
349
|
-
*/
|
|
350
|
-
export function computeReportData({
|
|
351
|
-
input,
|
|
352
|
-
}: {
|
|
353
|
-
input: {
|
|
354
|
-
response?: ReportPairResponseShape | null;
|
|
355
|
-
assessment?: ReportAssessmentInput | null;
|
|
356
|
-
research?: ReportResearchInput | null;
|
|
357
|
-
brandName?: string | null;
|
|
358
|
-
};
|
|
359
|
-
}): ReportData {
|
|
360
|
-
const { response, assessment } = input;
|
|
361
|
-
const research = input.research ?? null;
|
|
362
|
-
const bands = readStageBands(assessment);
|
|
363
|
-
const researchData = buildResearchReportData({
|
|
364
|
-
research,
|
|
365
|
-
response,
|
|
366
|
-
assessment,
|
|
367
|
-
bands,
|
|
368
|
-
resolveBand: (score: number) => selectStageBand(bands, score),
|
|
369
|
-
brandName: input.brandName,
|
|
370
|
-
});
|
|
371
|
-
// Research-only reports (no quiz response) take their headline score from
|
|
372
|
-
// the research pass so the Snapshot and pillar sections render from the
|
|
373
|
-
// only scoring that exists; both halves present, the response stays the
|
|
374
|
-
// subject of section one and the research feeds sections two through five.
|
|
375
|
-
const scores = response ??
|
|
376
|
-
(research
|
|
377
|
-
? {
|
|
378
|
-
total_score: research.total_score,
|
|
379
|
-
category_scores: research.category_scores,
|
|
380
|
-
}
|
|
381
|
-
: null);
|
|
382
|
-
const totalScore = Math.max(0, Math.round(Number(scores?.total_score ?? 0)));
|
|
383
|
-
const categoryScores = scores?.category_scores ?? {};
|
|
384
|
-
const reportScore: ReportChartPoint[] = [{ label: "Score", value: totalScore }];
|
|
385
|
-
const reportPillars: ReportChartPoint[] = Object.entries(categoryScores)
|
|
386
|
-
.filter(([, value]) => Number.isFinite(Number(value)))
|
|
387
|
-
.map(([label, value]) => ({ label, value: Math.max(0, Math.round(Number(value))) }))
|
|
388
|
-
.filter((p) => p.label.length > 0)
|
|
389
|
-
.sort((a, b) => b.value - a.value);
|
|
390
|
-
|
|
391
|
-
const band = selectStageBand(bands, totalScore);
|
|
392
|
-
const findings = research ? readResearchFindings(research.findings) : {};
|
|
393
|
-
const nextSteps = research ? mergeNextSteps(band, findings) : null;
|
|
394
|
-
const narrative =
|
|
395
|
-
research && !response && researchData.execSummary
|
|
396
|
-
? researchData.execSummary
|
|
397
|
-
: generateNarrative(totalScore, band, assessment);
|
|
398
|
-
const pillarCards = buildPillarCards(assessment, categoryScores, band);
|
|
399
|
-
// The chart bars take each pillar's own colour, so the bar chart and the
|
|
400
|
-
// pillar cards cannot disagree about which colour means which pillar.
|
|
401
|
-
// Only an AUTHORED colour travels to the chart. When the assessment sets
|
|
402
|
-
// none, the bars keep the chart's own default rather than being stamped with
|
|
403
|
-
// the cards' fallback — an assessment that never asked for pillar colours
|
|
404
|
-
// gets exactly the output it got before.
|
|
405
|
-
const colorFor = new Map(
|
|
406
|
-
pillarCards.filter((c) => c.color !== DEFAULT_PILLAR_COLOR).map((c) => [c.label, c.color]),
|
|
407
|
-
);
|
|
408
|
-
for (const point of reportPillars) {
|
|
409
|
-
const color = colorFor.get(point.label);
|
|
410
|
-
if (color) point.color = color;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// The positioning trio names the pillar; the advisory line beside it is the
|
|
414
|
-
// band's note for that pillar when one is authored, otherwise the generic
|
|
415
|
-
// role of that column. Falling back per-column rather than dropping the
|
|
416
|
-
// whole card keeps the section rendering while copy is still being written.
|
|
417
|
-
const noteFor = (label: string, fallback: string): string =>
|
|
418
|
-
pillarNote(band, label) || fallback;
|
|
419
|
-
const pos = (p: ReportChartPoint | undefined, fallback: string): PillarPosition | null =>
|
|
420
|
-
p ? { label: p.label, summary: noteFor(p.label, fallback) } : null;
|
|
421
|
-
|
|
422
|
-
const strongest = pos(
|
|
423
|
-
reportPillars[0],
|
|
424
|
-
"Your strongest pillar — protect and compound this advantage.",
|
|
425
|
-
);
|
|
426
|
-
const opportunity =
|
|
427
|
-
reportPillars.length >= 3
|
|
428
|
-
? pos(
|
|
429
|
-
reportPillars[1],
|
|
430
|
-
"Your biggest growth opportunity — focused effort here lifts overall authority.",
|
|
431
|
-
)
|
|
432
|
-
: null;
|
|
433
|
-
const authorityGap =
|
|
434
|
-
reportPillars.length >= 2
|
|
435
|
-
? pos(
|
|
436
|
-
reportPillars[reportPillars.length - 1],
|
|
437
|
-
"Your lowest pillar — the single biggest lever for improvement right now.",
|
|
438
|
-
)
|
|
439
|
-
: null;
|
|
440
|
-
|
|
441
|
-
return {
|
|
442
|
-
pillarCards,
|
|
443
|
-
reportScore,
|
|
444
|
-
reportPillars,
|
|
445
|
-
narrative,
|
|
446
|
-
totalScore,
|
|
447
|
-
stage: band?.name ?? "",
|
|
448
|
-
stageSummary: band?.tagline ?? "",
|
|
449
|
-
strongest,
|
|
450
|
-
opportunity,
|
|
451
|
-
authorityGap,
|
|
452
|
-
// Section five: the research pass's person-specific content wins
|
|
453
|
-
// field-by-field; without research these are the band's copy, exactly
|
|
454
|
-
// as every report before this table rendered them.
|
|
455
|
-
strongestAreas: nextSteps?.strongestAreas ?? band?.strongestAreas ?? [],
|
|
456
|
-
priorityActions: nextSteps?.priorityActions ?? band?.priorityActions ?? [],
|
|
457
|
-
strategicFocus: nextSteps?.strategicFocus ?? band?.strategicFocus ?? "",
|
|
458
|
-
strengths: nextSteps?.strengths ?? band?.strengths ?? [],
|
|
459
|
-
opportunities: nextSteps?.opportunities ?? band?.opportunities ?? [],
|
|
460
|
-
strengthsList: numberItems(nextSteps?.strengths ?? band?.strengths),
|
|
461
|
-
opportunitiesList: numberItems(nextSteps?.opportunities ?? band?.opportunities),
|
|
462
|
-
twelveMonthTarget: nextSteps?.twelveMonthTarget ?? band?.twelveMonthTarget ?? "",
|
|
463
|
-
roadmap: nextSteps?.roadmap ?? orderRoadmap(band?.roadmap),
|
|
464
|
-
research: {
|
|
465
|
-
...researchData,
|
|
466
|
-
groups: researchData.groups,
|
|
467
|
-
weakestAreasList: numberItems(researchData.weakestAreas),
|
|
468
|
-
},
|
|
469
|
-
whoYouHelp: band?.whoYouHelp ?? "",
|
|
470
|
-
knownFor: band?.knownFor ?? "",
|
|
471
|
-
insights: band?.insights ?? [],
|
|
472
|
-
contentThemes: band?.contentThemes ?? [],
|
|
473
|
-
contentRhythm: band?.contentRhythm ?? "",
|
|
474
|
-
northStar: band?.northStar ?? "",
|
|
475
|
-
priorityFocus: band?.priorityFocus ?? "",
|
|
476
|
-
priorityFocusSummary: band?.priorityFocusSummary ?? "",
|
|
477
|
-
recommendedTier: band?.recommendedTier ?? "",
|
|
478
|
-
tierSummary: band?.tierSummary ?? "",
|
|
479
|
-
whyItFits: band?.whyItFits ?? "",
|
|
480
|
-
whatItSolves: band?.whatItSolves ?? "",
|
|
481
|
-
implementationFocus: band?.implementationFocus ?? "",
|
|
482
|
-
expectedOutcome: band?.expectedOutcome ?? "",
|
|
483
|
-
finalWord: band?.finalWord ?? "",
|
|
484
|
-
};
|
|
485
|
-
}
|