@growth-labs/cms 0.8.24 → 0.8.28

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.
Files changed (79) hide show
  1. package/README.md +23 -0
  2. package/dist/engine/published-content.d.ts +4 -0
  3. package/dist/engine/published-content.d.ts.map +1 -1
  4. package/dist/engine/published-content.js +61 -19
  5. package/dist/engine/published-content.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/integration/options.d.ts +226 -13
  11. package/dist/integration/options.d.ts.map +1 -1
  12. package/dist/integration/options.js +17 -2
  13. package/dist/integration/options.js.map +1 -1
  14. package/dist/providers/types.d.ts +17 -1
  15. package/dist/providers/types.d.ts.map +1 -1
  16. package/dist/providers/types.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts +4 -0
  18. package/dist/routes/content-insights.d.ts.map +1 -1
  19. package/dist/routes/content-insights.js +104 -0
  20. package/dist/routes/content-insights.js.map +1 -1
  21. package/dist/routes/surveys.d.ts.map +1 -1
  22. package/dist/routes/surveys.js +80 -4
  23. package/dist/routes/surveys.js.map +1 -1
  24. package/dist/schema/insights-ingest.d.ts +64 -64
  25. package/dist/schema/migrations.d.ts.map +1 -1
  26. package/dist/schema/migrations.js +7 -4
  27. package/dist/schema/migrations.js.map +1 -1
  28. package/dist/surveys/auto-label.d.ts +31 -4
  29. package/dist/surveys/auto-label.d.ts.map +1 -1
  30. package/dist/surveys/auto-label.js +160 -29
  31. package/dist/surveys/auto-label.js.map +1 -1
  32. package/dist/surveys/readout.d.ts +69 -0
  33. package/dist/surveys/readout.d.ts.map +1 -0
  34. package/dist/surveys/readout.js +157 -0
  35. package/dist/surveys/readout.js.map +1 -0
  36. package/dist/surveys/schema.d.ts +156 -0
  37. package/dist/surveys/schema.d.ts.map +1 -1
  38. package/dist/surveys/schema.js +50 -1
  39. package/dist/surveys/schema.js.map +1 -1
  40. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  41. package/dist/ui/api/content/insights-inputs.js +11 -1
  42. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  43. package/dist/ui/client/mount.d.ts.map +1 -1
  44. package/dist/ui/client/mount.js +1 -0
  45. package/dist/ui/client/mount.js.map +1 -1
  46. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
  47. package/dist/ui/screens/PodcastListeningScreen.js +16 -2
  48. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
  49. package/dist/ui/screens/SurveyResponsesScreen.d.ts +36 -3
  50. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  51. package/dist/ui/screens/SurveyResponsesScreen.js +271 -107
  52. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  53. package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
  54. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
  55. package/dist/ui/screens/podcast-listening-data.js +26 -1
  56. package/dist/ui/screens/podcast-listening-data.js.map +1 -1
  57. package/dist/ui/screens/registry.d.ts +3 -1
  58. package/dist/ui/screens/registry.d.ts.map +1 -1
  59. package/dist/ui/screens/registry.js +4 -2
  60. package/dist/ui/screens/registry.js.map +1 -1
  61. package/dist/ui/styles/broadsheet.css +136 -0
  62. package/package.json +2 -3
  63. package/src/engine/published-content.ts +86 -19
  64. package/src/index.ts +1 -0
  65. package/src/integration/options.ts +21 -1
  66. package/src/providers/types.ts +16 -1
  67. package/src/routes/content-insights.ts +104 -0
  68. package/src/routes/surveys.ts +106 -4
  69. package/src/schema/migrations.ts +7 -4
  70. package/src/surveys/auto-label.ts +204 -33
  71. package/src/surveys/readout.ts +243 -0
  72. package/src/surveys/schema.ts +59 -1
  73. package/src/ui/api/content/insights-inputs.ts +8 -1
  74. package/src/ui/client/mount.tsx +1 -0
  75. package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
  76. package/src/ui/screens/SurveyResponsesScreen.tsx +964 -305
  77. package/src/ui/screens/podcast-listening-data.ts +33 -1
  78. package/src/ui/screens/registry.tsx +6 -2
  79. package/src/ui/styles/broadsheet.css +136 -0
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'react'
1
+ import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
2
2
  import type { CmsRole } from '../../schema/types.js'
3
3
  import { canLabelSurveyAnswers, canViewSurveyVerbatims } from '../../surveys/access.js'
