@growth-labs/cms 0.8.24 → 0.8.26

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 (39) hide show
  1. package/dist/engine/published-content.d.ts.map +1 -1
  2. package/dist/engine/published-content.js +9 -3
  3. package/dist/engine/published-content.js.map +1 -1
  4. package/dist/providers/types.d.ts +7 -1
  5. package/dist/providers/types.d.ts.map +1 -1
  6. package/dist/providers/types.js.map +1 -1
  7. package/dist/schema/migrations.d.ts.map +1 -1
  8. package/dist/schema/migrations.js +7 -4
  9. package/dist/schema/migrations.js.map +1 -1
  10. package/dist/surveys/readout.d.ts +69 -0
  11. package/dist/surveys/readout.d.ts.map +1 -0
  12. package/dist/surveys/readout.js +157 -0
  13. package/dist/surveys/readout.js.map +1 -0
  14. package/dist/surveys/schema.d.ts +1 -0
  15. package/dist/surveys/schema.d.ts.map +1 -1
  16. package/dist/surveys/schema.js +1 -1
  17. package/dist/surveys/schema.js.map +1 -1
  18. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
  19. package/dist/ui/screens/PodcastListeningScreen.js +16 -2
  20. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
  21. package/dist/ui/screens/SurveyResponsesScreen.d.ts +19 -1
  22. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  23. package/dist/ui/screens/SurveyResponsesScreen.js +203 -96
  24. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  25. package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
  26. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
  27. package/dist/ui/screens/podcast-listening-data.js +26 -1
  28. package/dist/ui/screens/podcast-listening-data.js.map +1 -1
  29. package/dist/ui/styles/broadsheet.css +135 -0
  30. package/package.json +2 -3
  31. package/src/engine/published-content.ts +11 -3
  32. package/src/providers/types.ts +7 -1
  33. package/src/schema/migrations.ts +7 -4
  34. package/src/surveys/readout.ts +243 -0
  35. package/src/surveys/schema.ts +1 -1
  36. package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
  37. package/src/ui/screens/SurveyResponsesScreen.tsx +769 -290
  38. package/src/ui/screens/podcast-listening-data.ts +33 -1
  39. package/src/ui/styles/broadsheet.css +135 -0
@@ -8,6 +8,28 @@ 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,
12
34
  type SurveyQuestion,
13
35
  type SurveyResults,
@@ -25,6 +47,15 @@ export type SurveyResponsesState =
25
47
  | { kind: 'auth_failed' }
26
48
  | { kind: 'retryable' }
27
49
 
50
+ /** Every response, read page by page, so a group comparison covers the whole survey. */
51
+ export type EveryoneState =
52
+ | { kind: 'idle' }
53
+ | { kind: 'loading' }
54
+ | { kind: 'ready'; responses: SurveyResponse[] }
55
+ | { kind: 'failed' }
56
+
57
+ type ScaleQuestion = Extract<SurveyQuestion, { kind: 'scale' }>
58
+
28
59
  export function parseSurveyResultsPayload(value: unknown): SurveyResults | null {
29
60
  const parsed = SurveyResultsSchema.safeParse(value)
30
61
  return parsed.success ? parsed.data : null
@@ -55,11 +86,14 @@ export async function classifySurveyResultsResponse(
55
86
  }
56
87
 
57
88
  const PAGE_SIZE = 100
89
+ // Five groups is as many as a reader can tell apart by color; more fold into a note.
90
+ const MAX_GROUPS = 5
58
91
 
59
92
  export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
60
93
  const [state, setState] = useState<SurveyResponsesState>({ kind: 'loading' })
61
94
  const [offset, setOffset] = useState(0)
62
95
  const [labelSaveFailed, setLabelSaveFailed] = useState(false)
96
+ const [everyone, setEveryone] = useState<EveryoneState>({ kind: 'idle' })
63
97
  const load = useCallback(
64
98
  async (nextOffset: number) => {
65
99
  setState({ kind: 'loading' })
@@ -75,6 +109,34 @@ export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
75
109
  [surveyId],
76
110
  )
77
111
 
112
+ // Group comparisons are worked out in the browser, so they read every page
113
+ // first: a comparison over one page of 100 would sit beside survey-wide
114
+ // totals while describing different people. Pages load one after another
115
+ // to keep the reporting upstream's load flat; any failed page fails the
116
+ // whole comparison rather than quietly comparing a subset.
117
+ const loadEveryone = useCallback(
118
+ async (total: number) => {
119
+ setEveryone({ kind: 'loading' })
120
+ const responses = new Map<string, SurveyResponse>()
121
+ try {
122
+ for (let pageOffset = 0; pageOffset < total; pageOffset += PAGE_SIZE) {
123
+ const page = await classifySurveyResultsResponse(
124
+ await fetch(`/admin/api/surveys/${encodeURIComponent(surveyId)}?offset=${pageOffset}`),
125
+ )
126
+ if (page.kind !== 'ready') {
127
+ setEveryone({ kind: 'failed' })
128
+ return
129
+ }
130
+ for (const response of page.data.responses) responses.set(response.id, response)
131
+ }
132
+ setEveryone({ kind: 'ready', responses: [...responses.values()] })
133
+ } catch {
134
+ setEveryone({ kind: 'failed' })
135
+ }
136
+ },
137
+ [surveyId],
138
+ )
139
+
78
140
  // Owner-only classification toggle: on success the label is applied to the
79
141
  // already-loaded page (no refetch); on failure the toggle stays honest on
80
142
  // its old value and a note says the save did not land.
@@ -125,6 +187,8 @@ export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
125
187
  onOffsetChange={setOffset}
126
188
  onSetLabel={setLabel}
127
189
  labelSaveFailed={labelSaveFailed}
190
+ everyone={everyone}
191
+ onLoadEveryone={(total) => void loadEveryone(total)}
128
192
  />
129
193
  )
130
194
  }
