@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.
Files changed (151) hide show
  1. package/dist/engine/foundry-dispatch.d.ts +7 -2
  2. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  3. package/dist/engine/foundry-dispatch.js +24 -6
  4. package/dist/engine/foundry-dispatch.js.map +1 -1
  5. package/dist/engine/fronts-publish-intent.d.ts +174 -0
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
  7. package/dist/engine/fronts-publish-intent.js +352 -0
  8. package/dist/engine/fronts-publish-intent.js.map +1 -0
  9. package/dist/engine/fronts-publish.d.ts +80 -0
  10. package/dist/engine/fronts-publish.d.ts.map +1 -0
  11. package/dist/engine/fronts-publish.js +286 -0
  12. package/dist/engine/fronts-publish.js.map +1 -0
  13. package/dist/engine/index.d.ts +7 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +10 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publication-continuity.d.ts +77 -0
  18. package/dist/engine/publication-continuity.d.ts.map +1 -0
  19. package/dist/engine/publication-continuity.js +94 -0
  20. package/dist/engine/publication-continuity.js.map +1 -0
  21. package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
  22. package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
  23. package/dist/engine/publication-receipt/canonical-json.js +100 -0
  24. package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
  25. package/dist/engine/publication-receipt/receipt.d.ts +316 -0
  26. package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
  27. package/dist/engine/publication-receipt/receipt.js +131 -0
  28. package/dist/engine/publication-receipt/receipt.js.map +1 -0
  29. package/dist/engine/publication-receipt/signature.d.ts +36 -0
  30. package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
  31. package/dist/engine/publication-receipt/signature.js +79 -0
  32. package/dist/engine/publication-receipt/signature.js.map +1 -0
  33. package/dist/engine/publication.d.ts.map +1 -1
  34. package/dist/engine/publication.js +2 -0
  35. package/dist/engine/publication.js.map +1 -1
  36. package/dist/engine/publisher.d.ts.map +1 -1
  37. package/dist/engine/publisher.js +23 -1
  38. package/dist/engine/publisher.js.map +1 -1
  39. package/dist/engine/source-sections.d.ts +20 -0
  40. package/dist/engine/source-sections.d.ts.map +1 -0
  41. package/dist/engine/source-sections.js +130 -0
  42. package/dist/engine/source-sections.js.map +1 -0
  43. package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
  44. package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
  45. package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
  46. package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
  47. package/dist/engine/validator/index.d.ts +10 -0
  48. package/dist/engine/validator/index.d.ts.map +1 -1
  49. package/dist/engine/validator/index.js +4 -0
  50. package/dist/engine/validator/index.js.map +1 -1
  51. package/dist/engine/validator/types.d.ts +1 -1
  52. package/dist/engine/validator/types.d.ts.map +1 -1
  53. package/dist/engine/validator/types.js +10 -0
  54. package/dist/engine/validator/types.js.map +1 -1
  55. package/dist/integration/index.d.ts.map +1 -1
  56. package/dist/integration/index.js +10 -0
  57. package/dist/integration/index.js.map +1 -1
  58. package/dist/providers/types.d.ts +1 -0
  59. package/dist/providers/types.d.ts.map +1 -1
  60. package/dist/routes/content.d.ts.map +1 -1
  61. package/dist/routes/content.js +22 -2
  62. package/dist/routes/content.js.map +1 -1
  63. package/dist/routes/fronts-publish.d.ts +10 -0
  64. package/dist/routes/fronts-publish.d.ts.map +1 -0
  65. package/dist/routes/fronts-publish.js +250 -0
  66. package/dist/routes/fronts-publish.js.map +1 -0
  67. package/dist/routes/index.d.ts +3 -0
  68. package/dist/routes/index.d.ts.map +1 -1
  69. package/dist/routes/index.js +4 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/surveys.d.ts.map +1 -1
  72. package/dist/routes/surveys.js +13 -1
  73. package/dist/routes/surveys.js.map +1 -1
  74. package/dist/schema/insights-ingest.d.ts +28 -28
  75. package/dist/schema/layout.d.ts +2 -2
  76. package/dist/schema/migrations.d.ts.map +1 -1
  77. package/dist/schema/migrations.js +36 -0
  78. package/dist/schema/migrations.js.map +1 -1
  79. package/dist/schema/portable-text.d.ts +2 -2
  80. package/dist/schema/tables.d.ts +1 -1
  81. package/dist/schema/tables.d.ts.map +1 -1
  82. package/dist/schema/tables.js +2 -0
  83. package/dist/schema/tables.js.map +1 -1
  84. package/dist/schema/types.d.ts +59 -0
  85. package/dist/schema/types.d.ts.map +1 -1
  86. package/dist/schema/types.js.map +1 -1
  87. package/dist/surveys/access.d.ts +2 -0
  88. package/dist/surveys/access.d.ts.map +1 -1
  89. package/dist/surveys/access.js +7 -0
  90. package/dist/surveys/access.js.map +1 -1
  91. package/dist/surveys/errors.d.ts +8 -0
  92. package/dist/surveys/errors.d.ts.map +1 -1
  93. package/dist/surveys/errors.js +12 -0
  94. package/dist/surveys/errors.js.map +1 -1
  95. package/dist/surveys/schema.d.ts +129 -54
  96. package/dist/surveys/schema.d.ts.map +1 -1
  97. package/dist/surveys/schema.js +45 -0
  98. package/dist/surveys/schema.js.map +1 -1
  99. package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
  100. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
  101. package/dist/ui/api/fronts/publish-callback.js +8 -0
  102. package/dist/ui/api/fronts/publish-callback.js.map +1 -0
  103. package/dist/ui/api/fronts/schedule.d.ts +3 -0
  104. package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
  105. package/dist/ui/api/fronts/schedule.js +8 -0
  106. package/dist/ui/api/fronts/schedule.js.map +1 -0
  107. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  108. package/dist/ui/editor/ContentForm.js +52 -1
  109. package/dist/ui/editor/ContentForm.js.map +1 -1
  110. package/dist/ui/editor/body-warnings.d.ts +27 -0
  111. package/dist/ui/editor/body-warnings.d.ts.map +1 -0
  112. package/dist/ui/editor/body-warnings.js +58 -0
  113. package/dist/ui/editor/body-warnings.js.map +1 -0
  114. package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
  115. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  116. package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
  117. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  118. package/migrations/0026_fronts_publish.sql +31 -0
  119. package/migrations/0027_fronts_publish_intent.sql +44 -0
  120. package/package.json +1 -1
  121. package/src/engine/foundry-dispatch.ts +22 -6
  122. package/src/engine/fronts-publish-intent.ts +676 -0
  123. package/src/engine/fronts-publish.ts +417 -0
  124. package/src/engine/index.ts +60 -0
  125. package/src/engine/publication-continuity.ts +140 -0
  126. package/src/engine/publication-receipt/canonical-json.ts +123 -0
  127. package/src/engine/publication-receipt/receipt.ts +160 -0
  128. package/src/engine/publication-receipt/signature.ts +112 -0
  129. package/src/engine/publication.ts +2 -0
  130. package/src/engine/publisher.ts +23 -1
  131. package/src/engine/source-sections.ts +153 -0
  132. package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
  133. package/src/engine/validator/index.ts +12 -0
  134. package/src/engine/validator/types.ts +19 -0
  135. package/src/integration/index.ts +10 -0
  136. package/src/providers/types.ts +1 -1
  137. package/src/routes/content.ts +23 -2
  138. package/src/routes/fronts-publish.ts +292 -0
  139. package/src/routes/index.ts +8 -0
  140. package/src/routes/surveys.ts +12 -1
  141. package/src/schema/migrations.ts +36 -0
  142. package/src/schema/tables.ts +2 -0
  143. package/src/schema/types.ts +61 -0
  144. package/src/surveys/access.ts +9 -0
  145. package/src/surveys/errors.ts +12 -0
  146. package/src/surveys/schema.ts +47 -0
  147. package/src/ui/api/fronts/publish-callback.ts +18 -0
  148. package/src/ui/api/fronts/schedule.ts +18 -0
  149. package/src/ui/editor/ContentForm.tsx +93 -0
  150. package/src/ui/editor/body-warnings.ts +78 -0
  151. 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 { type SurveyResults, SurveyResultsSchema } from '../../surveys/schema.js'
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
- return { kind: 'malformed' }
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 load = useCallback(async () => {
49
- setState({ kind: 'loading' })
50
- try {
51
- const response = await fetch(`/admin/api/surveys/${encodeURIComponent(surveyId)}`)
52
- setState(await classifySurveyResultsResponse(response))
53
- } catch {
54
- setState({ kind: 'retryable' })
55
- }
56
- }, [surveyId])
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 <SurveyResponsesBody surveyId={surveyId} state={state} onRetry={load} />
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 <StatusPage title="Survey responses could not be loaded." retry={onRetry} />
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 freeTextByQuestion = new Map<string, SurveyResults['freeText']>()
88
- for (const entry of data.freeText) {
89
- const entries = freeTextByQuestion.get(entry.questionId) ?? []
90
- entries.push(entry)
91
- freeTextByQuestion.set(entry.questionId, entries)
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
- {data.survey.responseCount} {data.survey.responseCount === 1 ? 'response' : 'responses'}{' '}
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
- {data.survey.responseCount === 0 ? (
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 free-text answers will appear after submission.</p>
168
+ <p className="page-sub">Aggregates and answers will appear after submission.</p>
116
169
  </div>
117
170
  ) : (
118
- <section className="card" aria-labelledby="survey-response-list">
119
- <h2 className="card-title" id="survey-response-list">
120
- Recent responses
121
- </h2>
122
- <div className="masthead-rule" />
123
- <table className="tbl">
124
- <thead>
125
- <tr>
126
- <th>Submitted</th>
127
- <th>Channel</th>
128
- <th>Audience</th>
129
- <th>Attribution</th>
130
- </tr>
131
- </thead>
132
- <tbody>
133
- {data.responses.map((response) => (
134
- <tr key={response.id}>
135
- <td>
136
- <time dateTime={response.submittedAt}>{formatTime(response.submittedAt)}</time>
137
- </td>
138
- <td>{response.channel}</td>
139
- <td>{segmentLabel(response.respondentSegment)}</td>
140
- <td>{response.anonymous ? 'Anonymous' : 'Known respondent'}</td>
141
- </tr>
142
- ))}
143
- </tbody>
144
- </table>
145
- </section>
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
- <section aria-labelledby="survey-aggregates" style={{ marginTop: 24 }}>
149
- <h2 className="page-title" id="survey-aggregates" style={{ fontSize: 22 }}>
150
- Question aggregates
151
- </h2>
152
- <div className="grid-split-wide" style={{ alignItems: 'start', marginTop: 12 }}>
153
- {data.questions.map((question) => (
154
- <article className="card" key={question.id}>
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
- Q{question.position + 1} · {questionAudienceLabel(question.audience)}
263
+ <time dateTime={response.submittedAt}>{formatTime(response.submittedAt)}</time> ·{' '}
264
+ {segmentLabel(response.respondentSegment)} · {respondentLabel(response)}
157
265
  </div>
158
- <h3 className="card-title">{question.prompt}</h3>
159
- {question.counts && <CountTable values={question.counts} />}
160
- {question.distribution && (
161
- <>
162
- <p className="page-sub">
163
- Average: {question.average === null ? '—' : question.average.toFixed(2)}
164
- </p>
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
- </div>
174
- </section>
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
- <section aria-labelledby="survey-free-text" style={{ marginTop: 24 }}>
177
- <h2 className="page-title" id="survey-free-text" style={{ fontSize: 22 }}>
178
- Free-text responses
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
- {data.questions
181
- .filter((question) => question.kind === 'text')
182
- .map((question) => (
183
- <div className="card" key={question.id} style={{ marginTop: 12 }}>
184
- <h3 className="card-title">{question.prompt}</h3>
185
- {(freeTextByQuestion.get(question.id) ?? []).length === 0 ? (
186
- <p className="page-sub">No free-text responses.</p>
187
- ) : (
188
- <ul>
189
- {(freeTextByQuestion.get(question.id) ?? []).map((entry) => (
190
- <li key={`${entry.responseId}:${entry.submittedAt}`}>{entry.value}</li>
191
- ))}
192
- </ul>
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
- </section>
197
- </div>
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 && <p className="page-sub">{detail}</p>}
232
- {retry && (
233
- <button type="button" className="btn" onClick={retry}>
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
- <table className="tbl">
244
- <tbody>
245
- {Object.entries(values).map(([label, count]) => (
246
- <tr key={label}>
247
- <th scope="row">{label}</th>
248
- <td className="tnum">{count}</td>
249
- </tr>
250
- ))}
251
- </tbody>
252
- </table>
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 formatTime(value: string): string {
257
- const date = new Date(value)
258
- return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
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 segmentLabel(value: 'member' | 'non_member' | 'unknown'): string {
262
- if (value === 'member') return 'Member'
263
- if (value === 'non_member') return 'Non-member'
264
- return 'Unknown'
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 questionAudienceLabel(value: 'all' | 'member' | 'non_member'): string {
268
- if (value === 'member') return 'Members only'
269
- if (value === 'non_member') return 'Non-members only'
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
  }