4
4
  import {
@@ -8,7 +8,30 @@ import {
8
8
  type SurveyErrorCode,
9
9
  } from '../../surveys/errors.js'
10
10
  import {
11
+ answerValue,
12
+ compareQuestion,
13
+ formatAverage,
14
+ type GroupCount,
15
+ groupResponses,
16
+ headlineScale,
17
+ MEMBERSHIP_SPLIT,
18
+ type Mood,
19
+ type OptionShare,
20
+ optionRows,
21
+ percent,
22
+ questionTakeaway,
23
+ type ResponseGroup,
24
+ type ScaleSummary,
25
+ type SurveyResponse,
26
+ scaleMood,
27
+ share,
28
+ splitQuestions,
29
+ summarizeScale,
30
+ } from '../../surveys/readout.js'
31
+ import {
32
+ MAX_FREE_TEXT_ENTRIES,
11
33
  type SurveyAnswerLabel,
34
+ type SurveyFeedbackTheme,
12
35
  type SurveyQuestion,
13
36
  type SurveyResults,
14
37
  SurveyResultsSchema,
@@ -25,6 +48,15 @@ export type SurveyResponsesState =
25
48
  | { kind: 'auth_failed' }
26
49
  | { kind: 'retryable' }
27
50
 
51
+ /** Every response, read page by page, so a group comparison covers the whole survey. */
52
+ export type EveryoneState =
53
+ | { kind: 'idle' }
54
+ | { kind: 'loading' }
55
+ | { kind: 'ready'; responses: SurveyResponse[] }
56
+ | { kind: 'failed' }
57
+
58
+ type ScaleQuestion = Extract<SurveyQuestion, { kind: 'scale' }>
59
+
28
60
  export function parseSurveyResultsPayload(value: unknown): SurveyResults | null {
29
61
  const parsed = SurveyResultsSchema.safeParse(value)
30
62
  return parsed.success ? parsed.data : null
@@ -55,11 +87,79 @@ export async function classifySurveyResultsResponse(
55
87
  }
56
88
 
57
89
  const PAGE_SIZE = 100
90
+ // Five groups is as many as a reader can tell apart by color; more fold into a note.
91
+ const MAX_GROUPS = 5
92
+
93
+ export interface SurveyChoice {
94
+ surveyId: string
95
+ label: string
96
+ feedbackThemes?: SurveyFeedbackTheme[]
97
+ }
58
98
 
59
- export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
99
+ export function SurveyResponsesScreen({
100
+ surveyId,
101
+ surveys,
102
+ }: {
103
+ surveyId: string
104
+ surveys?: SurveyChoice[]
105
+ }) {
106
+ const [selected, setSelected] = useState(surveyId)
107
+ const picker =
108
+ surveys && surveys.length > 1 ? (
109
+ <SurveyPicker surveys={surveys} selected={selected} onSelect={setSelected} />
110
+ ) : null
111
+ // Keyed by survey, so switching starts clean: no page, comparison, filter or
112
+ // loaded responses carry over from one survey to another.
113
+ return (
114
+ <SurveyResultsView
115
+ key={selected}
116
+ surveyId={selected}
117
+ picker={picker}
118
+ feedbackThemes={surveys?.find((survey) => survey.surveyId === selected)?.feedbackThemes ?? []}
119
+ />
120
+ )
121
+ }
122
+
123
+ export function SurveyPicker({
124
+ surveys,
125
+ selected,
126
+ onSelect,
127
+ }: {
128
+ surveys: SurveyChoice[]
129
+ selected: string
130
+ onSelect: (surveyId: string) => void
131
+ }) {
132
+ return (
133
+ <label className="svy-control">
134
+ <span className="kicker">Survey</span>
135
+ <select
136
+ className="selectbox"
137
+ value={selected}
138
+ onChange={(event) => onSelect(event.target.value)}
139
+ >
140
+ {surveys.map((survey) => (
141
+ <option key={survey.surveyId} value={survey.surveyId}>
142
+ {survey.label}
143
+ </option>
144
+ ))}
145
+ </select>
146
+ </label>
147
+ )
148
+ }
149
+
150
+ function SurveyResultsView({
151
+ surveyId,
152
+ picker,
153
+ feedbackThemes,
154
+ }: {
155
+ surveyId: string
156
+ picker: ReactNode
157
+ feedbackThemes: SurveyFeedbackTheme[]
158
+ }) {
60
159
  const [state, setState] = useState<SurveyResponsesState>({ kind: 'loading' })
61
160
  const [offset, setOffset] = useState(0)
62
161
  const [labelSaveFailed, setLabelSaveFailed] = useState(false)
162
+ const [everyone, setEveryone] = useState<EveryoneState>({ kind: 'idle' })
63
163
  const load = useCallback(
64
164
  async (nextOffset: number) => {
65
165
  setState({ kind: 'loading' })
@@ -75,6 +175,34 @@ export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
75
175
  [surveyId],
76
176
  )
77
177
 
178
+ // Group comparisons are worked out in the browser, so they read every page
179
+ // first: a comparison over one page of 100 would sit beside survey-wide
180
+ // totals while describing different people. Pages load one after another
181
+ // to keep the reporting upstream's load flat; any failed page fails the
182
+ // whole comparison rather than quietly comparing a subset.
183
+ const loadEveryone = useCallback(
184
+ async (total: number) => {
185
+ setEveryone({ kind: 'loading' })
186
+ const responses = new Map<string, SurveyResponse>()
187
+ try {
188
+ for (let pageOffset = 0; pageOffset < total; pageOffset += PAGE_SIZE) {
189
+ const page = await classifySurveyResultsResponse(
190
+ await fetch(`/admin/api/surveys/${encodeURIComponent(surveyId)}?offset=${pageOffset}`),
191
+ )
192
+ if (page.kind !== 'ready') {
193
+ setEveryone({ kind: 'failed' })
194
+ return
195
+ }
196
+ for (const response of page.data.responses) responses.set(response.id, response)
197
+ }
198
+ setEveryone({ kind: 'ready', responses: [...responses.values()] })
199
+ } catch {
200
+ setEveryone({ kind: 'failed' })
201
+ }
202
+ },
203
+ [surveyId],
204
+ )
205
+
78
206
  // Owner-only classification toggle: on success the label is applied to the
79
207
  // already-loaded page (no refetch); on failure the toggle stays honest on
80
208
  // its old value and a note says the save did not land.
@@ -125,6 +253,10 @@ export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
125
253
  onOffsetChange={setOffset}
126
254
  onSetLabel={setLabel}
127
255
  labelSaveFailed={labelSaveFailed}
256
+ everyone={everyone}
257
+ onLoadEveryone={(total) => void loadEveryone(total)}
258
+ picker={picker}
259
+ feedbackThemes={feedbackThemes}
128
260
  />
129
261
  )
130
262
  }
@@ -137,6 +269,18 @@ function withAnswerLabel(
137
269
  ): SurveyResults {
138
270
  return {
139
271
  ...data,
272
+ responses: data.responses.map((response) =>
273
+ response.id !== responseId
274
+ ? response
275
+ : {
276
+ ...response,
277
+ answers: response.answers?.map((answer) =>
278
+ answer.questionId !== questionId
279
+ ? answer
280
+ : { ...answer, label, labelSource: 'owner' },
281
+ ),
282
+ },
283
+ ),
140
284
  freeText: data.freeText.map((entry) =>
141
285
  entry.responseId === responseId && entry.questionId === questionId
142
286
  ? // A successful owner save stores source 'owner' server-side; the
@@ -156,6 +300,12 @@ export function SurveyResponsesBody({
156
300
  onOffsetChange,
157
301
  onSetLabel,
158
302
  labelSaveFailed = false,
303
+ everyone = { kind: 'idle' },
304
+ onLoadEveryone,
305
+ compareBy = '',
306
+ commentMood = '',
307
+ picker = null,
308
+ feedbackThemes = [],
159
309
  }: {
160
310
  surveyId: string
161
311
  state: SurveyResponsesState
@@ -165,30 +315,46 @@ export function SurveyResponsesBody({
165
315
  onOffsetChange?: (offset: number) => void
166
316
  onSetLabel?: (responseId: string, questionId: string, label: SurveyAnswerLabel) => void
167
317
  labelSaveFailed?: boolean
318
+ everyone?: EveryoneState
319
+ onLoadEveryone?: (total: number) => void
320
+ /** The comparison to open with; the screen starts with none. */
321
+ compareBy?: string
322
+ /** The comment mood filter to open with; the screen starts with all readers. */
323
+ commentMood?: Mood | ''
324
+ /** The survey switcher, shown in the header when there is more than one survey. */
325
+ picker?: ReactNode
326
+ feedbackThemes?: SurveyFeedbackTheme[]
168
327
  }) {
169
328
  const canSeeVerbatims = canViewSurveyVerbatims(role)
170
329
  const canLabel = canLabelSurveyAnswers(role)
171
330
 
172
- if (state.kind === 'loading') return <StatusPage title="Loading survey responses…" />
331
+ if (state.kind === 'loading')
332
+ return <StatusPage picker={picker} title="Loading survey responses…" />
173
333
  if (state.kind === 'forbidden')
174
- return <StatusPage title="You do not have permission to view survey responses." />
334
+ return (
335
+ <StatusPage picker={picker} title="You do not have permission to view survey responses." />
336
+ )
175
337
  if (state.kind === 'unavailable')
176
- return <StatusPage title="Survey reporting is not configured for this site." />
338
+ return <StatusPage picker={picker} title="Survey reporting is not configured for this site." />
177
339
  if (state.kind === 'auth_failed')
178
340
  return (
179
341
  <StatusPage
342
+ picker={picker}
180
343
  title="Survey reporting rejected this site’s credentials."
181
344
  detail="The signing secret shared with the reporting upstream is missing or does not match."
182
345
  retry={onRetry}
183
346
  />
184
347
  )
185
348
  if (state.kind === 'malformed')
186
- return <StatusPage title="Masthead received invalid survey data." retry={onRetry} />
349
+ return (
350
+ <StatusPage picker={picker} title="Masthead received invalid survey data." retry={onRetry} />
351
+ )
187
352
  if (state.kind === 'not_found')
188
- return <StatusPage title="The configured survey could not be found." />
353
+ return <StatusPage picker={picker} title="The configured survey could not be found." />
189
354
  if (state.kind === 'retryable')
190
355
  return (
191
356
  <StatusPage
357
+ picker={picker}
192
358
  title="Survey responses could not be loaded."
193
359
  detail="The request did not reach the reporting upstream, or it failed before replying."
194
360
  retry={onRetry}
@@ -196,122 +362,532 @@ export function SurveyResponsesBody({
196
362
  )
197
363
 
198
364
  const { data } = state
199
- const shown = data.responses.length
200
365
  const total = data.survey.responseCount
201
- const rangeStart = total === 0 ? 0 : offset + 1
202
- const rangeEnd = offset + shown
203
- const hasMore = rangeEnd < total
366
+ const headline = headlineScale(data.questions)
204
367
 
205
368
  return (
206
- <div className="page page-wide fade-in" style={{ maxWidth: 1340 }}>
369
+ <div className="page page-wide fade-in svy" style={{ maxWidth: 1340 }}>
207
370
  <div className="page-head">
208
371
  <div>
372
+ <div className="kicker">Survey results</div>
209
373
  <h1 className="page-title">{data.survey.title}</h1>
210
- {/* Never a truncated list under a full count: say exactly what is on screen. */}
211
374
  <div className="page-sub">
212
375
  {total === 0
213
376
  ? 'No responses yet'
214
- : `Showing ${rangeStart}–${rangeEnd} of ${total} ${total === 1 ? 'response' : 'responses'}`}{' '}
215
- · {data.survey.status}
377
+ : `${total} ${total === 1 ? 'response' : 'responses'} · ${surveyStatusLabel(data.survey.status)}`}
216
378
  </div>
217
379
  </div>
218
- <a
219
- className="btn"
220
- href={`/admin/api/surveys/${encodeURIComponent(surveyId)}/responses.csv`}
221
- >
222
- Export CSV
223
- </a>
380
+ <div className="svy-head-actions">
381
+ {picker}
382
+ <a
383
+ className="btn"
384
+ href={`/admin/api/surveys/${encodeURIComponent(surveyId)}/responses.csv`}
385
+ >
386
+ Export CSV
387
+ </a>
388
+ </div>
224
389
  </div>
225
390
 
226
391
  {total === 0 ? (
227
392
  <div className="card" role="status">
228
393
  <h2 className="card-title">No responses yet</h2>
229
- <p className="page-sub">Aggregates and answers will appear after submission.</p>
394
+ <p className="page-sub">Charts and comments will appear after the first submission.</p>
230
395
  </div>
231
396
  ) : (
232
397
  <>
233
- <HeadlineScale questions={data.questions} />
398
+ {headline && headline.answerCount > 0 ? <Headline question={headline} /> : null}
399
+
400
+ <Results
401
+ data={data}
402
+ everyone={everyone}
403
+ onLoadEveryone={onLoadEveryone}
404
+ initialCompareBy={compareBy}
405
+ />
406
+
407
+ {feedbackThemes.length > 0 && <FeedbackThemes themes={feedbackThemes} />}
234
408
 
235
409
  {canSeeVerbatims ? (
236
- <>
237
- {canLabel && (data.unlabeledCount ?? 0) > 0 ? (
238
- <p className="page-sub" role="status" style={{ marginTop: 24 }}>
239
- {unlabeledAnswersNotice(data.unlabeledCount ?? 0)}
240
- </p>
241
- ) : null}
242
- {canLabel && (data.personalDetailsCount ?? 0) > 0 ? (
243
- <p className="page-sub" role="status" style={{ marginTop: 24 }}>
244
- {personalDetailsNotice(data.personalDetailsCount ?? 0)}
245
- </p>
246
- ) : null}
247
- {labelSaveFailed && canLabel ? (
248
- <p className="page-sub" role="alert" style={{ marginTop: 24 }}>
249
- The last label change could not be saved. Try again.
250
- </p>
251
- ) : null}
252
- <VerbatimFeed data={data} canLabel={canLabel} onSetLabel={onSetLabel} />
253
- </>
410
+ <Comments
411
+ data={data}
412
+ offset={offset}
413
+ canLabel={canLabel}
414
+ onSetLabel={onSetLabel}
415
+ labelSaveFailed={labelSaveFailed}
416
+ onOffsetChange={onOffsetChange}
417
+ everyone={everyone}
418
+ onLoadEveryone={onLoadEveryone}
419
+ initialMood={commentMood}
420
+ />
254
421
  ) : (
255
- <div className="card" role="status" style={{ marginTop: 24 }}>
422
+ <div className="card svy-section" role="status">
256
423
  <h2 className="card-title">Individual answers are restricted</h2>
257
424
  <p className="page-sub">
258
- Aggregates below are available to your role. Verbatim answers and respondent
259
- attribution require owner or senior editor.
425
+ The charts above are available to your role. What readers wrote, and who wrote it,
426
+ requires owner or senior editor.
260
427
  </p>
261
428
  </div>
262
429
  )}
263
-
264
- <Aggregates data={data} />
265
-
266
- {(offset > 0 || hasMore) && onOffsetChange ? (
267
- <div className="page-head" style={{ marginTop: 24 }}>
268
- <button
269
- className="btn"
270
- type="button"
271
- disabled={offset === 0}
272
- onClick={() => onOffsetChange(Math.max(offset - PAGE_SIZE, 0))}
273
- >
274
- Previous
275
- </button>
276
- <button
277
- className="btn"
278
- type="button"
279
- disabled={!hasMore}
280
- onClick={() => onOffsetChange(offset + PAGE_SIZE)}
281
- >
282
- Next
283
- </button>
284
- </div>
285
- ) : null}
286
430
  </>
287
431
  )}
288
432
  </div>
289
433
  )
290
434
  }
291
435
 
292
- // The first scale question is the survey's headline number. Leading with the
293
- // distribution keeps one harsh verbatim from setting the tone of a readout.
294
- function HeadlineScale({ questions }: { questions: SurveyResults['questions'] }) {
295
- const scale = questions.find((question) => question.kind === 'scale')
296
- if (scale?.kind !== 'scale' || scale.answerCount === 0) return null
297
- const midpoint = (scale.options.min + scale.options.max) / 2
298
- const favourable = Object.entries(scale.distribution)
299
- .filter(([value]) => Number(value) > midpoint)
300
- .reduce((sum, [, count]) => sum + count, 0)
301
- const share = Math.round((favourable / scale.answerCount) * 100)
436
+ // Existing site configuration supplies reviewed statuses; the survey payload stays unchanged.
437
+ function FeedbackThemes({ themes }: { themes: SurveyFeedbackTheme[] }) {
438
+ const states: Record<SurveyFeedbackTheme['state'], string> = {
439
+ open: 'Open',
440
+ partly_shipped: 'Partly shipped',
441
+ shipped: 'Shipped',
442
+ unknown: 'Not verified',
443
+ }
444
+ return (
445
+ <section className="svy-section" aria-labelledby="survey-feedback-themes">
446
+ <div className="svy-section-head">
447
+ <div className="kicker">Recurring feedback</div>
448
+ <h2 className="card-title" id="survey-feedback-themes">
449
+ You said → we shipped
450
+ </h2>
451
+ <p className="page-sub">
452
+ Reviewed delivery notes for recurring reader themes. These are dated updates, not live
453
+ issue statuses or counts of survey answers. Open work stays visible.
454
+ </p>
455
+ </div>
456
+ <div className="svy-grid">
457
+ {themes.map((theme) => (
458
+ <article className="card" key={theme.id} data-feedback-theme={theme.id}>
459
+ <div className="kicker">{states[theme.state]}</div>
460
+ <h3 className="svy-q-prompt">{theme.title}</h3>
461
+ <p className="svy-takeaway">{theme.summary}</p>
462
+ <p className="svy-note">
463
+ Reviewed <time dateTime={theme.reviewedAt}>{theme.reviewedAt.slice(0, 10)}</time>
464
+ </p>
465
+ {theme.evidence.length > 0 && (
466
+ <ul className="svy-note">
467
+ {theme.evidence.map((link) => (
468
+ <li key={link.href}>
469
+ <a href={link.href}>{link.label}</a>
470
+ </li>
471
+ ))}
472
+ </ul>
473
+ )}
474
+ </article>
475
+ ))}
476
+ </div>
477
+ </section>
478
+ )
479
+ }
480
+
481
+ // ── Headline ────────────────────────────────────────────────────────────
302
482
 
483
+ // The first scale question is the survey's headline number. Leading with the
484
+ // distribution keeps one harsh comment from setting the tone of a readout.
485
+ function Headline({ question }: { question: ScaleQuestion }) {
486
+ const summary = summarizeScale(question.options, question.distribution)
303
487
  return (
304
- <section className="card" aria-labelledby="survey-headline" style={{ marginTop: 24 }}>
305
- <div className="kicker">{scale.prompt}</div>
306
- <h2 className="card-title" id="survey-headline">
307
- {share}% rate {midpoint.toFixed(1)} or better · average{' '}
308
- {scale.average === null ? '—' : scale.average.toFixed(2)}
488
+ <section className="card svy-headline" aria-labelledby="survey-headline">
489
+ <div className="kicker">{`Headline · Q${question.position + 1} · ${summary.answered} answered`}</div>
490
+ <h2 className="svy-headline-prompt" id="survey-headline">
491
+ {question.prompt}
309
492
  </h2>
310
- <CountTable values={scale.distribution} />
493
+ <div className="svy-headline-body">
494
+ <div>
495
+ <div className="svy-big">{percent(summary.happy, summary.answered)}</div>
496
+ <div className="svy-big-label">{`are happy — they rated it ${summary.happyRange} out of ${summary.max}`}</div>
497
+ <dl className="svy-facts">
498
+ <div>
499
+ <dt>
500
+ <span className="svy-swatch mood-unhappy" aria-hidden="true" />
501
+ {`Unhappy (${summary.unhappyRange})`}
502
+ </dt>
503
+ <dd>{`${percent(summary.unhappy, summary.answered)} · ${people(summary.unhappy)}`}</dd>
504
+ </div>
505
+ {summary.neutralValue !== null ? (
506
+ <div>
507
+ <dt>
508
+ <span className="svy-swatch mood-neutral" aria-hidden="true" />
509
+ {`In the middle (${summary.neutralValue})`}
510
+ </dt>
511
+ <dd>{`${percent(summary.neutral, summary.answered)} · ${people(summary.neutral)}`}</dd>
512
+ </div>
513
+ ) : null}
514
+ <div>
515
+ <dt>
516
+ <span className="svy-swatch mood-happy" aria-hidden="true" />
517
+ {`Happy (${summary.happyRange})`}
518
+ </dt>
519
+ <dd>{`${percent(summary.happy, summary.answered)} · ${people(summary.happy)}`}</dd>
520
+ </div>
521
+ <div>
522
+ <dt>Average</dt>
523
+ <dd>{`${formatAverage(summary.average)} out of ${summary.max}`}</dd>
524
+ </div>
525
+ </dl>
526
+ </div>
527
+ <ScaleColumns question={question} />
528
+ </div>
529
+ </section>
530
+ )
531
+ }
532
+
533
+ function ScaleColumns({ question }: { question: ScaleQuestion }) {
534
+ const { min, max } = question.options
535
+ const summary = summarizeScale(question.options, question.distribution)
536
+ const values = Array.from({ length: max - min + 1 }, (_, index) => min + index)
537
+ const peak = Math.max(1, ...values.map((value) => question.distribution[String(value)] ?? 0))
538
+ const columns = { gridTemplateColumns: `repeat(${values.length}, minmax(0, 1fr))` }
539
+ return (
540
+ <figure className="svy-cols" aria-label={`How people rated it, from ${min} to ${max}`}>
541
+ <div className="svy-cols-plot" style={columns}>
542
+ {values.map((value) => {
543
+ const count = question.distribution[String(value)] ?? 0
544
+ return (
545
+ <div
546
+ className="svy-col"
547
+ key={value}
548
+ title={`Rated ${value}: ${people(count)} (${percent(count, summary.answered)})`}
549
+ >
550
+ <span className="svy-col-value">{percent(count, summary.answered)}</span>
551
+ <span
552
+ className={`svy-col-bar mood-${scaleMood(value, min, max)}`}
553
+ style={{ height: `${(count / peak) * 100}%` }}
554
+ />
555
+ </div>
556
+ )
557
+ })}
558
+ </div>
559
+ <div className="svy-cols-axis" style={columns}>
560
+ {values.map((value) => (
561
+ <span key={value}>{value}</span>
562
+ ))}
563
+ </div>
564
+ {summary.average !== null ? (
565
+ <div className="svy-cols-avg">
566
+ <span style={{ left: `${((summary.average - min + 0.5) / values.length) * 100}%` }}>
567
+ {`▲ average ${formatAverage(summary.average)}`}
568
+ </span>
569
+ </div>
570
+ ) : null}
571
+ <figcaption className="svy-cols-caption">
572
+ <span>{`${min} = lowest`}</span>
573
+ <span>{`highest = ${max}`}</span>
574
+ </figcaption>
575
+ </figure>
576
+ )
577
+ }
578
+
579
+ // ── Results by question ─────────────────────────────────────────────────
580
+
581
+ function Results({
582
+ data,
583
+ everyone,
584
+ onLoadEveryone,
585
+ initialCompareBy,
586
+ }: {
587
+ data: SurveyResults
588
+ everyone: EveryoneState
589
+ onLoadEveryone?: (total: number) => void
590
+ initialCompareBy: string
591
+ }) {
592
+ const [compareBy, setCompareBy] = useState(initialCompareBy)
593
+ const total = data.survey.responseCount
594
+ // Comparing by an answer needs each person's answers. A provider that sends
595
+ // none can still compare members with non-members, so only that is offered.
596
+ const hasAnswers = data.responses.some((response) => (response.answers ?? []).length > 0)
597
+ const candidates = hasAnswers ? splitQuestions(data.questions) : []
598
+
599
+ useEffect(() => {
600
+ if (compareBy && everyone.kind === 'idle') onLoadEveryone?.(total)
601
+ }, [compareBy, everyone.kind, onLoadEveryone, total])
602
+
603
+ const allGroups = useMemo(
604
+ () =>
605
+ compareBy && everyone.kind === 'ready'
606
+ ? groupResponses(everyone.responses, compareBy, data.questions)
607
+ : null,
608
+ [compareBy, everyone, data.questions],
609
+ )
610
+ const groups = allGroups?.slice(0, MAX_GROUPS) ?? null
611
+ const headlineId = headlineScale(data.questions)?.id
612
+ const gridQuestions = data.questions.filter((question) => groups || question.id !== headlineId)
613
+
614
+ return (
615
+ <section aria-labelledby="survey-results" className="svy-section">
616
+ <div className="page-head svy-section-head">
617
+ <div>
618
+ <h2 className="page-title svy-h2" id="survey-results">
619
+ What readers told us
620
+ </h2>
621
+ <p className="page-sub">
622
+ {`Each chart covers all ${total} responses. Percentages are of the people who answered that question.`}
623
+ </p>
624
+ </div>
625
+ <label className="svy-control">
626
+ <span className="kicker">Compare</span>
627
+ <select
628
+ className="selectbox"
629
+ value={compareBy}
630
+ onChange={(event) => setCompareBy(event.target.value)}
631
+ >
632
+ <option value="">Everyone together</option>
633
+ <option value={MEMBERSHIP_SPLIT}>Members vs non-members</option>
634
+ {candidates.map((question) => (
635
+ <option key={question.id} value={question.id}>
636
+ {question.kind === 'scale'
637
+ ? `Happy vs unhappy — ${question.prompt}`
638
+ : `By answer — ${question.prompt}`}
639
+ </option>
640
+ ))}
641
+ </select>
642
+ </label>
643
+ </div>
644
+
645
+ {compareBy ? (
646
+ <CompareStatus
647
+ everyone={everyone}
648
+ total={total}
649
+ groups={groups}
650
+ hiddenGroups={(allGroups?.length ?? 0) - (groups?.length ?? 0)}
651
+ onRetry={onLoadEveryone ? () => onLoadEveryone(total) : undefined}
652
+ />
653
+ ) : null}
654
+
655
+ <div className="svy-grid">
656
+ {/* The headline question already has its own chart above; it returns
657
+ here only when a comparison has something to add to it. */}
658
+ {gridQuestions.map((question) => (
659
+ <QuestionCard
660
+ key={question.id}
661
+ question={question}
662
+ total={total}
663
+ groups={groups}
664
+ isSplit={question.id === compareBy}
665
+ />
666
+ ))}
667
+ </div>
311
668
  </section>
312
669
  )
313
670
  }
314
671
 
672
+ function CompareStatus({
673
+ everyone,
674
+ total,
675
+ groups,
676
+ hiddenGroups,
677
+ onRetry,
678
+ }: {
679
+ everyone: EveryoneState
680
+ total: number
681
+ groups: ResponseGroup[] | null
682
+ hiddenGroups: number
683
+ onRetry?: () => void
684
+ }) {
685
+ if (everyone.kind === 'failed') {
686
+ return (
687
+ <div className="svy-callout is-alert" role="alert">
688
+ Not every response could be loaded, so groups are not compared — a partial comparison would
689
+ describe the wrong people.{' '}
690
+ {onRetry ? (
691
+ <button className="btn sm" type="button" onClick={onRetry}>
692
+ Retry
693
+ </button>
694
+ ) : null}
695
+ </div>
696
+ )
697
+ }
698
+ if (everyone.kind !== 'ready' || !groups) {
699
+ return (
700
+ <p className="page-sub svy-compare-status" role="status">
701
+ {`Loading all ${total} responses to compare groups…`}
702
+ </p>
703
+ )
704
+ }
705
+ if (groups.length === 0) {
706
+ return (
707
+ <p className="svy-callout" role="status">
708
+ Nobody has answered that question yet, so there are no groups to compare.
709
+ </p>
710
+ )
711
+ }
712
+ return (
713
+ <div className="svy-legend" role="status">
714
+ <span className="page-sub">{`Comparing all ${everyone.responses.length} responses:`}</span>
715
+ {groups.map((group, index) => (
716
+ <span className="svy-legend-item" key={group.key}>
717
+ <span className={`svy-swatch ${groupTone(group, index)}`} aria-hidden="true" />
718
+ {`${group.label} · ${people(group.responses.length)}`}
719
+ </span>
720
+ ))}
721
+ {hiddenGroups > 0 ? (
722
+ <span className="page-sub">{`+${hiddenGroups} smaller ${hiddenGroups === 1 ? 'group' : 'groups'} not shown`}</span>
723
+ ) : null}
724
+ </div>
725
+ )
726
+ }
727
+
728
+ function QuestionCard({
729
+ question,
730
+ total,
731
+ groups,
732
+ isSplit,
733
+ }: {
734
+ question: SurveyQuestion
735
+ total: number
736
+ groups: ResponseGroup[] | null
737
+ isSplit: boolean
738
+ }) {
739
+ const comparison =
740
+ groups && groups.length > 0 && !isSplit ? compareQuestion(question, groups) : null
741
+ return (
742
+ <article className="card svy-q">
743
+ <div className="kicker">
744
+ {`Q${question.position + 1} · ${questionAudienceLabel(question.audience)} · ${question.answerCount} answered`}
745
+ </div>
746
+ <h3 className="svy-q-prompt">{question.prompt}</h3>
747
+ <p className="svy-takeaway">{questionTakeaway(question, total)}</p>
748
+
749
+ {comparison && comparison.kind === 'choice' && groups ? (
750
+ <GroupedBars rows={comparison.rows} groups={groups} />
751
+ ) : comparison && comparison.kind === 'scale' && groups ? (
752
+ <MoodBars summaries={comparison.byGroup} groups={groups} />
753
+ ) : question.kind === 'single_choice' || question.kind === 'multi_choice' ? (
754
+ <BarList rows={optionRows(question)} of={question.answerCount} />
755
+ ) : question.kind === 'scale' ? (
756
+ <ScaleColumns question={question} />
757
+ ) : (
758
+ <BarList rows={[{ option: 'Wrote an answer', count: question.answerCount }]} of={total} />
759
+ )}
760
+
761
+ {question.kind === 'multi_choice' ? (
762
+ <p className="svy-note">
763
+ People could pick more than one, so these add up to more than 100%.
764
+ </p>
765
+ ) : null}
766
+ {isSplit && groups ? (
767
+ <p className="svy-note">The groups being compared come from this question.</p>
768
+ ) : null}
769
+ {comparison?.kind === 'none' ? (
770
+ <p className="svy-note">Written answers are not split into groups — read them below.</p>
771
+ ) : null}
772
+ </article>
773
+ )
774
+ }
775
+
776
+ function BarList({ rows, of }: { rows: OptionShare[]; of: number }) {
777
+ const top = Math.max(0, ...rows.map((row) => row.count))
778
+ return (
779
+ <ul className="svy-bars">
780
+ {rows.map((row) => (
781
+ <li
782
+ className="svy-bar"
783
+ key={row.option}
784
+ title={`${row.option}: ${row.count} of ${of} (${percent(row.count, of)})`}
785
+ >
786
+ <span className="svy-bar-label">{row.option}</span>
787
+ <span className="svy-bar-value">
788
+ <strong>{percent(row.count, of)}</strong>
789
+ <span className="svy-count">{String(row.count)}</span>
790
+ </span>
791
+ <span className="svy-track">
792
+ <span
793
+ className={`svy-fill${top > 0 && row.count === top ? ' is-top' : ''}`}
794
+ style={{ width: `${share(row.count, of) * 100}%` }}
795
+ />
796
+ </span>
797
+ </li>
798
+ ))}
799
+ </ul>
800
+ )
801
+ }
802
+
803
+ function GroupedBars({
804
+ rows,
805
+ groups,
806
+ }: {
807
+ rows: Array<{ option: string; byGroup: GroupCount[] }>
808
+ groups: ResponseGroup[]
809
+ }) {
810
+ return (
811
+ <ul className="svy-bars">
812
+ {rows.map((row, rowIndex) => (
813
+ <li className="svy-bar is-grouped" key={row.option}>
814
+ <span className="svy-bar-label">{row.option}</span>
815
+ <span className="svy-gbars">
816
+ {row.byGroup.map((cell, index) => {
817
+ const group = groups[index]
818
+ return (
819
+ <span
820
+ className="svy-gbar"
821
+ key={group.key}
822
+ title={`${group.label}: ${cell.count} of ${cell.answered} (${percent(cell.count, cell.answered)})`}
823
+ >
824
+ <span className="svy-track sm">
825
+ <span
826
+ className={`svy-fill ${groupTone(group, index)}`}
827
+ style={{ width: `${share(cell.count, cell.answered) * 100}%` }}
828
+ />
829
+ </span>
830
+ <span className="svy-gbar-value">
831
+ {/* Direct-label the first row only; the legend carries the rest. */}
832
+ {rowIndex === 0
833
+ ? `${percent(cell.count, cell.answered)} ${group.label}`
834
+ : percent(cell.count, cell.answered)}
835
+ </span>
836
+ </span>
837
+ )
838
+ })}
839
+ </span>
840
+ </li>
841
+ ))}
842
+ </ul>
843
+ )
844
+ }
845
+
846
+ function people(count: number): string {
847
+ return `${count} ${count === 1 ? 'person' : 'people'}`
848
+ }
849
+
850
+ function groupTone(group: ResponseGroup, index: number): string {
851
+ return group.mood ? `mood-${group.mood}` : `g${index}`
852
+ }
853
+
854
+ function MoodBars({ summaries, groups }: { summaries: ScaleSummary[]; groups: ResponseGroup[] }) {
855
+ return (
856
+ <ul className="svy-bars">
857
+ {summaries.map((summary, index) => {
858
+ const group = groups[index]
859
+ return (
860
+ <li
861
+ className="svy-bar"
862
+ key={group.key}
863
+ title={`${group.label}: ${summary.happy} happy, ${summary.neutral} in the middle, ${summary.unhappy} unhappy of ${summary.answered}`}
864
+ >
865
+ {/* Named in text: the bar's colors already mean happy and unhappy. */}
866
+ <span className="svy-bar-label">{group.label}</span>
867
+ <span className="svy-bar-value">
868
+ <strong>{`${percent(summary.happy, summary.answered)} happy`}</strong>
869
+ <span className="svy-count">{`avg ${formatAverage(summary.average)}`}</span>
870
+ </span>
871
+ <span className="svy-track svy-stack">
872
+ {(['unhappy', 'neutral', 'happy'] as const).map((mood) =>
873
+ summary[mood] > 0 ? (
874
+ <span
875
+ className={`svy-seg mood-${mood}`}
876
+ key={mood}
877
+ style={{ width: `${share(summary[mood], summary.answered) * 100}%` }}
878
+ />
879
+ ) : null,
880
+ )}
881
+ </span>
882
+ </li>
883
+ )
884
+ })}
885
+ </ul>
886
+ )
887
+ }
888
+
889
+ // ── What readers wrote ──────────────────────────────────────────────────
890
+
315
891
  type WrittenAnswer = SurveyResults['freeText'][number]
316
892
 
317
893
  // The owner's loud surface for answers auto-labeling has not placed yet
@@ -333,72 +909,216 @@ function personalDetailsNotice(count: number): string {
333
909
  : `${count} answers contain personal details and need your label.`
334
910
  }
335
911
 
336
- function VerbatimFeed({
912
+ function Comments({
337
913
  data,
914
+ offset,
338
915
  canLabel,
339
916
  onSetLabel,
917
+ labelSaveFailed,
918
+ onOffsetChange,
919
+ everyone,
920
+ onLoadEveryone,
921
+ initialMood,
340
922
  }: {
341
923
  data: SurveyResults
924
+ offset: number
342
925
  canLabel: boolean
343
926
  onSetLabel?: (responseId: string, questionId: string, label: SurveyAnswerLabel) => void
927
+ labelSaveFailed: boolean
928
+ onOffsetChange?: (offset: number) => void
929
+ everyone: EveryoneState
930
+ onLoadEveryone?: (total: number) => void
931
+ initialMood: Mood | ''
344
932
  }) {
345
933
  const [labelFilter, setLabelFilter] = useState<'all' | 'general' | 'technical'>('all')
346
- const textQuestionIds = new Set(
347
- data.questions.filter((question) => question.kind === 'text').map((question) => question.id),
348
- )
934
+ const [moodFilter, setMoodFilter] = useState<Mood | ''>(initialMood)
935
+ const total = data.survey.responseCount
936
+ const shown = data.responses.length
937
+ const rangeStart = total === 0 ? 0 : offset + 1
938
+ const rangeEnd = offset + shown
939
+ const hasMore = rangeEnd < total
940
+
941
+ const headline = headlineScale(data.questions)
942
+ const textQuestions = data.questions.filter((question) => question.kind === 'text')
943
+ const textQuestionIds = new Set(textQuestions.map((question) => question.id))
349
944
  const promptById = new Map(data.questions.map((question) => [question.id, question.prompt]))
945
+ const ratingOf = (response: SurveyResponse): number | null => {
946
+ if (!headline) return null
947
+ const value = answerValue(response, headline.id)
948
+ return typeof value === 'number' ? value : null
949
+ }
950
+ const moodOf = (response: SurveyResponse): Mood | null => {
951
+ const rating = ratingOf(response)
952
+ return rating === null || !headline
953
+ ? null
954
+ : scaleMood(rating, headline.options.min, headline.options.max)
955
+ }
956
+ const canFilterByMood = data.responses.some((response) => ratingOf(response) !== null)
957
+ const moodRanges = headline ? summarizeScale(headline.options, {}) : null
958
+
350
959
  // Non-owners never receive technical or unlabeled entries (the route filters
351
960
  // server-side), so for them this feed is already the general thread.
961
+ const writtenAnswers: WrittenAnswer[] = [...data.freeText]
962
+ for (const response of everyone.kind === 'ready' ? everyone.responses : data.responses) {
963
+ for (const answer of response.answers ?? []) {
964
+ if (answer.writtenValue === undefined) continue
965
+ writtenAnswers.push({
966
+ questionId: answer.questionId,
967
+ responseId: response.id,
968
+ submittedAt: response.submittedAt,
969
+ value: answer.writtenValue,
970
+ label: answer.label,
971
+ labelSource: answer.labelSource,
972
+ classificationStatus: answer.classificationStatus,
973
+ nextAttemptAt: answer.nextAttemptAt,
974
+ })
975
+ }
976
+ }
352
977
  const visibleFreeText =
353
- labelFilter === 'all' ? data.freeText : data.freeText.filter((e) => e.label === labelFilter)
354
- const freeTextByResponse = new Map<string, WrittenAnswer[]>()
978
+ labelFilter === 'all' ? writtenAnswers : writtenAnswers.filter((e) => e.label === labelFilter)
979
+ const writtenByResponse = new Map<string, WrittenAnswer[]>()
355
980
  for (const entry of visibleFreeText) {
356
- const entries = freeTextByResponse.get(entry.responseId) ?? []
981
+ const entries = writtenByResponse.get(entry.responseId) ?? []
357
982
  entries.push(entry)
358
- freeTextByResponse.set(entry.responseId, entries)
983
+ writtenByResponse.set(entry.responseId, entries)
359
984
  }
985
+ const wroteSomething = new Set(writtenAnswers.map((entry) => entry.responseId))
986
+
987
+ // A filter answers "what do unhappy readers say", so it searches every
988
+ // response, never just the page on screen. The written answers already
989
+ // arrive for the whole survey; each response's rating and details come
990
+ // from loading every page, exactly as the group comparison does.
991
+ const filtering = moodFilter !== '' || labelFilter !== 'all'
992
+ useEffect(() => {
993
+ if (filtering && everyone.kind === 'idle') onLoadEveryone?.(total)
994
+ }, [filtering, everyone.kind, onLoadEveryone, total])
995
+ const pool: SurveyResponse[] | null = !filtering
996
+ ? data.responses
997
+ : everyone.kind === 'ready'
998
+ ? everyone.responses
999
+ : null
1000
+ const visible = (pool ?? []).filter(
1001
+ (response) =>
1002
+ (writtenByResponse.get(response.id) ?? []).length > 0 &&
1003
+ (!moodFilter || moodOf(response) === moodFilter),
1004
+ )
1005
+ const silent = filtering
1006
+ ? []
1007
+ : data.responses.filter((response) => !wroteSomething.has(response.id))
1008
+ const freeTextCapped = data.freeText.length >= MAX_FREE_TEXT_ENTRIES
360
1009
 
361
1010
  return (
362
- <section aria-labelledby="survey-verbatims" style={{ marginTop: 24 }}>
363
- <div className="page-head">
364
- <h2 className="page-title" id="survey-verbatims" style={{ fontSize: 22 }}>
365
- Responses
366
- </h2>
367
- {canLabel ? (
368
- // biome-ignore lint/a11y/useSemanticElements: chip-row is a flex div throughout the admin UI; a fieldset would import UA border/padding with no reset in the publication CSS, while role="group" + aria-label keeps the grouping semantics
369
- <div className="chip-row" role="group" aria-label="Filter written answers">
370
- {(['all', 'general', 'technical'] as const).map((option) => (
371
- <button
372
- className="chip"
373
- key={option}
374
- type="button"
375
- aria-pressed={labelFilter === option}
376
- onClick={() => setLabelFilter(option)}
1011
+ <section aria-labelledby="survey-verbatims" className="svy-section">
1012
+ <div className="page-head svy-section-head">
1013
+ <div>
1014
+ <h2 className="page-title svy-h2" id="survey-verbatims">
1015
+ What readers wrote
1016
+ </h2>
1017
+ {textQuestions.length > 0 ? (
1018
+ <p className="page-sub">{textQuestions.map((q) => `“${q.prompt}”`).join(' · ')}</p>
1019
+ ) : null}
1020
+ {/* Never a truncated list under a full count: say exactly what is on screen. */}
1021
+ <p className="page-sub">
1022
+ {!filtering
1023
+ ? `Showing ${rangeStart}–${rangeEnd} of ${total} ${total === 1 ? 'response' : 'responses'}`
1024
+ : pool
1025
+ ? `${visible.length} ${visible.length === 1 ? 'comment matches' : 'comments match'} these filters, searched across all ${pool.length} responses`
1026
+ : `Loading all ${total} responses to search every comment…`}
1027
+ </p>
1028
+ </div>
1029
+ <div className="svy-filters">
1030
+ {canFilterByMood && moodRanges ? (
1031
+ <label className="svy-control">
1032
+ <span className="kicker">From</span>
1033
+ <select
1034
+ className="selectbox"
1035
+ value={moodFilter}
1036
+ onChange={(event) => setMoodFilter(event.target.value as Mood | '')}
377
1037
  >
378
- {option === 'all' ? 'All' : option === 'general' ? 'General' : 'Technical'}
379
- </button>
380
- ))}
381
- </div>
382
- ) : null}
1038
+ <option value="">All readers</option>
1039
+ <option value="unhappy">{`Unhappy readers (${moodRanges.unhappyRange})`}</option>
1040
+ {moodRanges.neutralValue !== null ? (
1041
+ <option value="neutral">{`Readers in the middle (${moodRanges.neutralValue})`}</option>
1042
+ ) : null}
1043
+ <option value="happy">{`Happy readers (${moodRanges.happyRange})`}</option>
1044
+ </select>
1045
+ </label>
1046
+ ) : null}
1047
+ {canLabel ? (
1048
+ // biome-ignore lint/a11y/useSemanticElements: chip-row is a flex div throughout the admin UI; a fieldset would import UA border/padding with no reset in the publication CSS, while role="group" + aria-label keeps the grouping semantics
1049
+ <div className="chip-row" role="group" aria-label="Filter written answers">
1050
+ {(['all', 'general', 'technical'] as const).map((option) => (
1051
+ <button
1052
+ className="chip"
1053
+ key={option}
1054
+ type="button"
1055
+ aria-pressed={labelFilter === option}
1056
+ onClick={() => setLabelFilter(option)}
1057
+ >
1058
+ {option === 'all' ? 'All' : option === 'general' ? 'General' : 'Technical'}
1059
+ </button>
1060
+ ))}
1061
+ </div>
1062
+ ) : null}
1063
+ </div>
383
1064
  </div>
384
- <div style={{ marginTop: 12 }}>
385
- {data.responses.map((response) => {
386
- const written = freeTextByResponse.get(response.id) ?? []
387
- const choices = (response.answers ?? []).filter(
388
- (answer) => !textQuestionIds.has(answer.questionId),
389
- )
390
- return (
391
- <article className="card" key={response.id} style={{ marginTop: 12 }}>
392
- <div className="kicker">
393
- <time dateTime={response.submittedAt}>{formatTime(response.submittedAt)}</time> ·{' '}
394
- {segmentLabel(response.respondentSegment)} · {respondentLabel(response)}
395
- </div>
396
- {written.length === 0 ? (
397
- <p className="page-sub">No written answer.</p>
398
- ) : (
399
- written.map((entry) => (
400
- <div key={`${response.id}:${entry.questionId}`}>
401
- <blockquote>{entry.value}</blockquote>
1065
+
1066
+ {canLabel && (data.unlabeledCount ?? 0) > 0 ? (
1067
+ <p className="svy-callout" role="status">
1068
+ {unlabeledAnswersNotice(data.unlabeledCount ?? 0)}
1069
+ </p>
1070
+ ) : null}
1071
+ {canLabel && (data.personalDetailsCount ?? 0) > 0 ? (
1072
+ <p className="svy-callout" role="status">
1073
+ {personalDetailsNotice(data.personalDetailsCount ?? 0)}
1074
+ </p>
1075
+ ) : null}
1076
+ {labelSaveFailed && canLabel ? (
1077
+ <p className="svy-callout is-alert" role="alert">
1078
+ The last label change could not be saved. Try again.
1079
+ </p>
1080
+ ) : null}
1081
+
1082
+ {filtering && everyone.kind === 'failed' ? (
1083
+ <div className="svy-callout is-alert" role="alert">
1084
+ Not every response could be loaded, so the filter has nothing complete to search — a
1085
+ partial search would hide comments.{' '}
1086
+ {onLoadEveryone ? (
1087
+ <button className="btn sm" type="button" onClick={() => onLoadEveryone(total)}>
1088
+ Retry
1089
+ </button>
1090
+ ) : null}
1091
+ </div>
1092
+ ) : null}
1093
+ {filtering && pool && freeTextCapped ? (
1094
+ <p className="svy-callout" role="status">
1095
+ {`Only the newest ${MAX_FREE_TEXT_ENTRIES} written answers reach this screen, so older ones are not searched. Export CSV has every answer.`}
1096
+ </p>
1097
+ ) : null}
1098
+
1099
+ {!pool ? null : visible.length === 0 ? (
1100
+ <p className="page-sub">
1101
+ {filtering ? 'No comments match these filters.' : 'No comments on this page.'}
1102
+ </p>
1103
+ ) : (
1104
+ <div className="svy-comments">
1105
+ {visible.map((response) => {
1106
+ const rating = ratingOf(response)
1107
+ const mood = moodOf(response)
1108
+ const others = (response.answers ?? []).filter(
1109
+ (answer) =>
1110
+ !textQuestionIds.has(answer.questionId) &&
1111
+ answer.writtenValue === undefined &&
1112
+ answer.questionId !== headline?.id,
1113
+ )
1114
+ return (
1115
+ <article className="card svy-comment" key={response.id}>
1116
+ {(writtenByResponse.get(response.id) ?? []).map((entry) => (
1117
+ <div className="svy-comment-entry" key={`${response.id}:${entry.questionId}`}>
1118
+ {textQuestions.length > 1 || !textQuestionIds.has(entry.questionId) ? (
1119
+ <div className="kicker">{promptById.get(entry.questionId)}</div>
1120
+ ) : null}
1121
+ <blockquote className="svy-quote">{entry.value}</blockquote>
402
1122
  {canLabel && onSetLabel ? (
403
1123
  // biome-ignore lint/a11y/useSemanticElements: chip-row is a flex div throughout the admin UI; a fieldset would import UA border/padding with no reset in the publication CSS, while role="group" + aria-label keeps the grouping semantics
404
1124
  <div
@@ -424,9 +1144,24 @@ function VerbatimFeed({
424
1144
  >
425
1145
  General
426
1146
  </button>
1147
+ {!entry.label && entry.classificationStatus ? (
1148
+ <span className="svy-meta" role="status">
1149
+ {entry.classificationStatus === 'pending'
1150
+ ? 'Classification in progress. Reload to check the result.'
1151
+ : entry.classificationStatus === 'manual_review'
1152
+ ? 'Choose a label manually.'
1153
+ : entry.classificationStatus === 'retry'
1154
+ ? `Classification failed. ${entry.nextAttemptAt ? `Retry eligible after ${formatTime(new Date(entry.nextAttemptAt).toISOString())}. ` : ''}Reload to retry, or choose a label.`
1155
+ : entry.classificationStatus === 'expired'
1156
+ ? 'Classification timed out. Reload to schedule a retry, or choose a label.'
1157
+ : entry.classificationStatus === 'unavailable'
1158
+ ? 'Classification status unavailable. Reload or choose a label.'
1159
+ : 'Awaiting classification. Reload to check, or choose a label.'}
1160
+ </span>
1161
+ ) : null}
427
1162
  {entry.labelSource === 'auto' ? (
428
1163
  <span
429
- className="chip"
1164
+ className="chip is-quiet"
430
1165
  title="Labeled automatically — your choice overrides it"
431
1166
  >
432
1167
  auto
@@ -435,167 +1170,90 @@ function VerbatimFeed({
435
1170
  </div>
436
1171
  ) : null}
437
1172
  </div>
438
- ))
439
- )}
440
- <div className="chip-row">
441
- {choices.map((answer) => (
442
- <span
443
- className="chip"
444
- key={`${response.id}:${answer.questionId}`}
445
- title={promptById.get(answer.questionId) ?? answer.questionId}
446
- >
447
- {formatAnswerValue(answer.value)}
448
- </span>
449
1173
  ))}
450
- </div>
451
- </article>
452
- )
453
- })}
454
- </div>
455
- </section>
456
- )
457
- }
1174
+ <div className="svy-meta">
1175
+ {rating !== null && headline && mood ? (
1176
+ <span className="svy-rating" title={headline.prompt}>
1177
+ <span className={`svy-swatch mood-${mood}`} aria-hidden="true" />
1178
+ {`Rated ${rating} of ${headline.options.max}`}
1179
+ </span>
1180
+ ) : null}
1181
+ <span>{segmentLabel(response.respondentSegment)}</span>
1182
+ <span>{respondentLabel(response)}</span>
1183
+ <time dateTime={response.submittedAt} title={formatTime(response.submittedAt)}>
1184
+ {formatDate(response.submittedAt)}
1185
+ </time>
1186
+ </div>
1187
+ {others.length > 0 ? (
1188
+ <details className="svy-answers">
1189
+ <summary>Their other answers</summary>
1190
+ <dl>
1191
+ {others.map((answer) => (
1192
+ <div key={answer.questionId}>
1193
+ <dt>{promptById.get(answer.questionId) ?? answer.questionId}</dt>
1194
+ <dd>{formatAnswerValue(answer.value)}</dd>
1195
+ </div>
1196
+ ))}
1197
+ </dl>
1198
+ </details>
1199
+ ) : null}
1200
+ </article>
1201
+ )
1202
+ })}
1203
+ </div>
1204
+ )}
458
1205
 
459
- function Aggregates({ data }: { data: SurveyResults }) {
460
- // Flat counts cannot express the finding that matters most — how one cohort
461
- // differs from another — so every aggregate can be split by another question.
462
- const splitCandidates = data.questions.filter(
463
- (question) => question.kind === 'single_choice' || question.kind === 'scale',
464
- )
465
- const [splitBy, setSplitBy] = useState<string>('')
466
- const splitQuestion = splitCandidates.find((question) => question.id === splitBy)
467
-
468
- const answersByResponse = useMemo(() => {
469
- const map = new Map<string, Map<string, unknown>>()
470
- for (const response of data.responses) {
471
- map.set(
472
- response.id,
473
- new Map((response.answers ?? []).map((answer) => [answer.questionId, answer.value])),
474
- )
475
- }
476
- return map
477
- }, [data.responses])
478
-
479
- const cohorts = useMemo(() => {
480
- if (!splitQuestion) return null
481
- const buckets = new Map<string, string[]>()
482
- for (const response of data.responses) {
483
- const value = answersByResponse.get(response.id)?.get(splitQuestion.id)
484
- if (value === undefined) continue
485
- const key = formatAnswerValue(value)
486
- const ids = buckets.get(key) ?? []
487
- ids.push(response.id)
488
- buckets.set(key, ids)
489
- }
490
- return buckets
491
- }, [answersByResponse, data.responses, splitQuestion])
1206
+ {silent.length > 0 ? (
1207
+ <details className="svy-silent">
1208
+ <summary>
1209
+ {`${silent.length} ${silent.length === 1 ? 'response' : 'responses'} on this page left no comment`}
1210
+ </summary>
1211
+ <ul>
1212
+ {silent.map((response) => {
1213
+ const rating = ratingOf(response)
1214
+ return (
1215
+ <li key={response.id}>
1216
+ {[
1217
+ formatDate(response.submittedAt),
1218
+ segmentLabel(response.respondentSegment),
1219
+ respondentLabel(response),
1220
+ rating !== null && headline
1221
+ ? `rated ${rating} of ${headline.options.max}`
1222
+ : null,
1223
+ ]
1224
+ .filter(Boolean)
1225
+ .join(' · ')}
1226
+ </li>
1227
+ )
1228
+ })}
1229
+ </ul>
1230
+ </details>
1231
+ ) : null}
492
1232
 
493
- return (
494
- <section aria-labelledby="survey-aggregates" style={{ marginTop: 24 }}>
495
- <div className="page-head">
496
- <h2 className="page-title" id="survey-aggregates" style={{ fontSize: 22 }}>
497
- Question aggregates
498
- </h2>
499
- <label>
500
- <span className="page-sub">Split by </span>
501
- <select value={splitBy} onChange={(event) => setSplitBy(event.target.value)}>
502
- <option value="">No split</option>
503
- {splitCandidates.map((question) => (
504
- <option key={question.id} value={question.id}>
505
- {question.prompt}
506
- </option>
507
- ))}
508
- </select>
509
- </label>
510
- </div>
511
- <div className="grid-split-wide" style={{ alignItems: 'start', marginTop: 12 }}>
512
- {data.questions.map((question) => (
513
- <article className="card" key={question.id}>
514
- <div className="kicker">
515
- Q{question.position + 1} · {questionAudienceLabel(question.audience)}
516
- </div>
517
- <h3 className="card-title">{question.prompt}</h3>
518
- {question.counts && <CountTable values={question.counts} />}
519
- {question.distribution && (
520
- <>
521
- <p className="page-sub">
522
- Average: {question.average === null ? '—' : question.average.toFixed(2)}
523
- </p>
524
- <CountTable values={question.distribution} />
525
- </>
526
- )}
527
- {question.kind === 'text' && (
528
- <p className="page-sub">{question.responseCount ?? 0} written answers</p>
529
- )}
530
- {cohorts && splitQuestion && splitQuestion.id !== question.id ? (
531
- <CrossTab
532
- question={question}
533
- cohorts={cohorts}
534
- answersByResponse={answersByResponse}
535
- />
536
- ) : null}
537
- </article>
538
- ))}
539
- </div>
1233
+ {!filtering && (offset > 0 || hasMore) && onOffsetChange ? (
1234
+ <div className="svy-pager">
1235
+ <button
1236
+ className="btn"
1237
+ type="button"
1238
+ disabled={offset === 0}
1239
+ onClick={() => onOffsetChange(Math.max(offset - PAGE_SIZE, 0))}
1240
+ >
1241
+ Previous
1242
+ </button>
1243
+ <button
1244
+ className="btn"
1245
+ type="button"
1246
+ disabled={!hasMore}
1247
+ onClick={() => onOffsetChange(offset + PAGE_SIZE)}
1248
+ >
1249
+ Next
1250
+ </button>
1251
+ </div>
1252
+ ) : null}
540
1253
  </section>
541
1254
  )
542
1255
  }
543
1256
 
544
- function CrossTab({
545
- question,
546
- cohorts,
547
- answersByResponse,
548
- }: {
549
- question: SurveyQuestion
550
- cohorts: Map<string, string[]>
551
- answersByResponse: Map<string, Map<string, unknown>>
552
- }) {
553
- const rows = [...cohorts.entries()].map(([cohort, responseIds]) => {
554
- const values = responseIds
555
- .map((id) => answersByResponse.get(id)?.get(question.id))
556
- .filter((value) => value !== undefined)
557
- if (question.kind === 'scale') {
558
- const numbers = values.filter((value): value is number => typeof value === 'number')
559
- const average = numbers.length
560
- ? numbers.reduce((sum, value) => sum + value, 0) / numbers.length
561
- : null
562
- return { cohort, n: values.length, summary: average === null ? '—' : average.toFixed(2) }
563
- }
564
- const counts = new Map<string, number>()
565
- for (const value of values.flatMap((value) => (Array.isArray(value) ? value : [value]))) {
566
- const key = formatAnswerValue(value)
567
- counts.set(key, (counts.get(key) ?? 0) + 1)
568
- }
569
- const top = [...counts.entries()].sort((left, right) => right[1] - left[1])[0]
570
- return { cohort, n: values.length, summary: top ? `${top[0]} (${top[1]})` : '—' }
571
- })
572
-
573
- if (rows.length === 0) return null
574
- return (
575
- <>
576
- <div className="masthead-rule" />
577
- <table className="tbl">
578
- <thead>
579
- <tr>
580
- <th>Cohort</th>
581
- <th>n</th>
582
- <th>{question.kind === 'scale' ? 'Average' : 'Most common'}</th>
583
- </tr>
584
- </thead>
585
- <tbody>
586
- {rows.map((row) => (
587
- <tr key={row.cohort}>
588
- <td>{row.cohort}</td>
589
- <td>{row.n}</td>
590
- <td>{row.summary}</td>
591
- </tr>
592
- ))}
593
- </tbody>
594
- </table>
595
- </>
596
- )
597
- }
598
-
599
1257
  async function readSurveyErrorCode(response: Response): Promise<SurveyErrorCode | null> {
600
1258
  const headerCode = response.headers.get(SURVEY_ERROR_CODE_HEADER)
601
1259
  if (!isSurveyErrorCode(headerCode)) return null
@@ -619,7 +1277,7 @@ function respondentLabel(response: SurveyResults['responses'][number]): string {
619
1277
  ? `${response.respondent.label} · ${response.respondent.detail}`
620
1278
  : response.respondent.label
621
1279
  }
622
- return response.anonymous ? 'Anonymous' : 'Known respondent'
1280
+ return response.anonymous ? 'Anonymous' : 'Signed-in reader'
623
1281
  }
624
1282
 
625
1283
  function formatAnswerValue(value: unknown): string {
@@ -631,14 +1289,19 @@ function StatusPage({
631
1289
  title,
632
1290
  detail,
633
1291
  retry,
1292
+ picker = null,
634
1293
  }: {
635
1294
  title: string
636
1295
  detail?: string
637
1296
  retry?: () => void
1297
+ picker?: ReactNode
638
1298
  }) {
639
1299
  return (
640
1300
  <div className="page" role="status">
641
- <h1 className="page-title">Survey responses</h1>
1301
+ <div className="page-head">
1302
+ <h1 className="page-title">Survey responses</h1>
1303
+ {picker}
1304
+ </div>
642
1305
  <p className="page-sub">{title}</p>
643
1306
  {detail ? <p className="page-sub">{detail}</p> : null}
644
1307
  {retry ? (
@@ -650,36 +1313,32 @@ function StatusPage({
650
1313
  )
651
1314
  }
652
1315
 
653
- function CountTable({ values }: { values: Record<string, number> }) {
654
- const total = Object.values(values).reduce((sum, count) => sum + count, 0)
655
- return (
656
- <dl className="count-list">
657
- {Object.entries(values).map(([label, count]) => (
658
- <div key={label}>
659
- <dt>{label}</dt>
660
- <dd>
661
- {count}
662
- {total > 0 ? ` · ${Math.round((count / total) * 100)}%` : ''}
663
- </dd>
664
- </div>
665
- ))}
666
- </dl>
667
- )
1316
+ function surveyStatusLabel(status: SurveyResults['survey']['status']): string {
1317
+ if (status === 'active') return 'open'
1318
+ if (status === 'closed') return 'closed'
1319
+ return 'draft'
668
1320
  }
669
1321
 
670
1322
  function segmentLabel(segment: 'member' | 'non_member' | 'unknown'): string {
671
1323
  if (segment === 'member') return 'Member'
672
1324
  if (segment === 'non_member') return 'Non-member'
673
- return 'Unknown'
1325
+ return 'Membership unknown'
674
1326
  }
675
1327
 
676
1328
  function questionAudienceLabel(audience: 'all' | 'member' | 'non_member'): string {
677
- if (audience === 'member') return 'Members'
678
- if (audience === 'non_member') return 'Non-members'
679
- return 'Everyone'
1329
+ if (audience === 'member') return 'asked members'
1330
+ if (audience === 'non_member') return 'asked non-members'
1331
+ return 'asked everyone'
680
1332
  }
681
1333
 
682
1334
  function formatTime(value: string): string {
683
1335
  const date = new Date(value)
684
1336
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
685
1337
  }
1338
+
1339
+ function formatDate(value: string): string {
1340
+ const date = new Date(value)
1341
+ return Number.isNaN(date.getTime())
1342
+ ? value
1343
+ : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
1344
+ }