@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.
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +9 -3
- package/dist/engine/published-content.js.map +1 -1
- package/dist/providers/types.d.ts +7 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -4
- package/dist/schema/migrations.js.map +1 -1
- package/dist/surveys/readout.d.ts +69 -0
- package/dist/surveys/readout.d.ts.map +1 -0
- package/dist/surveys/readout.js +157 -0
- package/dist/surveys/readout.js.map +1 -0
- package/dist/surveys/schema.d.ts +1 -0
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +1 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.js +16 -2
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +19 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +203 -96
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
- package/dist/ui/screens/podcast-listening-data.js +26 -1
- package/dist/ui/screens/podcast-listening-data.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +135 -0
- package/package.json +2 -3
- package/src/engine/published-content.ts +11 -3
- package/src/providers/types.ts +7 -1
- package/src/schema/migrations.ts +7 -4
- package/src/surveys/readout.ts +243 -0
- package/src/surveys/schema.ts +1 -1
- package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +769 -290
- package/src/ui/screens/podcast-listening-data.ts +33 -1
- 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
|
|
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
|
-
:
|
|
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">
|
|
299
|
+
<p className="page-sub">Charts and comments will appear after the first submission.</p>
|
|
230
300
|
</div>
|
|
231
301
|
) : (
|
|
232
302
|
<>
|
|
233
|
-
<
|
|
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
|
-
{
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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"
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
//
|
|
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"
|
|
305
|
-
<div className="kicker">{
|
|
306
|
-
<h2 className="
|
|
307
|
-
{
|
|
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
|
-
<
|
|
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
|
|
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
|
|
347
|
-
|
|
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
|
|
821
|
+
const writtenByResponse = new Map<string, WrittenAnswer[]>()
|
|
355
822
|
for (const entry of visibleFreeText) {
|
|
356
|
-
const entries =
|
|
823
|
+
const entries = writtenByResponse.get(entry.responseId) ?? []
|
|
357
824
|
entries.push(entry)
|
|
358
|
-
|
|
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"
|
|
363
|
-
<div className="page-head">
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
))}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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' : '
|
|
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
|
|
654
|
-
|
|
655
|
-
return
|
|
656
|
-
|
|
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 '
|
|
1145
|
+
return 'Membership unknown'
|
|
674
1146
|
}
|
|
675
1147
|
|
|
676
1148
|
function questionAudienceLabel(audience: 'all' | 'member' | 'non_member'): string {
|
|
677
|
-
if (audience === 'member') return '
|
|
678
|
-
if (audience === 'non_member') return '
|
|
679
|
-
return '
|
|
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
|
+
}
|