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