@@ -156,6 +220,10 @@ export function SurveyResponsesBody({
156
220
  onOffsetChange,
157
221
  onSetLabel,
158
222
  labelSaveFailed = false,
223
+ everyone = { kind: 'idle' },
224
+ onLoadEveryone,
225
+ compareBy = '',
226
+ commentMood = '',
159
227
  }: {
160
228
  surveyId: string
161
229
  state: SurveyResponsesState
@@ -165,6 +233,12 @@ export function SurveyResponsesBody({
165
233
  onOffsetChange?: (offset: number) => void
166
234
  onSetLabel?: (responseId: string, questionId: string, label: SurveyAnswerLabel) => void
167
235
  labelSaveFailed?: boolean
236
+ everyone?: EveryoneState
237
+ onLoadEveryone?: (total: number) => void
238
+ /** The comparison to open with; the screen starts with none. */
239
+ compareBy?: string
240
+ /** The comment mood filter to open with; the screen starts with all readers. */
241
+ commentMood?: Mood | ''
168
242
  }) {
169
243
  const canSeeVerbatims = canViewSurveyVerbatims(role)
170
244
  const canLabel = canLabelSurveyAnswers(role)
@@ -196,23 +270,19 @@ export function SurveyResponsesBody({
196
270
  )
197
271
 
198
272
  const { data } = state
199
- const shown = data.responses.length
200
273
  const total = data.survey.responseCount
201
- const rangeStart = total === 0 ? 0 : offset + 1
202
- const rangeEnd = offset + shown
203
- const hasMore = rangeEnd < total
274
+ const headline = headlineScale(data.questions)
204
275
 
205
276
  return (
206
- <div className="page page-wide fade-in" style={{ maxWidth: 1340 }}>
277
+ <div className="page page-wide fade-in svy" style={{ maxWidth: 1340 }}>
207
278
  <div className="page-head">
208
279
  <div>
280
+ <div className="kicker">Survey results</div>
209
281
  <h1 className="page-title">{data.survey.title}</h1>
210
- {/* Never a truncated list under a full count: say exactly what is on screen. */}
211
282
  <div className="page-sub">
212
283
  {total === 0
213
284
  ? 'No responses yet'
214
- : `Showing ${rangeStart}–${rangeEnd} of ${total} ${total === 1 ? 'response' : 'responses'}`}{' '}
215
- · {data.survey.status}
285
+ : `${total} ${total === 1 ? 'response' : 'responses'} · ${surveyStatusLabel(data.survey.status)}`}
216
286
  </div>
217
287
  </div>
218
288
  <a
@@ -226,92 +296,456 @@ export function SurveyResponsesBody({
226
296
  {total === 0 ? (
227
297
  <div className="card" role="status">
228
298
  <h2 className="card-title">No responses yet</h2>
229
- <p className="page-sub">Aggregates and answers will appear after submission.</p>
299
+ <p className="page-sub">Charts and comments will appear after the first submission.</p>
230
300
  </div>
231
301
  ) : (
232
302
  <>
233
- <HeadlineScale questions={data.questions} />
303
+ {headline && headline.answerCount > 0 ? <Headline question={headline} /> : null}
304
+
305
+ <Results
306
+ data={data}
307
+ everyone={everyone}
308
+ onLoadEveryone={onLoadEveryone}
309
+ initialCompareBy={compareBy}
310
+ />
234
311
 
235
312
  {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
- </>
313
+ <Comments
314
+ data={data}
315
+ offset={offset}
316
+ canLabel={canLabel}
317
+ onSetLabel={onSetLabel}
318
+ labelSaveFailed={labelSaveFailed}
319
+ onOffsetChange={onOffsetChange}
320
+ everyone={everyone}
321
+ onLoadEveryone={onLoadEveryone}
322
+ initialMood={commentMood}
323
+ />
254
324
  ) : (
255
- <div className="card" role="status" style={{ marginTop: 24 }}>
325
+ <div className="card svy-section" role="status">
256
326
  <h2 className="card-title">Individual answers are restricted</h2>
257
327
  <p className="page-sub">
258
- Aggregates below are available to your role. Verbatim answers and respondent
259
- attribution require owner or senior editor.
328
+ The charts above are available to your role. What readers wrote, and who wrote it,
329
+ requires owner or senior editor.
260
330
  </p>
261
331
  </div>
262
332
  )}
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
333
  </>
287
334
  )}
288
335
  </div>
289
336
  )
290
337
  }
291
338
 
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)
339
+ // ── Headline ────────────────────────────────────────────────────────────
302
340
 
341
+ // The first scale question is the survey's headline number. Leading with the
342
+ // distribution keeps one harsh comment from setting the tone of a readout.
343
+ function Headline({ question }: { question: ScaleQuestion }) {
344
+ const summary = summarizeScale(question.options, question.distribution)
303
345
  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)}
346
+ <section className="card svy-headline" aria-labelledby="survey-headline">
347
+ <div className="kicker">{`Headline · Q${question.position + 1} · ${summary.answered} answered`}</div>
348
+ <h2 className="svy-headline-prompt" id="survey-headline">
349
+ {question.prompt}
309
350
  </h2>
310
- <CountTable values={scale.distribution} />
351
+ <div className="svy-headline-body">
352
+ <div>
353
+ <div className="svy-big">{percent(summary.happy, summary.answered)}</div>
354
+ <div className="svy-big-label">{`are happy — they rated it ${summary.happyRange} out of ${summary.max}`}</div>
355
+ <dl className="svy-facts">
356
+ <div>
357
+ <dt>
358
+ <span className="svy-swatch mood-unhappy" aria-hidden="true" />
359
+ {`Unhappy (${summary.unhappyRange})`}
360
+ </dt>
361
+ <dd>{`${percent(summary.unhappy, summary.answered)} · ${people(summary.unhappy)}`}</dd>
362
+ </div>
363
+ {summary.neutralValue !== null ? (
364
+ <div>
365
+ <dt>
366
+ <span className="svy-swatch mood-neutral" aria-hidden="true" />
367
+ {`In the middle (${summary.neutralValue})`}
368
+ </dt>
369
+ <dd>{`${percent(summary.neutral, summary.answered)} · ${people(summary.neutral)}`}</dd>
370
+ </div>
371
+ ) : null}
372
+ <div>
373
+ <dt>
374
+ <span className="svy-swatch mood-happy" aria-hidden="true" />
375
+ {`Happy (${summary.happyRange})`}
376
+ </dt>
377
+ <dd>{`${percent(summary.happy, summary.answered)} · ${people(summary.happy)}`}</dd>
378
+ </div>
379
+ <div>
380
+ <dt>Average</dt>
381
+ <dd>{`${formatAverage(summary.average)} out of ${summary.max}`}</dd>
382
+ </div>
383
+ </dl>
384
+ </div>
385
+ <ScaleColumns question={question} />
386
+ </div>
311
387
  </section>
312
388
  )
