@growth-labs/cms 0.5.29 → 0.5.31
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/foundry-dispatch.d.ts +7 -2
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +24 -6
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +174 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
- package/dist/engine/fronts-publish-intent.js +352 -0
- package/dist/engine/fronts-publish-intent.js.map +1 -0
- package/dist/engine/fronts-publish.d.ts +80 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -0
- package/dist/engine/fronts-publish.js +286 -0
- package/dist/engine/fronts-publish.js.map +1 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +10 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-continuity.d.ts +77 -0
- package/dist/engine/publication-continuity.d.ts.map +1 -0
- package/dist/engine/publication-continuity.js +94 -0
- package/dist/engine/publication-continuity.js.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.js +100 -0
- package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
- package/dist/engine/publication-receipt/receipt.d.ts +316 -0
- package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
- package/dist/engine/publication-receipt/receipt.js +131 -0
- package/dist/engine/publication-receipt/receipt.js.map +1 -0
- package/dist/engine/publication-receipt/signature.d.ts +36 -0
- package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
- package/dist/engine/publication-receipt/signature.js +79 -0
- package/dist/engine/publication-receipt/signature.js.map +1 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +2 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +23 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/source-sections.d.ts +20 -0
- package/dist/engine/source-sections.d.ts.map +1 -0
- package/dist/engine/source-sections.js +130 -0
- package/dist/engine/source-sections.js.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
- package/dist/engine/validator/index.d.ts +10 -0
- package/dist/engine/validator/index.d.ts.map +1 -1
- package/dist/engine/validator/index.js +4 -0
- package/dist/engine/validator/index.js.map +1 -1
- package/dist/engine/validator/types.d.ts +1 -1
- package/dist/engine/validator/types.d.ts.map +1 -1
- package/dist/engine/validator/types.js +10 -0
- package/dist/engine/validator/types.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +10 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +22 -2
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +10 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -0
- package/dist/routes/fronts-publish.js +250 -0
- package/dist/routes/fronts-publish.js.map +1 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +13 -1
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +28 -28
- package/dist/schema/layout.d.ts +2 -2
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +36 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/portable-text.d.ts +2 -2
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +59 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/access.d.ts +2 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +7 -0
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/errors.d.ts +8 -0
- package/dist/surveys/errors.d.ts.map +1 -1
- package/dist/surveys/errors.js +12 -0
- package/dist/surveys/errors.js.map +1 -1
- package/dist/surveys/schema.d.ts +129 -54
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +45 -0
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
- package/dist/ui/api/fronts/publish-callback.js +8 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -0
- package/dist/ui/api/fronts/schedule.d.ts +3 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
- package/dist/ui/api/fronts/schedule.js +8 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -0
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +52 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/body-warnings.d.ts +27 -0
- package/dist/ui/editor/body-warnings.d.ts.map +1 -0
- package/dist/ui/editor/body-warnings.js +58 -0
- package/dist/ui/editor/body-warnings.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/migrations/0026_fronts_publish.sql +31 -0
- package/migrations/0027_fronts_publish_intent.sql +44 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +22 -6
- package/src/engine/fronts-publish-intent.ts +676 -0
- package/src/engine/fronts-publish.ts +417 -0
- package/src/engine/index.ts +60 -0
- package/src/engine/publication-continuity.ts +140 -0
- package/src/engine/publication-receipt/canonical-json.ts +123 -0
- package/src/engine/publication-receipt/receipt.ts +160 -0
- package/src/engine/publication-receipt/signature.ts +112 -0
- package/src/engine/publication.ts +2 -0
- package/src/engine/publisher.ts +23 -1
- package/src/engine/source-sections.ts +153 -0
- package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
- package/src/engine/validator/index.ts +12 -0
- package/src/engine/validator/types.ts +19 -0
- package/src/integration/index.ts +10 -0
- package/src/providers/types.ts +1 -1
- package/src/routes/content.ts +23 -2
- package/src/routes/fronts-publish.ts +292 -0
- package/src/routes/index.ts +8 -0
- package/src/routes/surveys.ts +12 -1
- package/src/schema/migrations.ts +36 -0
- package/src/schema/tables.ts +2 -0
- package/src/schema/types.ts +61 -0
- package/src/surveys/access.ts +9 -0
- package/src/surveys/errors.ts +12 -0
- package/src/surveys/schema.ts +47 -0
- package/src/ui/api/fronts/publish-callback.ts +18 -0
- package/src/ui/api/fronts/schedule.ts +18 -0
- package/src/ui/editor/ContentForm.tsx +93 -0
- package/src/ui/editor/body-warnings.ts +78 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import type { CmsRole } from '../../schema/types.js'
|
|
3
|
+
import { canViewSurveyVerbatims } from '../../surveys/access.js'
|
|
2
4
|
import {
|
|
3
5
|
isSurveyErrorCode,
|
|
4
6
|
SURVEY_ERROR_CODE_HEADER,
|
|
5
7
|
SURVEY_ERROR_DEFINITIONS,
|
|
6
8
|
type SurveyErrorCode,
|
|
7
9
|
} from '../../surveys/errors.js'
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type SurveyQuestion,
|
|
12
|
+
type SurveyResults,
|
|
13
|
+
SurveyResultsSchema,
|
|
14
|
+
} from '../../surveys/schema.js'
|
|
15
|
+
import { useWorkspace } from '../workspace-context.js'
|
|
9
16
|
|
|
10
17
|
export type SurveyResponsesState =
|
|
11
18
|
| { kind: 'loading' }
|
|
@@ -14,6 +21,7 @@ export type SurveyResponsesState =
|
|
|
14
21
|
| { kind: 'unavailable' }
|
|
15
22
|
| { kind: 'malformed' }
|
|
16
23
|
| { kind: 'not_found' }
|
|
24
|
+
| { kind: 'auth_failed' }
|
|
17
25
|
| { kind: 'retryable' }
|
|
18
26
|
|
|
19
27
|
export function parseSurveyResultsPayload(value: unknown): SurveyResults | null {
|
|
@@ -34,8 +42,10 @@ export async function classifySurveyResultsResponse(
|
|
|
34
42
|
return { kind: 'forbidden' }
|
|
35
43
|
}
|
|
36
44
|
if (response.status === 404 && code === 'survey_not_found') return { kind: 'not_found' }
|
|
37
|
-
if (response.status === 502 && code === 'survey_provider_malformed') {
|
|
38
|
-
|
|
45
|
+
if (response.status === 502 && code === 'survey_provider_malformed') return { kind: 'malformed' }
|
|
46
|
+
if (response.status === 502 && code === 'survey_invalid_response') return { kind: 'malformed' }
|
|
47
|
+
if (response.status === 502 && code === 'survey_upstream_auth_failed') {
|
|
48
|
+
return { kind: 'auth_failed' }
|
|
39
49
|
}
|
|
40
50
|
if (response.status === 503 && code === 'survey_provider_unavailable') {
|
|
41
51
|
return { kind: 'unavailable' }
|
|
@@ -43,61 +53,104 @@ export async function classifySurveyResultsResponse(
|
|
|
43
53
|
return { kind: 'retryable' }
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
const PAGE_SIZE = 100
|
|
57
|
+
|
|
46
58
|
export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
|
|
47
59
|
const [state, setState] = useState<SurveyResponsesState>({ kind: 'loading' })
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
const [offset, setOffset] = useState(0)
|
|
61
|
+
const load = useCallback(
|
|
62
|
+
async (nextOffset: number) => {
|
|
63
|
+
setState({ kind: 'loading' })
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(
|
|
66
|
+
`/admin/api/surveys/${encodeURIComponent(surveyId)}?offset=${nextOffset}`,
|
|
67
|
+
)
|
|
68
|
+
setState(await classifySurveyResultsResponse(response))
|
|
69
|
+
} catch {
|
|
70
|
+
setState({ kind: 'retryable' })
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[surveyId],
|
|
74
|
+
)
|
|
57
75
|
|
|
58
76
|
useEffect(() => {
|
|
59
|
-
void load()
|
|
60
|
-
}, [load])
|
|
77
|
+
void load(offset)
|
|
78
|
+
}, [load, offset])
|
|
79
|
+
|
|
80
|
+
const { state: workspace } = useWorkspace()
|
|
61
81
|
|
|
62
|
-
return
|
|
82
|
+
return (
|
|
83
|
+
<SurveyResponsesBody
|
|
84
|
+
surveyId={surveyId}
|
|
85
|
+
state={state}
|
|
86
|
+
offset={offset}
|
|
87
|
+
role={workspace.user?.role ?? null}
|
|
88
|
+
onRetry={() => void load(offset)}
|
|
89
|
+
onOffsetChange={setOffset}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
export function SurveyResponsesBody({
|
|
66
95
|
surveyId,
|
|
67
96
|
state,
|
|
97
|
+
offset = 0,
|
|
98
|
+
role = null,
|
|
68
99
|
onRetry,
|
|
100
|
+
onOffsetChange,
|
|
69
101
|
}: {
|
|
70
102
|
surveyId: string
|
|
71
103
|
state: SurveyResponsesState
|
|
104
|
+
offset?: number
|
|
105
|
+
role?: CmsRole | null
|
|
72
106
|
onRetry?: () => void
|
|
107
|
+
onOffsetChange?: (offset: number) => void
|
|
73
108
|
}) {
|
|
109
|
+
const canSeeVerbatims = canViewSurveyVerbatims(role)
|
|
110
|
+
|
|
74
111
|
if (state.kind === 'loading') return <StatusPage title="Loading survey responses…" />
|
|
75
112
|
if (state.kind === 'forbidden')
|
|
76
113
|
return <StatusPage title="You do not have permission to view survey responses." />
|
|
77
114
|
if (state.kind === 'unavailable')
|
|
78
115
|
return <StatusPage title="Survey reporting is not configured for this site." />
|
|
116
|
+
if (state.kind === 'auth_failed')
|
|
117
|
+
return (
|
|
118
|
+
<StatusPage
|
|
119
|
+
title="Survey reporting rejected this site’s credentials."
|
|
120
|
+
detail="The signing secret shared with the reporting upstream is missing or does not match."
|
|
121
|
+
retry={onRetry}
|
|
122
|
+
/>
|
|
123
|
+
)
|
|
79
124
|
if (state.kind === 'malformed')
|
|
80
125
|
return <StatusPage title="Masthead received invalid survey data." retry={onRetry} />
|
|
81
126
|
if (state.kind === 'not_found')
|
|
82
127
|
return <StatusPage title="The configured survey could not be found." />
|
|
83
128
|
if (state.kind === 'retryable')
|
|
84
|
-
return
|
|
129
|
+
return (
|
|
130
|
+
<StatusPage
|
|
131
|
+
title="Survey responses could not be loaded."
|
|
132
|
+
detail="The request did not reach the reporting upstream, or it failed before replying."
|
|
133
|
+
retry={onRetry}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
85
136
|
|
|
86
137
|
const { data } = state
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
138
|
+
const shown = data.responses.length
|
|
139
|
+
const total = data.survey.responseCount
|
|
140
|
+
const rangeStart = total === 0 ? 0 : offset + 1
|
|
141
|
+
const rangeEnd = offset + shown
|
|
142
|
+
const hasMore = rangeEnd < total
|
|
93
143
|
|
|
94
144
|
return (
|
|
95
145
|
<div className="page page-wide fade-in" style={{ maxWidth: 1340 }}>
|
|
96
146
|
<div className="page-head">
|
|
97
147
|
<div>
|
|
98
148
|
<h1 className="page-title">{data.survey.title}</h1>
|
|
149
|
+
{/* Never a truncated list under a full count: say exactly what is on screen. */}
|
|
99
150
|
<div className="page-sub">
|
|
100
|
-
{
|
|
151
|
+
{total === 0
|
|
152
|
+
? 'No responses yet'
|
|
153
|
+
: `Showing ${rangeStart}–${rangeEnd} of ${total} ${total === 1 ? 'response' : 'responses'}`}{' '}
|
|
101
154
|
· {data.survey.status}
|
|
102
155
|
</div>
|
|
103
156
|
</div>
|
|
@@ -109,92 +162,271 @@ export function SurveyResponsesBody({
|
|
|
109
162
|
</a>
|
|
110
163
|
</div>
|
|
111
164
|
|
|
112
|
-
{
|
|
165
|
+
{total === 0 ? (
|
|
113
166
|
<div className="card" role="status">
|
|
114
167
|
<h2 className="card-title">No responses yet</h2>
|
|
115
|
-
<p className="page-sub">Aggregates and
|
|
168
|
+
<p className="page-sub">Aggregates and answers will appear after submission.</p>
|
|
116
169
|
</div>
|
|
117
170
|
) : (
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
171
|
+
<>
|
|
172
|
+
<HeadlineScale questions={data.questions} />
|
|
173
|
+
|
|
174
|
+
{canSeeVerbatims ? (
|
|
175
|
+
<VerbatimFeed data={data} />
|
|
176
|
+
) : (
|
|
177
|
+
<div className="card" role="status" style={{ marginTop: 24 }}>
|
|
178
|
+
<h2 className="card-title">Individual answers are restricted</h2>
|
|
179
|
+
<p className="page-sub">
|
|
180
|
+
Aggregates below are available to your role. Verbatim answers and respondent
|
|
181
|
+
attribution require owner or senior editor.
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<Aggregates data={data} />
|
|
187
|
+
|
|
188
|
+
{(offset > 0 || hasMore) && onOffsetChange ? (
|
|
189
|
+
<div className="page-head" style={{ marginTop: 24 }}>
|
|
190
|
+
<button
|
|
191
|
+
className="btn"
|
|
192
|
+
type="button"
|
|
193
|
+
disabled={offset === 0}
|
|
194
|
+
onClick={() => onOffsetChange(Math.max(offset - PAGE_SIZE, 0))}
|
|
195
|
+
>
|
|
196
|
+
Previous
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
className="btn"
|
|
200
|
+
type="button"
|
|
201
|
+
disabled={!hasMore}
|
|
202
|
+
onClick={() => onOffsetChange(offset + PAGE_SIZE)}
|
|
203
|
+
>
|
|
204
|
+
Next
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
) : null}
|
|
208
|
+
</>
|
|
146
209
|
)}
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
147
213
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
214
|
+
// The first scale question is the survey's headline number. Leading with the
|
|
215
|
+
// distribution keeps one harsh verbatim from setting the tone of a readout.
|
|
216
|
+
function HeadlineScale({ questions }: { questions: SurveyResults['questions'] }) {
|
|
217
|
+
const scale = questions.find((question) => question.kind === 'scale')
|
|
218
|
+
if (!scale || scale.kind !== 'scale' || scale.answerCount === 0) return null
|
|
219
|
+
const midpoint = (scale.options.min + scale.options.max) / 2
|
|
220
|
+
const favourable = Object.entries(scale.distribution)
|
|
221
|
+
.filter(([value]) => Number(value) > midpoint)
|
|
222
|
+
.reduce((sum, [, count]) => sum + count, 0)
|
|
223
|
+
const share = Math.round((favourable / scale.answerCount) * 100)
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<section className="card" aria-labelledby="survey-headline" style={{ marginTop: 24 }}>
|
|
227
|
+
<div className="kicker">{scale.prompt}</div>
|
|
228
|
+
<h2 className="card-title" id="survey-headline">
|
|
229
|
+
{share}% rate {midpoint.toFixed(1)} or better · average{' '}
|
|
230
|
+
{scale.average === null ? '—' : scale.average.toFixed(2)}
|
|
231
|
+
</h2>
|
|
232
|
+
<CountTable values={scale.distribution} />
|
|
233
|
+
</section>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function VerbatimFeed({ data }: { data: SurveyResults }) {
|
|
238
|
+
const textQuestionIds = new Set(
|
|
239
|
+
data.questions.filter((question) => question.kind === 'text').map((question) => question.id),
|
|
240
|
+
)
|
|
241
|
+
const promptById = new Map(data.questions.map((question) => [question.id, question.prompt]))
|
|
242
|
+
const freeTextByResponse = new Map<string, string[]>()
|
|
243
|
+
for (const entry of data.freeText) {
|
|
244
|
+
const entries = freeTextByResponse.get(entry.responseId) ?? []
|
|
245
|
+
entries.push(entry.value)
|
|
246
|
+
freeTextByResponse.set(entry.responseId, entries)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<section aria-labelledby="survey-verbatims" style={{ marginTop: 24 }}>
|
|
251
|
+
<h2 className="page-title" id="survey-verbatims" style={{ fontSize: 22 }}>
|
|
252
|
+
Responses
|
|
253
|
+
</h2>
|
|
254
|
+
<div style={{ marginTop: 12 }}>
|
|
255
|
+
{data.responses.map((response) => {
|
|
256
|
+
const written = freeTextByResponse.get(response.id) ?? []
|
|
257
|
+
const choices = (response.answers ?? []).filter(
|
|
258
|
+
(answer) => !textQuestionIds.has(answer.questionId),
|
|
259
|
+
)
|
|
260
|
+
return (
|
|
261
|
+
<article className="card" key={response.id} style={{ marginTop: 12 }}>
|
|
155
262
|
<div className="kicker">
|
|
156
|
-
|
|
263
|
+
<time dateTime={response.submittedAt}>{formatTime(response.submittedAt)}</time> ·{' '}
|
|
264
|
+
{segmentLabel(response.respondentSegment)} · {respondentLabel(response)}
|
|
157
265
|
</div>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
<CountTable values={question.distribution} />
|
|
166
|
-
</>
|
|
167
|
-
)}
|
|
168
|
-
{question.kind === 'text' && (
|
|
169
|
-
<p className="page-sub">{question.responseCount ?? 0} free-text responses</p>
|
|
266
|
+
{written.length === 0 ? (
|
|
267
|
+
<p className="page-sub">No written answer.</p>
|
|
268
|
+
) : (
|
|
269
|
+
written.map((value, index) => (
|
|
270
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: values are free text with no stable id
|
|
271
|
+
<blockquote key={`${response.id}:${index}`}>{value}</blockquote>
|
|
272
|
+
))
|
|
170
273
|
)}
|
|
274
|
+
<div className="chip-row">
|
|
275
|
+
{choices.map((answer) => (
|
|
276
|
+
<span
|
|
277
|
+
className="chip"
|
|
278
|
+
key={`${response.id}:${answer.questionId}`}
|
|
279
|
+
title={promptById.get(answer.questionId) ?? answer.questionId}
|
|
280
|
+
>
|
|
281
|
+
{formatAnswerValue(answer.value)}
|
|
282
|
+
</span>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
171
285
|
</article>
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
</
|
|
286
|
+
)
|
|
287
|
+
})}
|
|
288
|
+
</div>
|
|
289
|
+
</section>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function Aggregates({ data }: { data: SurveyResults }) {
|
|
294
|
+
// Flat counts cannot express the finding that matters most — how one cohort
|
|
295
|
+
// differs from another — so every aggregate can be split by another question.
|
|
296
|
+
const splitCandidates = data.questions.filter(
|
|
297
|
+
(question) => question.kind === 'single_choice' || question.kind === 'scale',
|
|
298
|
+
)
|
|
299
|
+
const [splitBy, setSplitBy] = useState<string>('')
|
|
300
|
+
const splitQuestion = splitCandidates.find((question) => question.id === splitBy)
|
|
175
301
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
302
|
+
const answersByResponse = useMemo(() => {
|
|
303
|
+
const map = new Map<string, Map<string, unknown>>()
|
|
304
|
+
for (const response of data.responses) {
|
|
305
|
+
map.set(
|
|
306
|
+
response.id,
|
|
307
|
+
new Map((response.answers ?? []).map((answer) => [answer.questionId, answer.value])),
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
return map
|
|
311
|
+
}, [data.responses])
|
|
312
|
+
|
|
313
|
+
const cohorts = useMemo(() => {
|
|
314
|
+
if (!splitQuestion) return null
|
|
315
|
+
const buckets = new Map<string, string[]>()
|
|
316
|
+
for (const response of data.responses) {
|
|
317
|
+
const value = answersByResponse.get(response.id)?.get(splitQuestion.id)
|
|
318
|
+
if (value === undefined) continue
|
|
319
|
+
const key = formatAnswerValue(value)
|
|
320
|
+
const ids = buckets.get(key) ?? []
|
|
321
|
+
ids.push(response.id)
|
|
322
|
+
buckets.set(key, ids)
|
|
323
|
+
}
|
|
324
|
+
return buckets
|
|
325
|
+
}, [answersByResponse, data.responses, splitQuestion])
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<section aria-labelledby="survey-aggregates" style={{ marginTop: 24 }}>
|
|
329
|
+
<div className="page-head">
|
|
330
|
+
<h2 className="page-title" id="survey-aggregates" style={{ fontSize: 22 }}>
|
|
331
|
+
Question aggregates
|
|
179
332
|
</h2>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
333
|
+
<label>
|
|
334
|
+
<span className="page-sub">Split by </span>
|
|
335
|
+
<select value={splitBy} onChange={(event) => setSplitBy(event.target.value)}>
|
|
336
|
+
<option value="">No split</option>
|
|
337
|
+
{splitCandidates.map((question) => (
|
|
338
|
+
<option key={question.id} value={question.id}>
|
|
339
|
+
{question.prompt}
|
|
340
|
+
</option>
|
|
341
|
+
))}
|
|
342
|
+
</select>
|
|
343
|
+
</label>
|
|
344
|
+
</div>
|
|
345
|
+
<div className="grid-split-wide" style={{ alignItems: 'start', marginTop: 12 }}>
|
|
346
|
+
{data.questions.map((question) => (
|
|
347
|
+
<article className="card" key={question.id}>
|
|
348
|
+
<div className="kicker">
|
|
349
|
+
Q{question.position + 1} · {questionAudienceLabel(question.audience)}
|
|
194
350
|
</div>
|
|
351
|
+
<h3 className="card-title">{question.prompt}</h3>
|
|
352
|
+
{question.counts && <CountTable values={question.counts} />}
|
|
353
|
+
{question.distribution && (
|
|
354
|
+
<>
|
|
355
|
+
<p className="page-sub">
|
|
356
|
+
Average: {question.average === null ? '—' : question.average.toFixed(2)}
|
|
357
|
+
</p>
|
|
358
|
+
<CountTable values={question.distribution} />
|
|
359
|
+
</>
|
|
360
|
+
)}
|
|
361
|
+
{question.kind === 'text' && (
|
|
362
|
+
<p className="page-sub">{question.responseCount ?? 0} written answers</p>
|
|
363
|
+
)}
|
|
364
|
+
{cohorts && splitQuestion && splitQuestion.id !== question.id ? (
|
|
365
|
+
<CrossTab
|
|
366
|
+
question={question}
|
|
367
|
+
cohorts={cohorts}
|
|
368
|
+
answersByResponse={answersByResponse}
|
|
369
|
+
/>
|
|
370
|
+
) : null}
|
|
371
|
+
</article>
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
</section>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function CrossTab({
|
|
379
|
+
question,
|
|
380
|
+
cohorts,
|
|
381
|
+
answersByResponse,
|
|
382
|
+
}: {
|
|
383
|
+
question: SurveyQuestion
|
|
384
|
+
cohorts: Map<string, string[]>
|
|
385
|
+
answersByResponse: Map<string, Map<string, unknown>>
|
|
386
|
+
}) {
|
|
387
|
+
const rows = [...cohorts.entries()].map(([cohort, responseIds]) => {
|
|
388
|
+
const values = responseIds
|
|
389
|
+
.map((id) => answersByResponse.get(id)?.get(question.id))
|
|
390
|
+
.filter((value) => value !== undefined)
|
|
391
|
+
if (question.kind === 'scale') {
|
|
392
|
+
const numbers = values.filter((value): value is number => typeof value === 'number')
|
|
393
|
+
const average = numbers.length
|
|
394
|
+
? numbers.reduce((sum, value) => sum + value, 0) / numbers.length
|
|
395
|
+
: null
|
|
396
|
+
return { cohort, n: values.length, summary: average === null ? '—' : average.toFixed(2) }
|
|
397
|
+
}
|
|
398
|
+
const counts = new Map<string, number>()
|
|
399
|
+
for (const value of values.flatMap((value) => (Array.isArray(value) ? value : [value]))) {
|
|
400
|
+
const key = formatAnswerValue(value)
|
|
401
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
402
|
+
}
|
|
403
|
+
const top = [...counts.entries()].sort((left, right) => right[1] - left[1])[0]
|
|
404
|
+
return { cohort, n: values.length, summary: top ? `${top[0]} (${top[1]})` : '—' }
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
if (rows.length === 0) return null
|
|
408
|
+
return (
|
|
409
|
+
<>
|
|
410
|
+
<div className="masthead-rule" />
|
|
411
|
+
<table className="tbl">
|
|
412
|
+
<thead>
|
|
413
|
+
<tr>
|
|
414
|
+
<th>Cohort</th>
|
|
415
|
+
<th>n</th>
|
|
416
|
+
<th>{question.kind === 'scale' ? 'Average' : 'Most common'}</th>
|
|
417
|
+
</tr>
|
|
418
|
+
</thead>
|
|
419
|
+
<tbody>
|
|
420
|
+
{rows.map((row) => (
|
|
421
|
+
<tr key={row.cohort}>
|
|
422
|
+
<td>{row.cohort}</td>
|
|
423
|
+
<td>{row.n}</td>
|
|
424
|
+
<td>{row.summary}</td>
|
|
425
|
+
</tr>
|
|
195
426
|
))}
|
|
196
|
-
|
|
197
|
-
|
|
427
|
+
</tbody>
|
|
428
|
+
</table>
|
|
429
|
+
</>
|
|
198
430
|
)
|
|
199
431
|
}
|
|
200
432
|
|
|
@@ -215,6 +447,20 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
215
447
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
216
448
|
}
|
|
217
449
|
|
|
450
|
+
function respondentLabel(response: SurveyResults['responses'][number]): string {
|
|
451
|
+
if (response.respondent?.label) {
|
|
452
|
+
return response.respondent.detail
|
|
453
|
+
? `${response.respondent.label} · ${response.respondent.detail}`
|
|
454
|
+
: response.respondent.label
|
|
455
|
+
}
|
|
456
|
+
return response.anonymous ? 'Anonymous' : 'Known respondent'
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function formatAnswerValue(value: unknown): string {
|
|
460
|
+
if (Array.isArray(value)) return value.join(', ')
|
|
461
|
+
return String(value)
|
|
462
|
+
}
|
|
463
|
+
|
|
218
464
|
function StatusPage({
|
|
219
465
|
title,
|
|
220
466
|
detail,
|
|
@@ -228,44 +474,46 @@ function StatusPage({
|
|
|
228
474
|
<div className="page" role="status">
|
|
229
475
|
<h1 className="page-title">Survey responses</h1>
|
|
230
476
|
<p className="page-sub">{title}</p>
|
|
231
|
-
{detail
|
|
232
|
-
{retry
|
|
233
|
-
<button
|
|
477
|
+
{detail ? <p className="page-sub">{detail}</p> : null}
|
|
478
|
+
{retry ? (
|
|
479
|
+
<button className="btn" onClick={retry} type="button">
|
|
234
480
|
Retry
|
|
235
481
|
</button>
|
|
236
|
-
)}
|
|
482
|
+
) : null}
|
|
237
483
|
</div>
|
|
238
484
|
)
|
|
239
485
|
}
|
|
240
486
|
|
|
241
487
|
function CountTable({ values }: { values: Record<string, number> }) {
|
|
488
|
+
const total = Object.values(values).reduce((sum, count) => sum + count, 0)
|
|
242
489
|
return (
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
{
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
490
|
+
<dl className="count-list">
|
|
491
|
+
{Object.entries(values).map(([label, count]) => (
|
|
492
|
+
<div key={label}>
|
|
493
|
+
<dt>{label}</dt>
|
|
494
|
+
<dd>
|
|
495
|
+
{count}
|
|
496
|
+
{total > 0 ? ` · ${Math.round((count / total) * 100)}%` : ''}
|
|
497
|
+
</dd>
|
|
498
|
+
</div>
|
|
499
|
+
))}
|
|
500
|
+
</dl>
|
|
253
501
|
)
|
|
254
502
|
}
|
|
255
503
|
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
|
|
504
|
+
function segmentLabel(segment: 'member' | 'non_member' | 'unknown'): string {
|
|
505
|
+
if (segment === 'member') return 'Member'
|
|
506
|
+
if (segment === 'non_member') return 'Non-member'
|
|
507
|
+
return 'Unknown'
|
|
259
508
|
}
|
|
260
509
|
|
|
261
|
-
function
|
|
262
|
-
if (
|
|
263
|
-
if (
|
|
264
|
-
return '
|
|
510
|
+
function questionAudienceLabel(audience: 'all' | 'member' | 'non_member'): string {
|
|
511
|
+
if (audience === 'member') return 'Members'
|
|
512
|
+
if (audience === 'non_member') return 'Non-members'
|
|
513
|
+
return 'Everyone'
|
|
265
514
|
}
|
|
266
515
|
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return 'All respondents'
|
|
516
|
+
function formatTime(value: string): string {
|
|
517
|
+
const date = new Date(value)
|
|
518
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
|
271
519
|
}
|