313
389
  }
314
390
 
391
+ function ScaleColumns({ question }: { question: ScaleQuestion }) {
392
+ const { min, max } = question.options
393
+ const summary = summarizeScale(question.options, question.distribution)
394
+ const values = Array.from({ length: max - min + 1 }, (_, index) => min + index)
395
+ const peak = Math.max(1, ...values.map((value) => question.distribution[String(value)] ?? 0))
396
+ const columns = { gridTemplateColumns: `repeat(${values.length}, minmax(0, 1fr))` }
397
+ return (
398
+ <figure className="svy-cols" aria-label={`How people rated it, from ${min} to ${max}`}>
399
+ <div className="svy-cols-plot" style={columns}>
400
+ {values.map((value) => {
401
+ const count = question.distribution[String(value)] ?? 0
402
+ return (
403
+ <div
404
+ className="svy-col"
405
+ key={value}
406
+ title={`Rated ${value}: ${people(count)} (${percent(count, summary.answered)})`}
407
+ >
408
+ <span className="svy-col-value">{percent(count, summary.answered)}</span>
409
+ <span
410
+ className={`svy-col-bar mood-${scaleMood(value, min, max)}`}
411
+ style={{ height: `${(count / peak) * 100}%` }}
412
+ />
413
+ </div>
414
+ )
415
+ })}
416
+ </div>
417
+ <div className="svy-cols-axis" style={columns}>
418
+ {values.map((value) => (
419
+ <span key={value}>{value}</span>
420
+ ))}
421
+ </div>
422
+ {summary.average !== null ? (
423
+ <div className="svy-cols-avg">
424
+ <span style={{ left: `${((summary.average - min + 0.5) / values.length) * 100}%` }}>
425
+ {`▲ average ${formatAverage(summary.average)}`}
426
+ </span>
427
+ </div>
428
+ ) : null}
429
+ <figcaption className="svy-cols-caption">
430
+ <span>{`${min} = lowest`}</span>
431
+ <span>{`highest = ${max}`}</span>
432
+ </figcaption>
433
+ </figure>
434
+ )
435
+ }
436
+
437
+ // ── Results by question ─────────────────────────────────────────────────
438
+
439
+ function Results({
440
+ data,
441
+ everyone,
442
+ onLoadEveryone,
443
+ initialCompareBy,
444
+ }: {
445
+ data: SurveyResults
446
+ everyone: EveryoneState
447
+ onLoadEveryone?: (total: number) => void
448
+ initialCompareBy: string
449
+ }) {
450
+ const [compareBy, setCompareBy] = useState(initialCompareBy)
451
+ const total = data.survey.responseCount
452
+ // Comparing by an answer needs each person's answers. A provider that sends
453
+ // none can still compare members with non-members, so only that is offered.
454
+ const hasAnswers = data.responses.some((response) => (response.answers ?? []).length > 0)
455
+ const candidates = hasAnswers ? splitQuestions(data.questions) : []
456
+
457
+ useEffect(() => {
458
+ if (compareBy && everyone.kind === 'idle') onLoadEveryone?.(total)
459
+ }, [compareBy, everyone.kind, onLoadEveryone, total])
460
+
461
+ const allGroups = useMemo(
462
+ () =>
463
+ compareBy && everyone.kind === 'ready'
464
+ ? groupResponses(everyone.responses, compareBy, data.questions)
465
+ : null,
466
+ [compareBy, everyone, data.questions],
467
+ )
468
+ const groups = allGroups?.slice(0, MAX_GROUPS) ?? null
469
+ const headlineId = headlineScale(data.questions)?.id
470
+ const gridQuestions = data.questions.filter((question) => groups || question.id !== headlineId)
471
+
472
+ return (
473
+ <section aria-labelledby="survey-results" className="svy-section">
474
+ <div className="page-head svy-section-head">
475
+ <div>
476
+ <h2 className="page-title svy-h2" id="survey-results">
477
+ What readers told us
478
+ </h2>
479
+ <p className="page-sub">
480
+ {`Each chart covers all ${total} responses. Percentages are of the people who answered that question.`}
481
+ </p>
482
+ </div>
483
+ <label className="svy-control">
484
+ <span className="kicker">Compare</span>
485
+ <select
486
+ className="selectbox"
487
+ value={compareBy}
488
+ onChange={(event) => setCompareBy(event.target.value)}
489
+ >
490
+ <option value="">Everyone together</option>
491
+ <option value={MEMBERSHIP_SPLIT}>Members vs non-members</option>
492
+ {candidates.map((question) => (
493
+ <option key={question.id} value={question.id}>
494
+ {question.kind === 'scale'
495
+ ? `Happy vs unhappy — ${question.prompt}`
496
+ : `By answer — ${question.prompt}`}
497
+ </option>
498
+ ))}
499
+ </select>
500
+ </label>
501
+ </div>
502
+
503
+ {compareBy ? (
504
+ <CompareStatus
505
+ everyone={everyone}
506
+ total={total}
507
+ groups={groups}
508
+ hiddenGroups={(allGroups?.length ?? 0) - (groups?.length ?? 0)}
509
+ onRetry={onLoadEveryone ? () => onLoadEveryone(total) : undefined}
510
+ />
511
+ ) : null}
512
+
513
+ <div className="svy-grid">
514
+ {/* The headline question already has its own chart above; it returns
515
+ here only when a comparison has something to add to it. */}
516
+ {gridQuestions.map((question) => (
517
+ <QuestionCard
518
+ key={question.id}
519
+ question={question}
520
+ total={total}
521
+ groups={groups}
522
+ isSplit={question.id === compareBy}
523
+ />
524
+ ))}
525
+ </div>
526
+ </section>
527
+ )
528
+ }
529
+
530
+ function CompareStatus({
531
+ everyone,
532
+ total,
533
+ groups,
534
+ hiddenGroups,
535
+ onRetry,
536
+ }: {
537
+ everyone: EveryoneState
538
+ total: number
539
+ groups: ResponseGroup[] | null
540
+ hiddenGroups: number
541
+ onRetry?: () => void
542
+ }) {
543
+ if (everyone.kind === 'failed') {
544
+ return (
545
+ <div className="svy-callout is-alert" role="alert">
546
+ Not every response could be loaded, so groups are not compared — a partial comparison would
547
+ describe the wrong people.{' '}
548
+ {onRetry ? (
549
+ <button className="btn sm" type="button" onClick={onRetry}>
550
+ Retry
551
+ </button>
552
+ ) : null}
553
+ </div>
554
+ )
555
+ }
556
+ if (everyone.kind !== 'ready' || !groups) {
557
+ return (
558
+ <p className="page-sub svy-compare-status" role="status">
559
+ {`Loading all ${total} responses to compare groups…`}
560
+ </p>
561
+ )
562
+ }
563
+ if (groups.length === 0) {
564
+ return (
565
+ <p className="svy-callout" role="status">
566
+ Nobody has answered that question yet, so there are no groups to compare.
567
+ </p>
568
+ )
569
+ }
570
+ return (
571
+ <div className="svy-legend" role="status">
572
+ <span className="page-sub">{`Comparing all ${everyone.responses.length} responses:`}</span>
573
+ {groups.map((group, index) => (
574
+ <span className="svy-legend-item" key={group.key}>
575
+ <span className={`svy-swatch ${groupTone(group, index)}`} aria-hidden="true" />
576
+ {`${group.label} · ${people(group.responses.length)}`}
577
+ </span>
578
+ ))}
579
+ {hiddenGroups > 0 ? (
580
+ <span className="page-sub">{`+${hiddenGroups} smaller ${hiddenGroups === 1 ? 'group' : 'groups'} not shown`}</span>
581
+ ) : null}
582
+ </div>
583
+ )
584
+ }
585
+
586
+ function QuestionCard({
587
+ question,
588
+ total,
589
+ groups,
590
+ isSplit,
591
+ }: {
592
+ question: SurveyQuestion
593
+ total: number
594
+ groups: ResponseGroup[] | null
595
+ isSplit: boolean
596
+ }) {
597
+ const comparison =
598
+ groups && groups.length > 0 && !isSplit ? compareQuestion(question, groups) : null
599
+ return (
600
+ <article className="card svy-q">
601
+ <div className="kicker">
602
+ {`Q${question.position + 1} · ${questionAudienceLabel(question.audience)} · ${question.answerCount} answered`}
603
+ </div>
604
+ <h3 className="svy-q-prompt">{question.prompt}</h3>
605
+ <p className="svy-takeaway">{questionTakeaway(question, total)}</p>
606
+
607
+ {comparison && comparison.kind === 'choice' && groups ? (
608
+ <GroupedBars rows={comparison.rows} groups={groups} />
609
+ ) : comparison && comparison.kind === 'scale' && groups ? (
610
+ <MoodBars summaries={comparison.byGroup} groups={groups} />
611
+ ) : question.kind === 'single_choice' || question.kind === 'multi_choice' ? (
612
+ <BarList rows={optionRows(question)} of={question.answerCount} />
613
+ ) : question.kind === 'scale' ? (
614
+ <ScaleColumns question={question} />
615
+ ) : (
616
+ <BarList rows={[{ option: 'Wrote an answer', count: question.answerCount }]} of={total} />
617
+ )}
618
+
619
+ {question.kind === 'multi_choice' ? (
620
+ <p className="svy-note">
621
+ People could pick more than one, so these add up to more than 100%.
622
+ </p>
623
+ ) : null}
624
+ {isSplit && groups ? (
625
+ <p className="svy-note">The groups being compared come from this question.</p>
626
+ ) : null}
627
+ {comparison?.kind === 'none' ? (
628
+ <p className="svy-note">Written answers are not split into groups — read them below.</p>
629
+ ) : null}
630
+ </article>
631
+ )
632
+ }
633
+
634
+ function BarList({ rows, of }: { rows: OptionShare[]; of: number }) {
635
+ const top = Math.max(0, ...rows.map((row) => row.count))
636
+ return (
637
+ <ul className="svy-bars">
638
+ {rows.map((row) => (
639
+ <li
640
+ className="svy-bar"
641
+ key={row.option}
642
+ title={`${row.option}: ${row.count} of ${of} (${percent(row.count, of)})`}
643
+ >
644
+ <span className="svy-bar-label">{row.option}</span>
645
+ <span className="svy-bar-value">
646
+ <strong>{percent(row.count, of)}</strong>
647
+ <span className="svy-count">{String(row.count)}</span>
648
+ </span>
649
+ <span className="svy-track">
650
+ <span
651
+ className={`svy-fill${top > 0 && row.count === top ? ' is-top' : ''}`}
652
+ style={{ width: `${share(row.count, of) * 100}%` }}
653
+ />
654
+ </span>
655
+ </li>
656
+ ))}
657
+ </ul>
658
+ )
659
+ }
660
+
661
+ function GroupedBars({
662
+ rows,
663
+ groups,
664
+ }: {
665
+ rows: Array<{ option: string; byGroup: GroupCount[] }>
666
+ groups: ResponseGroup[]
667
+ }) {
668
+ return (
669
+ <ul className="svy-bars">
670
+ {rows.map((row, rowIndex) => (
671
+ <li className="svy-bar is-grouped" key={row.option}>
672
+ <span className="svy-bar-label">{row.option}</span>
673
+ <span className="svy-gbars">
674
+ {row.byGroup.map((cell, index) => {
675
+ const group = groups[index]
676
+ return (
677
+ <span
678
+ className="svy-gbar"
679
+ key={group.key}
680
+ title={`${group.label}: ${cell.count} of ${cell.answered} (${percent(cell.count, cell.answered)})`}
681
+ >
682
+ <span className="svy-track sm">
683
+ <span
684
+ className={`svy-fill ${groupTone(group, index)}`}
685
+ style={{ width: `${share(cell.count, cell.answered) * 100}%` }}
686
+ />
687
+ </span>
688
+ <span className="svy-gbar-value">
689
+ {/* Direct-label the first row only; the legend carries the rest. */}
690
+ {rowIndex === 0
691
+ ? `${percent(cell.count, cell.answered)} ${group.label}`
692
+ : percent(cell.count, cell.answered)}
693
+ </span>
694
+ </span>
695
+ )
696
+ })}
697
+ </span>
698
+ </li>
699
+ ))}
700
+ </ul>
701
+ )
702
+ }
703
+
704
+ function people(count: number): string {
705
+ return `${count} ${count === 1 ? 'person' : 'people'}`
706
+ }
707
+
708
+ function groupTone(group: ResponseGroup, index: number): string {
709
+ return group.mood ? `mood-${group.mood}` : `g${index}`
710
+ }
711
+
712
+ function MoodBars({ summaries, groups }: { summaries: ScaleSummary[]; groups: ResponseGroup[] }) {
713
+ return (
714
+ <ul className="svy-bars">
715
+ {summaries.map((summary, index) => {
716
+ const group = groups[index]
717
+ return (
718
+ <li
719
+ className="svy-bar"
720
+ key={group.key}
721
+ title={`${group.label}: ${summary.happy} happy, ${summary.neutral} in the middle, ${summary.unhappy} unhappy of ${summary.answered}`}
722
+ >
723
+ {/* Named in text: the bar's colors already mean happy and unhappy. */}
724
+ <span className="svy-bar-label">{group.label}</span>
725
+ <span className="svy-bar-value">
726
+ <strong>{`${percent(summary.happy, summary.answered)} happy`}</strong>
727
+ <span className="svy-count">{`avg ${formatAverage(summary.average)}`}</span>
728
+ </span>
729
+ <span className="svy-track svy-stack">
730
+ {(['unhappy', 'neutral', 'happy'] as const).map((mood) =>
731
+ summary[mood] > 0 ? (
732
+ <span
733
+ className={`svy-seg mood-${mood}`}
734
+ key={mood}
735
+ style={{ width: `${share(summary[mood], summary.answered) * 100}%` }}
736
+ />
737
+ ) : null,
738
+ )}
739
+ </span>
740
+ </li>
741
+ )
742
+ })}
743
+ </ul>
744
+ )
745
+ }
746
+
747
+ // ── What readers wrote ──────────────────────────────────────────────────
748
+
315
749
  type WrittenAnswer = SurveyResults['freeText'][number]
316
750
 
317
751
  // The owner's loud surface for answers auto-labeling has not placed yet
@@ -333,72 +767,198 @@ function personalDetailsNotice(count: number): string {
333
767
  : `${count} answers contain personal details and need your label.`
334
768
  }
335
769
 
336
- function VerbatimFeed({
770
+ function Comments({
337
771
  data,
772
+ offset,
338
773
  canLabel,
339
774
  onSetLabel,
775
+ labelSaveFailed,
776
+ onOffsetChange,
777
+ everyone,
778
+ onLoadEveryone,
779
+ initialMood,
340
780
  }: {
341
781
  data: SurveyResults
782
+ offset: number
342
783
  canLabel: boolean
343
784
  onSetLabel?: (responseId: string, questionId: string, label: SurveyAnswerLabel) => void
785
+ labelSaveFailed: boolean
786
+ onOffsetChange?: (offset: number) => void
787
+ everyone: EveryoneState
788
+ onLoadEveryone?: (total: number) => void
789
+ initialMood: Mood | ''
344
790
  }) {
345
791
  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
- )
792
+ const [moodFilter, setMoodFilter] = useState<Mood | ''>(initialMood)
793
+ const total = data.survey.responseCount
794
+ const shown = data.responses.length
795
+ const rangeStart = total === 0 ? 0 : offset + 1
796
+ const rangeEnd = offset + shown
797
+ const hasMore = rangeEnd < total
798
+
799
+ const headline = headlineScale(data.questions)
800
+ const textQuestions = data.questions.filter((question) => question.kind === 'text')
801
+ const textQuestionIds = new Set(textQuestions.map((question) => question.id))
349
802
  const promptById = new Map(data.questions.map((question) => [question.id, question.prompt]))
803
+ const ratingOf = (response: SurveyResponse): number | null => {
804
+ if (!headline) return null
805
+ const value = answerValue(response, headline.id)
806
+ return typeof value === 'number' ? value : null
807
+ }
808
+ const moodOf = (response: SurveyResponse): Mood | null => {
809
+ const rating = ratingOf(response)
810
+ return rating === null || !headline
811
+ ? null
812
+ : scaleMood(rating, headline.options.min, headline.options.max)
813
+ }
814
+ const canFilterByMood = data.responses.some((response) => ratingOf(response) !== null)
815
+ const moodRanges = headline ? summarizeScale(headline.options, {}) : null
816
+
350
817
  // Non-owners never receive technical or unlabeled entries (the route filters
351
818
  // server-side), so for them this feed is already the general thread.
352
819
  const visibleFreeText =
353
820
  labelFilter === 'all' ? data.freeText : data.freeText.filter((e) => e.label === labelFilter)
354
- const freeTextByResponse = new Map<string, WrittenAnswer[]>()
821
+ const writtenByResponse = new Map<string, WrittenAnswer[]>()
355
822
  for (const entry of visibleFreeText) {
356
- const entries = freeTextByResponse.get(entry.responseId) ?? []
823
+ const entries = writtenByResponse.get(entry.responseId) ?? []
357
824
  entries.push(entry)
358
- freeTextByResponse.set(entry.responseId, entries)
825
+ writtenByResponse.set(entry.responseId, entries)
359
826
  }
827
+ const wroteSomething = new Set(data.freeText.map((entry) => entry.responseId))
828
+
829
+ // A filter answers "what do unhappy readers say", so it searches every
830
+ // response, never just the page on screen. The written answers already
831
+ // arrive for the whole survey; each response's rating and details come
832
+ // from loading every page, exactly as the group comparison does.
833
+ const filtering = moodFilter !== '' || labelFilter !== 'all'
834
+ useEffect(() => {
835
+ if (filtering && everyone.kind === 'idle') onLoadEveryone?.(total)
836
+ }, [filtering, everyone.kind, onLoadEveryone, total])
837
+ const pool: SurveyResponse[] | null = !filtering
838
+ ? data.responses
839
+ : everyone.kind === 'ready'
840
+ ? everyone.responses
841
+ : null
842
+ const visible = (pool ?? []).filter(
843
+ (response) =>
844
+ (writtenByResponse.get(response.id) ?? []).length > 0 &&
845
+ (!moodFilter || moodOf(response) === moodFilter),
846
+ )
847
+ const silent = filtering
848
+ ? []
849
+ : data.responses.filter((response) => !wroteSomething.has(response.id))
850
+ const freeTextCapped = data.freeText.length >= MAX_FREE_TEXT_ENTRIES
360
851
 
361
852
  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)}
853
+ <section aria-labelledby="survey-verbatims" className="svy-section">
854
+ <div className="page-head svy-section-head">
855
+ <div>
856
+ <h2 className="page-title svy-h2" id="survey-verbatims">
857
+ What readers wrote
858
+ </h2>
859
+ {textQuestions.length > 0 ? (
860
+ <p className="page-sub">{textQuestions.map((q) => `“${q.prompt}”`).join(' · ')}</p>
861
+ ) : null}
862
+ {/* Never a truncated list under a full count: say exactly what is on screen. */}
863
+ <p className="page-sub">
864
+ {!filtering
865
+ ? `Showing ${rangeStart}–${rangeEnd} of ${total} ${total === 1 ? 'response' : 'responses'}`
866
+ : pool
867
+ ? `${visible.length} ${visible.length === 1 ? 'comment matches' : 'comments match'} these filters, searched across all ${pool.length} responses`
868
+ : `Loading all ${total} responses to search every comment…`}
869
+ </p>
870
+ </div>
871
+ <div className="svy-filters">
872
+ {canFilterByMood && moodRanges ? (
873
+ <label className="svy-control">
874
+ <span className="kicker">From</span>
875
+ <select
876
+ className="selectbox"
877
+ value={moodFilter}
878
+ onChange={(event) => setMoodFilter(event.target.value as Mood | '')}
377
879
  >
378
- {option === 'all' ? 'All' : option === 'general' ? 'General' : 'Technical'}
379
- </button>
380
- ))}
381
- </div>
382
- ) : null}
880
+ <option value="">All readers</option>
881
+ <option value="unhappy">{`Unhappy readers (${moodRanges.unhappyRange})`}</option>
882
+ {moodRanges.neutralValue !== null ? (
883
+ <option value="neutral">{`Readers in the middle (${moodRanges.neutralValue})`}</option>
884
+ ) : null}
885
+ <option value="happy">{`Happy readers (${moodRanges.happyRange})`}</option>
886
+ </select>
887
+ </label>
888
+ ) : null}
889
+ {canLabel ? (
890
+ // 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
891
+ <div className="chip-row" role="group" aria-label="Filter written answers">
892
+ {(['all', 'general', 'technical'] as const).map((option) => (
893
+ <button
894
+ className="chip"
895
+ key={option}
896
+ type="button"
897
+ aria-pressed={labelFilter === option}
898
+ onClick={() => setLabelFilter(option)}
899
+ >
900
+ {option === 'all' ? 'All' : option === 'general' ? 'General' : 'Technical'}
901
+ </button>
902
+ ))}
903
+ </div>
904
+ ) : null}
905
+ </div>
383
906
  </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>
907
+
908
+ {canLabel && (data.unlabeledCount ?? 0) > 0 ? (
909
+ <p className="svy-callout" role="status">
910
+ {unlabeledAnswersNotice(data.unlabeledCount ?? 0)}
911
+ </p>
912
+ ) : null}
913
+ {canLabel && (data.personalDetailsCount ?? 0) > 0 ? (
914
+ <p className="svy-callout" role="status">
915
+ {personalDetailsNotice(data.personalDetailsCount ?? 0)}
916
+ </p>
917
+ ) : null}
918
+ {labelSaveFailed && canLabel ? (
919
+ <p className="svy-callout is-alert" role="alert">
920
+ The last label change could not be saved. Try again.
921
+ </p>
922
+ ) : null}
923
+
924
+ {filtering && everyone.kind === 'failed' ? (
925
+ <div className="svy-callout is-alert" role="alert">
926
+ Not every response could be loaded, so the filter has nothing complete to search — a
927
+ partial search would hide comments.{' '}
928
+ {onLoadEveryone ? (
929
+ <button className="btn sm" type="button" onClick={() => onLoadEveryone(total)}>
930
+ Retry
931
+ </button>
932
+ ) : null}
933
+ </div>
934
+ ) : null}
935
+ {filtering && pool && freeTextCapped ? (
936
+ <p className="svy-callout" role="status">
937
+ {`Only the newest ${MAX_FREE_TEXT_ENTRIES} written answers reach this screen, so older ones are not searched. Export CSV has every answer.`}
938
+ </p>
939
+ ) : null}
940
+
941
+ {!pool ? null : visible.length === 0 ? (
942
+ <p className="page-sub">
943
+ {filtering ? 'No comments match these filters.' : 'No comments on this page.'}
944
+ </p>
945
+ ) : (
946
+ <div className="svy-comments">
947
+ {visible.map((response) => {
948
+ const rating = ratingOf(response)
949
+ const mood = moodOf(response)
950
+ const others = (response.answers ?? []).filter(
951
+ (answer) =>
952
+ !textQuestionIds.has(answer.questionId) && answer.questionId !== headline?.id,
953
+ )
954
+ return (
955
+ <article className="card svy-comment" key={response.id}>
956
+ {(writtenByResponse.get(response.id) ?? []).map((entry) => (
957
+ <div className="svy-comment-entry" key={`${response.id}:${entry.questionId}`}>
958
+ {textQuestions.length > 1 ? (
959
+ <div className="kicker">{promptById.get(entry.questionId)}</div>
960
+ ) : null}
961
+ <blockquote className="svy-quote">{entry.value}</blockquote>
402
962
  {canLabel && onSetLabel ? (
403
963
  // 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
964
  <div
@@ -426,7 +986,7 @@ function VerbatimFeed({
426
986
  </button>
427
987
  {entry.labelSource === 'auto' ? (
428
988
  <span
429
- className="chip"
989
+ className="chip is-quiet"
430
990
  title="Labeled automatically — your choice overrides it"
431
991
  >
432
992
  auto
@@ -435,167 +995,90 @@ function VerbatimFeed({
435
995
  </div>
436
996
  ) : null}
437
997
  </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
998
  ))}
450
- </div>
451
- </article>
452
- )
453
- })}
454
- </div>
455
- </section>
456
- )
457
- }
999
+ <div className="svy-meta">
1000
+ {rating !== null && headline && mood ? (
1001
+ <span className="svy-rating" title={headline.prompt}>
1002
+ <span className={`svy-swatch mood-${mood}`} aria-hidden="true" />
1003
+ {`Rated ${rating} of ${headline.options.max}`}
1004
+ </span>
1005
+ ) : null}
1006
+ <span>{segmentLabel(response.respondentSegment)}</span>
1007
+ <span>{respondentLabel(response)}</span>
1008
+ <time dateTime={response.submittedAt} title={formatTime(response.submittedAt)}>
1009
+ {formatDate(response.submittedAt)}
1010
+ </time>
1011
+ </div>
1012
+ {others.length > 0 ? (
1013
+ <details className="svy-answers">
1014
+ <summary>Their other answers</summary>
1015
+ <dl>
1016
+ {others.map((answer) => (
1017
+ <div key={answer.questionId}>
1018
+ <dt>{promptById.get(answer.questionId) ?? answer.questionId}</dt>
1019
+ <dd>{formatAnswerValue(answer.value)}</dd>
1020
+ </div>
1021
+ ))}
1022
+ </dl>
1023
+ </details>
1024
+ ) : null}
1025
+ </article>
1026
+ )
1027
+ })}
1028
+ </div>
1029
+ )}
458
1030
 
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])
1031
+ {silent.length > 0 ? (
1032
+ <details className="svy-silent">
1033
+ <summary>
1034
+ {`${silent.length} ${silent.length === 1 ? 'response' : 'responses'} on this page left no comment`}
1035
+ </summary>
1036
+ <ul>
1037
+ {silent.map((response) => {
1038
+ const rating = ratingOf(response)
1039
+ return (
1040
+ <li key={response.id}>
1041
+ {[
1042
+ formatDate(response.submittedAt),
1043
+ segmentLabel(response.respondentSegment),
1044
+ respondentLabel(response),
1045
+ rating !== null && headline
1046
+ ? `rated ${rating} of ${headline.options.max}`
1047
+ : null,
1048
+ ]
1049
+ .filter(Boolean)
1050
+ .join(' · ')}
1051
+ </li>
1052
+ )
1053
+ })}
1054
+ </ul>
1055
+ </details>
1056
+ ) : null}
492
1057
 
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>
1058
+ {!filtering && (offset > 0 || hasMore) && onOffsetChange ? (
1059
+ <div className="svy-pager">
1060
+ <button
1061
+ className="btn"
1062
+ type="button"
1063
+ disabled={offset === 0}
1064
+ onClick={() => onOffsetChange(Math.max(offset - PAGE_SIZE, 0))}
1065
+ >
1066
+ Previous
1067
+ </button>
1068
+ <button
1069
+ className="btn"
1070
+ type="button"
1071
+ disabled={!hasMore}
1072
+ onClick={() => onOffsetChange(offset + PAGE_SIZE)}
1073
+ >
1074
+ Next
1075
+ </button>
1076
+ </div>
1077
+ ) : null}
540
1078
  </section>
541
1079
  )
542
1080
  }
543
1081
 
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
1082
  async function readSurveyErrorCode(response: Response): Promise<SurveyErrorCode | null> {
600
1083
  const headerCode = response.headers.get(SURVEY_ERROR_CODE_HEADER)
601
1084
  if (!isSurveyErrorCode(headerCode)) return null
@@ -619,7 +1102,7 @@ function respondentLabel(response: SurveyResults['responses'][number]): string {
619
1102
  ? `${response.respondent.label} · ${response.respondent.detail}`
620
1103
  : response.respondent.label
621
1104
  }
622
- return response.anonymous ? 'Anonymous' : 'Known respondent'
1105
+ return response.anonymous ? 'Anonymous' : 'Signed-in reader'
623
1106
  }
624
1107
 
625
1108
  function formatAnswerValue(value: unknown): string {
@@ -650,36 +1133,32 @@ function StatusPage({
650
1133
  )
651
1134
  }
652
1135
 
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
- )
1136
+ function surveyStatusLabel(status: SurveyResults['survey']['status']): string {
1137
+ if (status === 'active') return 'open'
1138
+ if (status === 'closed') return 'closed'
1139
+ return 'draft'
668
1140
  }
669
1141
 
670
1142
  function segmentLabel(segment: 'member' | 'non_member' | 'unknown'): string {
671
1143
  if (segment === 'member') return 'Member'
672
1144
  if (segment === 'non_member') return 'Non-member'
673
- return 'Unknown'
1145
+ return 'Membership unknown'
674
1146
  }
675
1147
 
676
1148
  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'
1149
+ if (audience === 'member') return 'asked members'
1150
+ if (audience === 'non_member') return 'asked non-members'
1151
+ return 'asked everyone'
680
1152
  }
681
1153
 
682
1154
  function formatTime(value: string): string {
683
1155
  const date = new Date(value)
684
1156
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
685
1157
  }
1158
+
1159
+ function formatDate(value: string): string {
1160
+ const date = new Date(value)
1161
+ return Number.isNaN(date.getTime())
1162
+ ? value
1163
+ : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
1164
+ }