@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.
Files changed (79) hide show
  1. package/README.md +23 -0
  2. package/dist/engine/published-content.d.ts +4 -0
  3. package/dist/engine/published-content.d.ts.map +1 -1
  4. package/dist/engine/published-content.js +61 -19
  5. package/dist/engine/published-content.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/integration/options.d.ts +226 -13
  11. package/dist/integration/options.d.ts.map +1 -1
  12. package/dist/integration/options.js +17 -2
  13. package/dist/integration/options.js.map +1 -1
  14. package/dist/providers/types.d.ts +17 -1
  15. package/dist/providers/types.d.ts.map +1 -1
  16. package/dist/providers/types.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts +4 -0
  18. package/dist/routes/content-insights.d.ts.map +1 -1
  19. package/dist/routes/content-insights.js +104 -0
  20. package/dist/routes/content-insights.js.map +1 -1
  21. package/dist/routes/surveys.d.ts.map +1 -1
  22. package/dist/routes/surveys.js +80 -4
  23. package/dist/routes/surveys.js.map +1 -1
  24. package/dist/schema/insights-ingest.d.ts +64 -64
  25. package/dist/schema/migrations.d.ts.map +1 -1
  26. package/dist/schema/migrations.js +7 -4
  27. package/dist/schema/migrations.js.map +1 -1
  28. package/dist/surveys/auto-label.d.ts +31 -4
  29. package/dist/surveys/auto-label.d.ts.map +1 -1
  30. package/dist/surveys/auto-label.js +160 -29
  31. package/dist/surveys/auto-label.js.map +1 -1
  32. package/dist/surveys/readout.d.ts +69 -0
  33. package/dist/surveys/readout.d.ts.map +1 -0
  34. package/dist/surveys/readout.js +157 -0
  35. package/dist/surveys/readout.js.map +1 -0
  36. package/dist/surveys/schema.d.ts +156 -0
  37. package/dist/surveys/schema.d.ts.map +1 -1
  38. package/dist/surveys/schema.js +50 -1
  39. package/dist/surveys/schema.js.map +1 -1
  40. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  41. package/dist/ui/api/content/insights-inputs.js +11 -1
  42. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  43. package/dist/ui/client/mount.d.ts.map +1 -1
  44. package/dist/ui/client/mount.js +1 -0
  45. package/dist/ui/client/mount.js.map +1 -1
  46. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
  47. package/dist/ui/screens/PodcastListeningScreen.js +16 -2
  48. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
  49. package/dist/ui/screens/SurveyResponsesScreen.d.ts +36 -3
  50. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  51. package/dist/ui/screens/SurveyResponsesScreen.js +271 -107
  52. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  53. package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
  54. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
  55. package/dist/ui/screens/podcast-listening-data.js +26 -1
  56. package/dist/ui/screens/podcast-listening-data.js.map +1 -1
  57. package/dist/ui/screens/registry.d.ts +3 -1
  58. package/dist/ui/screens/registry.d.ts.map +1 -1
  59. package/dist/ui/screens/registry.js +4 -2
  60. package/dist/ui/screens/registry.js.map +1 -1
  61. package/dist/ui/styles/broadsheet.css +136 -0
  62. package/package.json +2 -3
  63. package/src/engine/published-content.ts +86 -19
  64. package/src/index.ts +1 -0
  65. package/src/integration/options.ts +21 -1
  66. package/src/providers/types.ts +16 -1
  67. package/src/routes/content-insights.ts +104 -0
  68. package/src/routes/surveys.ts +106 -4
  69. package/src/schema/migrations.ts +7 -4
  70. package/src/surveys/auto-label.ts +204 -33
  71. package/src/surveys/readout.ts +243 -0
  72. package/src/surveys/schema.ts +59 -1
  73. package/src/ui/api/content/insights-inputs.ts +8 -1
  74. package/src/ui/client/mount.tsx +1 -0
  75. package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
  76. package/src/ui/screens/SurveyResponsesScreen.tsx +964 -305
  77. package/src/ui/screens/podcast-listening-data.ts +33 -1
  78. package/src/ui/screens/registry.tsx +6 -2
  79. package/src/ui/styles/broadsheet.css +136 -0
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useCallback, useEffect, useMemo, useState } from 'react';
3
3
  import { canLabelSurveyAnswers, canViewSurveyVerbatims } from '../../surveys/access.js';
4
4
  import { isSurveyErrorCode, SURVEY_ERROR_CODE_HEADER, SURVEY_ERROR_DEFINITIONS, } from '../../surveys/errors.js';
5
- import { SurveyResultsSchema, } from '../../surveys/schema.js';
5
+ import { answerValue, compareQuestion, formatAverage, groupResponses, headlineScale, MEMBERSHIP_SPLIT, optionRows, percent, questionTakeaway, scaleMood, share, splitQuestions, summarizeScale, } from '../../surveys/readout.js';
6
+ import { MAX_FREE_TEXT_ENTRIES, SurveyResultsSchema, } from '../../surveys/schema.js';
6
7
  import { useWorkspace } from '../workspace-context.js';
7
8
  export function parseSurveyResultsPayload(value) {
8
9
  const parsed = SurveyResultsSchema.safeParse(value);
@@ -32,10 +33,23 @@ export async function classifySurveyResultsResponse(response) {
32
33
  return { kind: 'retryable' };
33
34
  }
34
35
  const PAGE_SIZE = 100;
35
- export function SurveyResponsesScreen({ surveyId }) {
36
+ // Five groups is as many as a reader can tell apart by color; more fold into a note.
37
+ const MAX_GROUPS = 5;
38
+ export function SurveyResponsesScreen({ surveyId, surveys, }) {
39
+ const [selected, setSelected] = useState(surveyId);
40
+ const picker = surveys && surveys.length > 1 ? (_jsx(SurveyPicker, { surveys: surveys, selected: selected, onSelect: setSelected })) : null;
41
+ // Keyed by survey, so switching starts clean: no page, comparison, filter or
42
+ // loaded responses carry over from one survey to another.
43
+ return (_jsx(SurveyResultsView, { surveyId: selected, picker: picker, feedbackThemes: surveys?.find((survey) => survey.surveyId === selected)?.feedbackThemes ?? [] }, selected));
44
+ }
45
+ export function SurveyPicker({ surveys, selected, onSelect, }) {
46
+ return (_jsxs("label", { className: "svy-control", children: [_jsx("span", { className: "kicker", children: "Survey" }), _jsx("select", { className: "selectbox", value: selected, onChange: (event) => onSelect(event.target.value), children: surveys.map((survey) => (_jsx("option", { value: survey.surveyId, children: survey.label }, survey.surveyId))) })] }));
47
+ }
48
+ function SurveyResultsView({ surveyId, picker, feedbackThemes, }) {
36
49
  const [state, setState] = useState({ kind: 'loading' });
37
50
  const [offset, setOffset] = useState(0);
38
51
  const [labelSaveFailed, setLabelSaveFailed] = useState(false);
52
+ const [everyone, setEveryone] = useState({ kind: 'idle' });
39
53
  const load = useCallback(async (nextOffset) => {
40
54
  setState({ kind: 'loading' });
41
55
  try {
@@ -46,6 +60,30 @@ export function SurveyResponsesScreen({ surveyId }) {
46
60
  setState({ kind: 'retryable' });
47
61
  }
48
62
  }, [surveyId]);
63
+ // Group comparisons are worked out in the browser, so they read every page
64
+ // first: a comparison over one page of 100 would sit beside survey-wide
65
+ // totals while describing different people. Pages load one after another
66
+ // to keep the reporting upstream's load flat; any failed page fails the
67
+ // whole comparison rather than quietly comparing a subset.
68
+ const loadEveryone = useCallback(async (total) => {
69
+ setEveryone({ kind: 'loading' });
70
+ const responses = new Map();
71
+ try {
72
+ for (let pageOffset = 0; pageOffset < total; pageOffset += PAGE_SIZE) {
73
+ const page = await classifySurveyResultsResponse(await fetch(`/admin/api/surveys/${encodeURIComponent(surveyId)}?offset=${pageOffset}`));
74
+ if (page.kind !== 'ready') {
75
+ setEveryone({ kind: 'failed' });
76
+ return;
77
+ }
78
+ for (const response of page.data.responses)
79
+ responses.set(response.id, response);
80
+ }
81
+ setEveryone({ kind: 'ready', responses: [...responses.values()] });
82
+ }
83
+ catch {
84
+ setEveryone({ kind: 'failed' });
85
+ }
86
+ }, [surveyId]);
49
87
  // Owner-only classification toggle: on success the label is applied to the
50
88
  // already-loaded page (no refetch); on failure the toggle stays honest on
51
89
  // its old value and a note says the save did not land.
@@ -76,11 +114,19 @@ export function SurveyResponsesScreen({ surveyId }) {
76
114
  void load(offset);
77
115
  }, [load, offset]);
78
116
  const { state: workspace } = useWorkspace();
79
- return (_jsx(SurveyResponsesBody, { surveyId: surveyId, state: state, offset: offset, role: workspace.user?.role ?? null, onRetry: () => void load(offset), onOffsetChange: setOffset, onSetLabel: setLabel, labelSaveFailed: labelSaveFailed }));
117
+ return (_jsx(SurveyResponsesBody, { surveyId: surveyId, state: state, offset: offset, role: workspace.user?.role ?? null, onRetry: () => void load(offset), onOffsetChange: setOffset, onSetLabel: setLabel, labelSaveFailed: labelSaveFailed, everyone: everyone, onLoadEveryone: (total) => void loadEveryone(total), picker: picker, feedbackThemes: feedbackThemes }));
80
118
  }
81
119
  function withAnswerLabel(data, responseId, questionId, label) {
82
120
  return {
83
121
  ...data,
122
+ responses: data.responses.map((response) => response.id !== responseId
123
+ ? response
124
+ : {
125
+ ...response,
126
+ answers: response.answers?.map((answer) => answer.questionId !== questionId
127
+ ? answer
128
+ : { ...answer, label, labelSource: 'owner' }),
129
+ }),
84
130
  freeText: data.freeText.map((entry) => entry.responseId === responseId && entry.questionId === questionId
85
131
  ? // A successful owner save stores source 'owner' server-side; the
86
132
  // optimistic copy reflects that instead of a stale 'auto' badge.
@@ -88,45 +134,119 @@ function withAnswerLabel(data, responseId, questionId, label) {
88
134
  : entry),
89
135
  };
90
136
  }
91
- export function SurveyResponsesBody({ surveyId, state, offset = 0, role = null, onRetry, onOffsetChange, onSetLabel, labelSaveFailed = false, }) {
137
+ export function SurveyResponsesBody({ surveyId, state, offset = 0, role = null, onRetry, onOffsetChange, onSetLabel, labelSaveFailed = false, everyone = { kind: 'idle' }, onLoadEveryone, compareBy = '', commentMood = '', picker = null, feedbackThemes = [], }) {
92
138
  const canSeeVerbatims = canViewSurveyVerbatims(role);
93
139
  const canLabel = canLabelSurveyAnswers(role);
94
140
  if (state.kind === 'loading')
95
- return _jsx(StatusPage, { title: "Loading survey responses\u2026" });
141
+ return _jsx(StatusPage, { picker: picker, title: "Loading survey responses\u2026" });
96
142
  if (state.kind === 'forbidden')
97
- return _jsx(StatusPage, { title: "You do not have permission to view survey responses." });
143
+ return (_jsx(StatusPage, { picker: picker, title: "You do not have permission to view survey responses." }));
98
144
  if (state.kind === 'unavailable')
99
- return _jsx(StatusPage, { title: "Survey reporting is not configured for this site." });
145
+ return _jsx(StatusPage, { picker: picker, title: "Survey reporting is not configured for this site." });
100
146
  if (state.kind === 'auth_failed')
101
- return (_jsx(StatusPage, { title: "Survey reporting rejected this site\u2019s credentials.", detail: "The signing secret shared with the reporting upstream is missing or does not match.", retry: onRetry }));
147
+ return (_jsx(StatusPage, { picker: picker, title: "Survey reporting rejected this site\u2019s credentials.", detail: "The signing secret shared with the reporting upstream is missing or does not match.", retry: onRetry }));
102
148
  if (state.kind === 'malformed')
103
- return _jsx(StatusPage, { title: "Masthead received invalid survey data.", retry: onRetry });
149
+ return (_jsx(StatusPage, { picker: picker, title: "Masthead received invalid survey data.", retry: onRetry }));
104
150
  if (state.kind === 'not_found')
105
- return _jsx(StatusPage, { title: "The configured survey could not be found." });
151
+ return _jsx(StatusPage, { picker: picker, title: "The configured survey could not be found." });
106
152
  if (state.kind === 'retryable')
107
- return (_jsx(StatusPage, { title: "Survey responses could not be loaded.", detail: "The request did not reach the reporting upstream, or it failed before replying.", retry: onRetry }));
153
+ return (_jsx(StatusPage, { picker: picker, title: "Survey responses could not be loaded.", detail: "The request did not reach the reporting upstream, or it failed before replying.", retry: onRetry }));
108
154
  const { data } = state;
109
- const shown = data.responses.length;
110
155
  const total = data.survey.responseCount;
111
- const rangeStart = total === 0 ? 0 : offset + 1;
112
- const rangeEnd = offset + shown;
113
- const hasMore = rangeEnd < total;
114
- return (_jsxs("div", { className: "page page-wide fade-in", style: { maxWidth: 1340 }, children: [_jsxs("div", { className: "page-head", children: [_jsxs("div", { children: [_jsx("h1", { className: "page-title", children: data.survey.title }), _jsxs("div", { className: "page-sub", children: [total === 0
115
- ? 'No responses yet'
116
- : `Showing ${rangeStart}–${rangeEnd} of ${total} ${total === 1 ? 'response' : 'responses'}`, ' ', "\u00B7 ", data.survey.status] })] }), _jsx("a", { className: "btn", href: `/admin/api/surveys/${encodeURIComponent(surveyId)}/responses.csv`, children: "Export CSV" })] }), total === 0 ? (_jsxs("div", { className: "card", role: "status", children: [_jsx("h2", { className: "card-title", children: "No responses yet" }), _jsx("p", { className: "page-sub", children: "Aggregates and answers will appear after submission." })] })) : (_jsxs(_Fragment, { children: [_jsx(HeadlineScale, { questions: data.questions }), canSeeVerbatims ? (_jsxs(_Fragment, { children: [canLabel && (data.unlabeledCount ?? 0) > 0 ? (_jsx("p", { className: "page-sub", role: "status", style: { marginTop: 24 }, children: unlabeledAnswersNotice(data.unlabeledCount ?? 0) })) : null, canLabel && (data.personalDetailsCount ?? 0) > 0 ? (_jsx("p", { className: "page-sub", role: "status", style: { marginTop: 24 }, children: personalDetailsNotice(data.personalDetailsCount ?? 0) })) : null, labelSaveFailed && canLabel ? (_jsx("p", { className: "page-sub", role: "alert", style: { marginTop: 24 }, children: "The last label change could not be saved. Try again." })) : null, _jsx(VerbatimFeed, { data: data, canLabel: canLabel, onSetLabel: onSetLabel })] })) : (_jsxs("div", { className: "card", role: "status", style: { marginTop: 24 }, children: [_jsx("h2", { className: "card-title", children: "Individual answers are restricted" }), _jsx("p", { className: "page-sub", children: "Aggregates below are available to your role. Verbatim answers and respondent attribution require owner or senior editor." })] })), _jsx(Aggregates, { data: data }), (offset > 0 || hasMore) && onOffsetChange ? (_jsxs("div", { className: "page-head", style: { marginTop: 24 }, children: [_jsx("button", { className: "btn", type: "button", disabled: offset === 0, onClick: () => onOffsetChange(Math.max(offset - PAGE_SIZE, 0)), children: "Previous" }), _jsx("button", { className: "btn", type: "button", disabled: !hasMore, onClick: () => onOffsetChange(offset + PAGE_SIZE), children: "Next" })] })) : null] }))] }));
156
+ const headline = headlineScale(data.questions);
157
+ return (_jsxs("div", { className: "page page-wide fade-in svy", style: { maxWidth: 1340 }, children: [_jsxs("div", { className: "page-head", children: [_jsxs("div", { children: [_jsx("div", { className: "kicker", children: "Survey results" }), _jsx("h1", { className: "page-title", children: data.survey.title }), _jsx("div", { className: "page-sub", children: total === 0
158
+ ? 'No responses yet'
159
+ : `${total} ${total === 1 ? 'response' : 'responses'} · ${surveyStatusLabel(data.survey.status)}` })] }), _jsxs("div", { className: "svy-head-actions", children: [picker, _jsx("a", { className: "btn", href: `/admin/api/surveys/${encodeURIComponent(surveyId)}/responses.csv`, children: "Export CSV" })] })] }), total === 0 ? (_jsxs("div", { className: "card", role: "status", children: [_jsx("h2", { className: "card-title", children: "No responses yet" }), _jsx("p", { className: "page-sub", children: "Charts and comments will appear after the first submission." })] })) : (_jsxs(_Fragment, { children: [headline && headline.answerCount > 0 ? _jsx(Headline, { question: headline }) : null, _jsx(Results, { data: data, everyone: everyone, onLoadEveryone: onLoadEveryone, initialCompareBy: compareBy }), feedbackThemes.length > 0 && _jsx(FeedbackThemes, { themes: feedbackThemes }), canSeeVerbatims ? (_jsx(Comments, { data: data, offset: offset, canLabel: canLabel, onSetLabel: onSetLabel, labelSaveFailed: labelSaveFailed, onOffsetChange: onOffsetChange, everyone: everyone, onLoadEveryone: onLoadEveryone, initialMood: commentMood })) : (_jsxs("div", { className: "card svy-section", role: "status", children: [_jsx("h2", { className: "card-title", children: "Individual answers are restricted" }), _jsx("p", { className: "page-sub", children: "The charts above are available to your role. What readers wrote, and who wrote it, requires owner or senior editor." })] }))] }))] }));
117
160
  }
161
+ // Existing site configuration supplies reviewed statuses; the survey payload stays unchanged.
162
+ function FeedbackThemes({ themes }) {
163
+ const states = {
164
+ open: 'Open',
165
+ partly_shipped: 'Partly shipped',
166
+ shipped: 'Shipped',
167
+ unknown: 'Not verified',
168
+ };
169
+ return (_jsxs("section", { className: "svy-section", "aria-labelledby": "survey-feedback-themes", children: [_jsxs("div", { className: "svy-section-head", children: [_jsx("div", { className: "kicker", children: "Recurring feedback" }), _jsx("h2", { className: "card-title", id: "survey-feedback-themes", children: "You said \u2192 we shipped" }), _jsx("p", { className: "page-sub", children: "Reviewed delivery notes for recurring reader themes. These are dated updates, not live issue statuses or counts of survey answers. Open work stays visible." })] }), _jsx("div", { className: "svy-grid", children: themes.map((theme) => (_jsxs("article", { className: "card", "data-feedback-theme": theme.id, children: [_jsx("div", { className: "kicker", children: states[theme.state] }), _jsx("h3", { className: "svy-q-prompt", children: theme.title }), _jsx("p", { className: "svy-takeaway", children: theme.summary }), _jsxs("p", { className: "svy-note", children: ["Reviewed ", _jsx("time", { dateTime: theme.reviewedAt, children: theme.reviewedAt.slice(0, 10) })] }), theme.evidence.length > 0 && (_jsx("ul", { className: "svy-note", children: theme.evidence.map((link) => (_jsx("li", { children: _jsx("a", { href: link.href, children: link.label }) }, link.href))) }))] }, theme.id))) })] }));
170
+ }
171
+ // ── Headline ────────────────────────────────────────────────────────────
118
172
  // The first scale question is the survey's headline number. Leading with the
119
- // distribution keeps one harsh verbatim from setting the tone of a readout.
120
- function HeadlineScale({ questions }) {
121
- const scale = questions.find((question) => question.kind === 'scale');
122
- if (scale?.kind !== 'scale' || scale.answerCount === 0)
123
- return null;
124
- const midpoint = (scale.options.min + scale.options.max) / 2;
125
- const favourable = Object.entries(scale.distribution)
126
- .filter(([value]) => Number(value) > midpoint)
127
- .reduce((sum, [, count]) => sum + count, 0);
128
- const share = Math.round((favourable / scale.answerCount) * 100);
129
- return (_jsxs("section", { className: "card", "aria-labelledby": "survey-headline", style: { marginTop: 24 }, children: [_jsx("div", { className: "kicker", children: scale.prompt }), _jsxs("h2", { className: "card-title", id: "survey-headline", children: [share, "% rate ", midpoint.toFixed(1), " or better \u00B7 average", ' ', scale.average === null ? '—' : scale.average.toFixed(2)] }), _jsx(CountTable, { values: scale.distribution })] }));
173
+ // distribution keeps one harsh comment from setting the tone of a readout.
174
+ function Headline({ question }) {
175
+ const summary = summarizeScale(question.options, question.distribution);
176
+ return (_jsxs("section", { className: "card svy-headline", "aria-labelledby": "survey-headline", children: [_jsx("div", { className: "kicker", children: `Headline · Q${question.position + 1} · ${summary.answered} answered` }), _jsx("h2", { className: "svy-headline-prompt", id: "survey-headline", children: question.prompt }), _jsxs("div", { className: "svy-headline-body", children: [_jsxs("div", { children: [_jsx("div", { className: "svy-big", children: percent(summary.happy, summary.answered) }), _jsx("div", { className: "svy-big-label", children: `are happy — they rated it ${summary.happyRange} out of ${summary.max}` }), _jsxs("dl", { className: "svy-facts", children: [_jsxs("div", { children: [_jsxs("dt", { children: [_jsx("span", { className: "svy-swatch mood-unhappy", "aria-hidden": "true" }), `Unhappy (${summary.unhappyRange})`] }), _jsx("dd", { children: `${percent(summary.unhappy, summary.answered)} · ${people(summary.unhappy)}` })] }), summary.neutralValue !== null ? (_jsxs("div", { children: [_jsxs("dt", { children: [_jsx("span", { className: "svy-swatch mood-neutral", "aria-hidden": "true" }), `In the middle (${summary.neutralValue})`] }), _jsx("dd", { children: `${percent(summary.neutral, summary.answered)} · ${people(summary.neutral)}` })] })) : null, _jsxs("div", { children: [_jsxs("dt", { children: [_jsx("span", { className: "svy-swatch mood-happy", "aria-hidden": "true" }), `Happy (${summary.happyRange})`] }), _jsx("dd", { children: `${percent(summary.happy, summary.answered)} · ${people(summary.happy)}` })] }), _jsxs("div", { children: [_jsx("dt", { children: "Average" }), _jsx("dd", { children: `${formatAverage(summary.average)} out of ${summary.max}` })] })] })] }), _jsx(ScaleColumns, { question: question })] })] }));
177
+ }
178
+ function ScaleColumns({ question }) {
179
+ const { min, max } = question.options;
180
+ const summary = summarizeScale(question.options, question.distribution);
181
+ const values = Array.from({ length: max - min + 1 }, (_, index) => min + index);
182
+ const peak = Math.max(1, ...values.map((value) => question.distribution[String(value)] ?? 0));
183
+ const columns = { gridTemplateColumns: `repeat(${values.length}, minmax(0, 1fr))` };
184
+ return (_jsxs("figure", { className: "svy-cols", "aria-label": `How people rated it, from ${min} to ${max}`, children: [_jsx("div", { className: "svy-cols-plot", style: columns, children: values.map((value) => {
185
+ const count = question.distribution[String(value)] ?? 0;
186
+ return (_jsxs("div", { className: "svy-col", title: `Rated ${value}: ${people(count)} (${percent(count, summary.answered)})`, children: [_jsx("span", { className: "svy-col-value", children: percent(count, summary.answered) }), _jsx("span", { className: `svy-col-bar mood-${scaleMood(value, min, max)}`, style: { height: `${(count / peak) * 100}%` } })] }, value));
187
+ }) }), _jsx("div", { className: "svy-cols-axis", style: columns, children: values.map((value) => (_jsx("span", { children: value }, value))) }), summary.average !== null ? (_jsx("div", { className: "svy-cols-avg", children: _jsx("span", { style: { left: `${((summary.average - min + 0.5) / values.length) * 100}%` }, children: `▲ average ${formatAverage(summary.average)}` }) })) : null, _jsxs("figcaption", { className: "svy-cols-caption", children: [_jsx("span", { children: `${min} = lowest` }), _jsx("span", { children: `highest = ${max}` })] })] }));
188
+ }
189
+ // ── Results by question ─────────────────────────────────────────────────
190
+ function Results({ data, everyone, onLoadEveryone, initialCompareBy, }) {
191
+ const [compareBy, setCompareBy] = useState(initialCompareBy);
192
+ const total = data.survey.responseCount;
193
+ // Comparing by an answer needs each person's answers. A provider that sends
194
+ // none can still compare members with non-members, so only that is offered.
195
+ const hasAnswers = data.responses.some((response) => (response.answers ?? []).length > 0);
196
+ const candidates = hasAnswers ? splitQuestions(data.questions) : [];
197
+ useEffect(() => {
198
+ if (compareBy && everyone.kind === 'idle')
199
+ onLoadEveryone?.(total);
200
+ }, [compareBy, everyone.kind, onLoadEveryone, total]);
201
+ const allGroups = useMemo(() => compareBy && everyone.kind === 'ready'
202
+ ? groupResponses(everyone.responses, compareBy, data.questions)
203
+ : null, [compareBy, everyone, data.questions]);
204
+ const groups = allGroups?.slice(0, MAX_GROUPS) ?? null;
205
+ const headlineId = headlineScale(data.questions)?.id;
206
+ const gridQuestions = data.questions.filter((question) => groups || question.id !== headlineId);
207
+ return (_jsxs("section", { "aria-labelledby": "survey-results", className: "svy-section", children: [_jsxs("div", { className: "page-head svy-section-head", children: [_jsxs("div", { children: [_jsx("h2", { className: "page-title svy-h2", id: "survey-results", children: "What readers told us" }), _jsx("p", { className: "page-sub", children: `Each chart covers all ${total} responses. Percentages are of the people who answered that question.` })] }), _jsxs("label", { className: "svy-control", children: [_jsx("span", { className: "kicker", children: "Compare" }), _jsxs("select", { className: "selectbox", value: compareBy, onChange: (event) => setCompareBy(event.target.value), children: [_jsx("option", { value: "", children: "Everyone together" }), _jsx("option", { value: MEMBERSHIP_SPLIT, children: "Members vs non-members" }), candidates.map((question) => (_jsx("option", { value: question.id, children: question.kind === 'scale'
208
+ ? `Happy vs unhappy — ${question.prompt}`
209
+ : `By answer — ${question.prompt}` }, question.id)))] })] })] }), compareBy ? (_jsx(CompareStatus, { everyone: everyone, total: total, groups: groups, hiddenGroups: (allGroups?.length ?? 0) - (groups?.length ?? 0), onRetry: onLoadEveryone ? () => onLoadEveryone(total) : undefined })) : null, _jsx("div", { className: "svy-grid", children: gridQuestions.map((question) => (_jsx(QuestionCard, { question: question, total: total, groups: groups, isSplit: question.id === compareBy }, question.id))) })] }));
210
+ }
211
+ function CompareStatus({ everyone, total, groups, hiddenGroups, onRetry, }) {
212
+ if (everyone.kind === 'failed') {
213
+ return (_jsxs("div", { className: "svy-callout is-alert", role: "alert", children: ["Not every response could be loaded, so groups are not compared \u2014 a partial comparison would describe the wrong people.", ' ', onRetry ? (_jsx("button", { className: "btn sm", type: "button", onClick: onRetry, children: "Retry" })) : null] }));
214
+ }
215
+ if (everyone.kind !== 'ready' || !groups) {
216
+ return (_jsx("p", { className: "page-sub svy-compare-status", role: "status", children: `Loading all ${total} responses to compare groups…` }));
217
+ }
218
+ if (groups.length === 0) {
219
+ return (_jsx("p", { className: "svy-callout", role: "status", children: "Nobody has answered that question yet, so there are no groups to compare." }));
220
+ }
221
+ return (_jsxs("div", { className: "svy-legend", role: "status", children: [_jsx("span", { className: "page-sub", children: `Comparing all ${everyone.responses.length} responses:` }), groups.map((group, index) => (_jsxs("span", { className: "svy-legend-item", children: [_jsx("span", { className: `svy-swatch ${groupTone(group, index)}`, "aria-hidden": "true" }), `${group.label} · ${people(group.responses.length)}`] }, group.key))), hiddenGroups > 0 ? (_jsx("span", { className: "page-sub", children: `+${hiddenGroups} smaller ${hiddenGroups === 1 ? 'group' : 'groups'} not shown` })) : null] }));
222
+ }
223
+ function QuestionCard({ question, total, groups, isSplit, }) {
224
+ const comparison = groups && groups.length > 0 && !isSplit ? compareQuestion(question, groups) : null;
225
+ return (_jsxs("article", { className: "card svy-q", children: [_jsx("div", { className: "kicker", children: `Q${question.position + 1} · ${questionAudienceLabel(question.audience)} · ${question.answerCount} answered` }), _jsx("h3", { className: "svy-q-prompt", children: question.prompt }), _jsx("p", { className: "svy-takeaway", children: questionTakeaway(question, total) }), comparison && comparison.kind === 'choice' && groups ? (_jsx(GroupedBars, { rows: comparison.rows, groups: groups })) : comparison && comparison.kind === 'scale' && groups ? (_jsx(MoodBars, { summaries: comparison.byGroup, groups: groups })) : question.kind === 'single_choice' || question.kind === 'multi_choice' ? (_jsx(BarList, { rows: optionRows(question), of: question.answerCount })) : question.kind === 'scale' ? (_jsx(ScaleColumns, { question: question })) : (_jsx(BarList, { rows: [{ option: 'Wrote an answer', count: question.answerCount }], of: total })), question.kind === 'multi_choice' ? (_jsx("p", { className: "svy-note", children: "People could pick more than one, so these add up to more than 100%." })) : null, isSplit && groups ? (_jsx("p", { className: "svy-note", children: "The groups being compared come from this question." })) : null, comparison?.kind === 'none' ? (_jsx("p", { className: "svy-note", children: "Written answers are not split into groups \u2014 read them below." })) : null] }));
226
+ }
227
+ function BarList({ rows, of }) {
228
+ const top = Math.max(0, ...rows.map((row) => row.count));
229
+ return (_jsx("ul", { className: "svy-bars", children: rows.map((row) => (_jsxs("li", { className: "svy-bar", title: `${row.option}: ${row.count} of ${of} (${percent(row.count, of)})`, children: [_jsx("span", { className: "svy-bar-label", children: row.option }), _jsxs("span", { className: "svy-bar-value", children: [_jsx("strong", { children: percent(row.count, of) }), _jsx("span", { className: "svy-count", children: String(row.count) })] }), _jsx("span", { className: "svy-track", children: _jsx("span", { className: `svy-fill${top > 0 && row.count === top ? ' is-top' : ''}`, style: { width: `${share(row.count, of) * 100}%` } }) })] }, row.option))) }));
230
+ }
231
+ function GroupedBars({ rows, groups, }) {
232
+ return (_jsx("ul", { className: "svy-bars", children: rows.map((row, rowIndex) => (_jsxs("li", { className: "svy-bar is-grouped", children: [_jsx("span", { className: "svy-bar-label", children: row.option }), _jsx("span", { className: "svy-gbars", children: row.byGroup.map((cell, index) => {
233
+ const group = groups[index];
234
+ return (_jsxs("span", { className: "svy-gbar", title: `${group.label}: ${cell.count} of ${cell.answered} (${percent(cell.count, cell.answered)})`, children: [_jsx("span", { className: "svy-track sm", children: _jsx("span", { className: `svy-fill ${groupTone(group, index)}`, style: { width: `${share(cell.count, cell.answered) * 100}%` } }) }), _jsx("span", { className: "svy-gbar-value", children: rowIndex === 0
235
+ ? `${percent(cell.count, cell.answered)} ${group.label}`
236
+ : percent(cell.count, cell.answered) })] }, group.key));
237
+ }) })] }, row.option))) }));
238
+ }
239
+ function people(count) {
240
+ return `${count} ${count === 1 ? 'person' : 'people'}`;
241
+ }
242
+ function groupTone(group, index) {
243
+ return group.mood ? `mood-${group.mood}` : `g${index}`;
244
+ }
245
+ function MoodBars({ summaries, groups }) {
246
+ return (_jsx("ul", { className: "svy-bars", children: summaries.map((summary, index) => {
247
+ const group = groups[index];
248
+ return (_jsxs("li", { className: "svy-bar", title: `${group.label}: ${summary.happy} happy, ${summary.neutral} in the middle, ${summary.unhappy} unhappy of ${summary.answered}`, children: [_jsx("span", { className: "svy-bar-label", children: group.label }), _jsxs("span", { className: "svy-bar-value", children: [_jsx("strong", { children: `${percent(summary.happy, summary.answered)} happy` }), _jsx("span", { className: "svy-count", children: `avg ${formatAverage(summary.average)}` })] }), _jsx("span", { className: "svy-track svy-stack", children: ['unhappy', 'neutral', 'happy'].map((mood) => summary[mood] > 0 ? (_jsx("span", { className: `svy-seg mood-${mood}`, style: { width: `${share(summary[mood], summary.answered) * 100}%` } }, mood)) : null) })] }, group.key));
249
+ }) }));
130
250
  }
131
251
  // The owner's loud surface for answers auto-labeling has not placed yet
132
252
  // (publishing #24): everything unlabeled is hidden from the team, so he sees
@@ -143,82 +263,117 @@ function personalDetailsNotice(count) {
143
263
  ? '1 answer contains personal details and needs your label.'
144
264
  : `${count} answers contain personal details and need your label.`;
145
265
  }
146
- function VerbatimFeed({ data, canLabel, onSetLabel, }) {
266
+ function Comments({ data, offset, canLabel, onSetLabel, labelSaveFailed, onOffsetChange, everyone, onLoadEveryone, initialMood, }) {
147
267
  const [labelFilter, setLabelFilter] = useState('all');
148
- const textQuestionIds = new Set(data.questions.filter((question) => question.kind === 'text').map((question) => question.id));
268
+ const [moodFilter, setMoodFilter] = useState(initialMood);
269
+ const total = data.survey.responseCount;
270
+ const shown = data.responses.length;
271
+ const rangeStart = total === 0 ? 0 : offset + 1;
272
+ const rangeEnd = offset + shown;
273
+ const hasMore = rangeEnd < total;
274
+ const headline = headlineScale(data.questions);
275
+ const textQuestions = data.questions.filter((question) => question.kind === 'text');
276
+ const textQuestionIds = new Set(textQuestions.map((question) => question.id));
149
277
  const promptById = new Map(data.questions.map((question) => [question.id, question.prompt]));
278
+ const ratingOf = (response) => {
279
+ if (!headline)
280
+ return null;
281
+ const value = answerValue(response, headline.id);
282
+ return typeof value === 'number' ? value : null;
283
+ };
284
+ const moodOf = (response) => {
285
+ const rating = ratingOf(response);
286
+ return rating === null || !headline
287
+ ? null
288
+ : scaleMood(rating, headline.options.min, headline.options.max);
289
+ };
290
+ const canFilterByMood = data.responses.some((response) => ratingOf(response) !== null);
291
+ const moodRanges = headline ? summarizeScale(headline.options, {}) : null;
150
292
  // Non-owners never receive technical or unlabeled entries (the route filters
151
293
  // server-side), so for them this feed is already the general thread.
152
- const visibleFreeText = labelFilter === 'all' ? data.freeText : data.freeText.filter((e) => e.label === labelFilter);
153
- const freeTextByResponse = new Map();
294
+ const writtenAnswers = [...data.freeText];
295
+ for (const response of everyone.kind === 'ready' ? everyone.responses : data.responses) {
296
+ for (const answer of response.answers ?? []) {
297
+ if (answer.writtenValue === undefined)
298
+ continue;
299
+ writtenAnswers.push({
300
+ questionId: answer.questionId,
301
+ responseId: response.id,
302
+ submittedAt: response.submittedAt,
303
+ value: answer.writtenValue,
304
+ label: answer.label,
305
+ labelSource: answer.labelSource,
306
+ classificationStatus: answer.classificationStatus,
307
+ nextAttemptAt: answer.nextAttemptAt,
308
+ });
309
+ }
310
+ }
311
+ const visibleFreeText = labelFilter === 'all' ? writtenAnswers : writtenAnswers.filter((e) => e.label === labelFilter);
312
+ const writtenByResponse = new Map();
154
313
  for (const entry of visibleFreeText) {
155
- const entries = freeTextByResponse.get(entry.responseId) ?? [];
314
+ const entries = writtenByResponse.get(entry.responseId) ?? [];
156
315
  entries.push(entry);
157
- freeTextByResponse.set(entry.responseId, entries);
316
+ writtenByResponse.set(entry.responseId, entries);
158
317
  }
159
- return (_jsxs("section", { "aria-labelledby": "survey-verbatims", style: { marginTop: 24 }, children: [_jsxs("div", { className: "page-head", children: [_jsx("h2", { className: "page-title", id: "survey-verbatims", style: { fontSize: 22 }, children: "Responses" }), canLabel ? (
160
- // 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
161
- _jsx("div", { className: "chip-row", role: "group", "aria-label": "Filter written answers", children: ['all', 'general', 'technical'].map((option) => (_jsx("button", { className: "chip", type: "button", "aria-pressed": labelFilter === option, onClick: () => setLabelFilter(option), children: option === 'all' ? 'All' : option === 'general' ? 'General' : 'Technical' }, option))) })) : null] }), _jsx("div", { style: { marginTop: 12 }, children: data.responses.map((response) => {
162
- const written = freeTextByResponse.get(response.id) ?? [];
163
- const choices = (response.answers ?? []).filter((answer) => !textQuestionIds.has(answer.questionId));
164
- return (_jsxs("article", { className: "card", style: { marginTop: 12 }, children: [_jsxs("div", { className: "kicker", children: [_jsx("time", { dateTime: response.submittedAt, children: formatTime(response.submittedAt) }), " \u00B7", ' ', segmentLabel(response.respondentSegment), " \u00B7 ", respondentLabel(response)] }), written.length === 0 ? (_jsx("p", { className: "page-sub", children: "No written answer." })) : (written.map((entry) => (_jsxs("div", { children: [_jsx("blockquote", { children: entry.value }), canLabel && onSetLabel ? (
318
+ const wroteSomething = new Set(writtenAnswers.map((entry) => entry.responseId));
319
+ // A filter answers "what do unhappy readers say", so it searches every
320
+ // response, never just the page on screen. The written answers already
321
+ // arrive for the whole survey; each response's rating and details come
322
+ // from loading every page, exactly as the group comparison does.
323
+ const filtering = moodFilter !== '' || labelFilter !== 'all';
324
+ useEffect(() => {
325
+ if (filtering && everyone.kind === 'idle')
326
+ onLoadEveryone?.(total);
327
+ }, [filtering, everyone.kind, onLoadEveryone, total]);
328
+ const pool = !filtering
329
+ ? data.responses
330
+ : everyone.kind === 'ready'
331
+ ? everyone.responses
332
+ : null;
333
+ const visible = (pool ?? []).filter((response) => (writtenByResponse.get(response.id) ?? []).length > 0 &&
334
+ (!moodFilter || moodOf(response) === moodFilter));
335
+ const silent = filtering
336
+ ? []
337
+ : data.responses.filter((response) => !wroteSomething.has(response.id));
338
+ const freeTextCapped = data.freeText.length >= MAX_FREE_TEXT_ENTRIES;
339
+ return (_jsxs("section", { "aria-labelledby": "survey-verbatims", className: "svy-section", children: [_jsxs("div", { className: "page-head svy-section-head", children: [_jsxs("div", { children: [_jsx("h2", { className: "page-title svy-h2", id: "survey-verbatims", children: "What readers wrote" }), textQuestions.length > 0 ? (_jsx("p", { className: "page-sub", children: textQuestions.map((q) => `“${q.prompt}”`).join(' · ') })) : null, _jsx("p", { className: "page-sub", children: !filtering
340
+ ? `Showing ${rangeStart}–${rangeEnd} of ${total} ${total === 1 ? 'response' : 'responses'}`
341
+ : pool
342
+ ? `${visible.length} ${visible.length === 1 ? 'comment matches' : 'comments match'} these filters, searched across all ${pool.length} responses`
343
+ : `Loading all ${total} responses to search every comment…` })] }), _jsxs("div", { className: "svy-filters", children: [canFilterByMood && moodRanges ? (_jsxs("label", { className: "svy-control", children: [_jsx("span", { className: "kicker", children: "From" }), _jsxs("select", { className: "selectbox", value: moodFilter, onChange: (event) => setMoodFilter(event.target.value), children: [_jsx("option", { value: "", children: "All readers" }), _jsx("option", { value: "unhappy", children: `Unhappy readers (${moodRanges.unhappyRange})` }), moodRanges.neutralValue !== null ? (_jsx("option", { value: "neutral", children: `Readers in the middle (${moodRanges.neutralValue})` })) : null, _jsx("option", { value: "happy", children: `Happy readers (${moodRanges.happyRange})` })] })] })) : null, canLabel ? (
344
+ // 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
345
+ _jsx("div", { className: "chip-row", role: "group", "aria-label": "Filter written answers", children: ['all', 'general', 'technical'].map((option) => (_jsx("button", { className: "chip", type: "button", "aria-pressed": labelFilter === option, onClick: () => setLabelFilter(option), children: option === 'all' ? 'All' : option === 'general' ? 'General' : 'Technical' }, option))) })) : null] })] }), canLabel && (data.unlabeledCount ?? 0) > 0 ? (_jsx("p", { className: "svy-callout", role: "status", children: unlabeledAnswersNotice(data.unlabeledCount ?? 0) })) : null, canLabel && (data.personalDetailsCount ?? 0) > 0 ? (_jsx("p", { className: "svy-callout", role: "status", children: personalDetailsNotice(data.personalDetailsCount ?? 0) })) : null, labelSaveFailed && canLabel ? (_jsx("p", { className: "svy-callout is-alert", role: "alert", children: "The last label change could not be saved. Try again." })) : null, filtering && everyone.kind === 'failed' ? (_jsxs("div", { className: "svy-callout is-alert", role: "alert", children: ["Not every response could be loaded, so the filter has nothing complete to search \u2014 a partial search would hide comments.", ' ', onLoadEveryone ? (_jsx("button", { className: "btn sm", type: "button", onClick: () => onLoadEveryone(total), children: "Retry" })) : null] })) : null, filtering && pool && freeTextCapped ? (_jsx("p", { className: "svy-callout", role: "status", children: `Only the newest ${MAX_FREE_TEXT_ENTRIES} written answers reach this screen, so older ones are not searched. Export CSV has every answer.` })) : null, !pool ? null : visible.length === 0 ? (_jsx("p", { className: "page-sub", children: filtering ? 'No comments match these filters.' : 'No comments on this page.' })) : (_jsx("div", { className: "svy-comments", children: visible.map((response) => {
346
+ const rating = ratingOf(response);
347
+ const mood = moodOf(response);
348
+ const others = (response.answers ?? []).filter((answer) => !textQuestionIds.has(answer.questionId) &&
349
+ answer.writtenValue === undefined &&
350
+ answer.questionId !== headline?.id);
351
+ return (_jsxs("article", { className: "card svy-comment", children: [(writtenByResponse.get(response.id) ?? []).map((entry) => (_jsxs("div", { className: "svy-comment-entry", children: [textQuestions.length > 1 || !textQuestionIds.has(entry.questionId) ? (_jsx("div", { className: "kicker", children: promptById.get(entry.questionId) })) : null, _jsx("blockquote", { className: "svy-quote", children: entry.value }), canLabel && onSetLabel ? (
165
352
  // 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
166
- _jsxs("div", { className: "chip-row", role: "group", "aria-label": `Classify the written answer from ${response.id}`, children: [_jsx("button", { className: "chip", type: "button", "aria-pressed": entry.label === 'technical', onClick: () => onSetLabel(entry.responseId, entry.questionId, 'technical'), children: "Technical" }), _jsx("button", { className: "chip", type: "button", "aria-pressed": entry.label === 'general', onClick: () => onSetLabel(entry.responseId, entry.questionId, 'general'), children: "General" }), entry.labelSource === 'auto' ? (_jsx("span", { className: "chip", title: "Labeled automatically \u2014 your choice overrides it", children: "auto" })) : null] })) : null] }, `${response.id}:${entry.questionId}`)))), _jsx("div", { className: "chip-row", children: choices.map((answer) => (_jsx("span", { className: "chip", title: promptById.get(answer.questionId) ?? answer.questionId, children: formatAnswerValue(answer.value) }, `${response.id}:${answer.questionId}`))) })] }, response.id));
167
- }) })] }));
168
- }
169
- function Aggregates({ data }) {
170
- // Flat counts cannot express the finding that matters most — how one cohort
171
- // differs from another — so every aggregate can be split by another question.
172
- const splitCandidates = data.questions.filter((question) => question.kind === 'single_choice' || question.kind === 'scale');
173
- const [splitBy, setSplitBy] = useState('');
174
- const splitQuestion = splitCandidates.find((question) => question.id === splitBy);
175
- const answersByResponse = useMemo(() => {
176
- const map = new Map();
177
- for (const response of data.responses) {
178
- map.set(response.id, new Map((response.answers ?? []).map((answer) => [answer.questionId, answer.value])));
179
- }
180
- return map;
181
- }, [data.responses]);
182
- const cohorts = useMemo(() => {
183
- if (!splitQuestion)
184
- return null;
185
- const buckets = new Map();
186
- for (const response of data.responses) {
187
- const value = answersByResponse.get(response.id)?.get(splitQuestion.id);
188
- if (value === undefined)
189
- continue;
190
- const key = formatAnswerValue(value);
191
- const ids = buckets.get(key) ?? [];
192
- ids.push(response.id);
193
- buckets.set(key, ids);
194
- }
195
- return buckets;
196
- }, [answersByResponse, data.responses, splitQuestion]);
197
- return (_jsxs("section", { "aria-labelledby": "survey-aggregates", style: { marginTop: 24 }, children: [_jsxs("div", { className: "page-head", children: [_jsx("h2", { className: "page-title", id: "survey-aggregates", style: { fontSize: 22 }, children: "Question aggregates" }), _jsxs("label", { children: [_jsx("span", { className: "page-sub", children: "Split by " }), _jsxs("select", { value: splitBy, onChange: (event) => setSplitBy(event.target.value), children: [_jsx("option", { value: "", children: "No split" }), splitCandidates.map((question) => (_jsx("option", { value: question.id, children: question.prompt }, question.id)))] })] })] }), _jsx("div", { className: "grid-split-wide", style: { alignItems: 'start', marginTop: 12 }, children: data.questions.map((question) => (_jsxs("article", { className: "card", children: [_jsxs("div", { className: "kicker", children: ["Q", question.position + 1, " \u00B7 ", questionAudienceLabel(question.audience)] }), _jsx("h3", { className: "card-title", children: question.prompt }), question.counts && _jsx(CountTable, { values: question.counts }), question.distribution && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "page-sub", children: ["Average: ", question.average === null ? '—' : question.average.toFixed(2)] }), _jsx(CountTable, { values: question.distribution })] })), question.kind === 'text' && (_jsxs("p", { className: "page-sub", children: [question.responseCount ?? 0, " written answers"] })), cohorts && splitQuestion && splitQuestion.id !== question.id ? (_jsx(CrossTab, { question: question, cohorts: cohorts, answersByResponse: answersByResponse })) : null] }, question.id))) })] }));
198
- }
199
- function CrossTab({ question, cohorts, answersByResponse, }) {
200
- const rows = [...cohorts.entries()].map(([cohort, responseIds]) => {
201
- const values = responseIds
202
- .map((id) => answersByResponse.get(id)?.get(question.id))
203
- .filter((value) => value !== undefined);
204
- if (question.kind === 'scale') {
205
- const numbers = values.filter((value) => typeof value === 'number');
206
- const average = numbers.length
207
- ? numbers.reduce((sum, value) => sum + value, 0) / numbers.length
208
- : null;
209
- return { cohort, n: values.length, summary: average === null ? '—' : average.toFixed(2) };
210
- }
211
- const counts = new Map();
212
- for (const value of values.flatMap((value) => (Array.isArray(value) ? value : [value]))) {
213
- const key = formatAnswerValue(value);
214
- counts.set(key, (counts.get(key) ?? 0) + 1);
215
- }
216
- const top = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
217
- return { cohort, n: values.length, summary: top ? `${top[0]} (${top[1]})` : '—' };
218
- });
219
- if (rows.length === 0)
220
- return null;
221
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "masthead-rule" }), _jsxs("table", { className: "tbl", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Cohort" }), _jsx("th", { children: "n" }), _jsx("th", { children: question.kind === 'scale' ? 'Average' : 'Most common' })] }) }), _jsx("tbody", { children: rows.map((row) => (_jsxs("tr", { children: [_jsx("td", { children: row.cohort }), _jsx("td", { children: row.n }), _jsx("td", { children: row.summary })] }, row.cohort))) })] })] }));
353
+ _jsxs("div", { className: "chip-row", role: "group", "aria-label": `Classify the written answer from ${response.id}`, children: [_jsx("button", { className: "chip", type: "button", "aria-pressed": entry.label === 'technical', onClick: () => onSetLabel(entry.responseId, entry.questionId, 'technical'), children: "Technical" }), _jsx("button", { className: "chip", type: "button", "aria-pressed": entry.label === 'general', onClick: () => onSetLabel(entry.responseId, entry.questionId, 'general'), children: "General" }), !entry.label && entry.classificationStatus ? (_jsx("span", { className: "svy-meta", role: "status", children: entry.classificationStatus === 'pending'
354
+ ? 'Classification in progress. Reload to check the result.'
355
+ : entry.classificationStatus === 'manual_review'
356
+ ? 'Choose a label manually.'
357
+ : entry.classificationStatus === 'retry'
358
+ ? `Classification failed. ${entry.nextAttemptAt ? `Retry eligible after ${formatTime(new Date(entry.nextAttemptAt).toISOString())}. ` : ''}Reload to retry, or choose a label.`
359
+ : entry.classificationStatus === 'expired'
360
+ ? 'Classification timed out. Reload to schedule a retry, or choose a label.'
361
+ : entry.classificationStatus === 'unavailable'
362
+ ? 'Classification status unavailable. Reload or choose a label.'
363
+ : 'Awaiting classification. Reload to check, or choose a label.' })) : null, entry.labelSource === 'auto' ? (_jsx("span", { className: "chip is-quiet", title: "Labeled automatically \u2014 your choice overrides it", children: "auto" })) : null] })) : null] }, `${response.id}:${entry.questionId}`))), _jsxs("div", { className: "svy-meta", children: [rating !== null && headline && mood ? (_jsxs("span", { className: "svy-rating", title: headline.prompt, children: [_jsx("span", { className: `svy-swatch mood-${mood}`, "aria-hidden": "true" }), `Rated ${rating} of ${headline.options.max}`] })) : null, _jsx("span", { children: segmentLabel(response.respondentSegment) }), _jsx("span", { children: respondentLabel(response) }), _jsx("time", { dateTime: response.submittedAt, title: formatTime(response.submittedAt), children: formatDate(response.submittedAt) })] }), others.length > 0 ? (_jsxs("details", { className: "svy-answers", children: [_jsx("summary", { children: "Their other answers" }), _jsx("dl", { children: others.map((answer) => (_jsxs("div", { children: [_jsx("dt", { children: promptById.get(answer.questionId) ?? answer.questionId }), _jsx("dd", { children: formatAnswerValue(answer.value) })] }, answer.questionId))) })] })) : null] }, response.id));
364
+ }) })), silent.length > 0 ? (_jsxs("details", { className: "svy-silent", children: [_jsx("summary", { children: `${silent.length} ${silent.length === 1 ? 'response' : 'responses'} on this page left no comment` }), _jsx("ul", { children: silent.map((response) => {
365
+ const rating = ratingOf(response);
366
+ return (_jsx("li", { children: [
367
+ formatDate(response.submittedAt),
368
+ segmentLabel(response.respondentSegment),
369
+ respondentLabel(response),
370
+ rating !== null && headline
371
+ ? `rated ${rating} of ${headline.options.max}`
372
+ : null,
373
+ ]
374
+ .filter(Boolean)
375
+ .join(' · ') }, response.id));
376
+ }) })] })) : null, !filtering && (offset > 0 || hasMore) && onOffsetChange ? (_jsxs("div", { className: "svy-pager", children: [_jsx("button", { className: "btn", type: "button", disabled: offset === 0, onClick: () => onOffsetChange(Math.max(offset - PAGE_SIZE, 0)), children: "Previous" }), _jsx("button", { className: "btn", type: "button", disabled: !hasMore, onClick: () => onOffsetChange(offset + PAGE_SIZE), children: "Next" })] })) : null] }));
222
377
  }
223
378
  async function readSurveyErrorCode(response) {
224
379
  const headerCode = response.headers.get(SURVEY_ERROR_CODE_HEADER);
@@ -243,36 +398,45 @@ function respondentLabel(response) {
243
398
  ? `${response.respondent.label} · ${response.respondent.detail}`
244
399
  : response.respondent.label;
245
400
  }
246
- return response.anonymous ? 'Anonymous' : 'Known respondent';
401
+ return response.anonymous ? 'Anonymous' : 'Signed-in reader';
247
402
  }
248
403
  function formatAnswerValue(value) {
249
404
  if (Array.isArray(value))
250
405
  return value.join(', ');
251
406
  return String(value);
252
407
  }
253
- function StatusPage({ title, detail, retry, }) {
254
- return (_jsxs("div", { className: "page", role: "status", children: [_jsx("h1", { className: "page-title", children: "Survey responses" }), _jsx("p", { className: "page-sub", children: title }), detail ? _jsx("p", { className: "page-sub", children: detail }) : null, retry ? (_jsx("button", { className: "btn", onClick: retry, type: "button", children: "Retry" })) : null] }));
408
+ function StatusPage({ title, detail, retry, picker = null, }) {
409
+ return (_jsxs("div", { className: "page", role: "status", children: [_jsxs("div", { className: "page-head", children: [_jsx("h1", { className: "page-title", children: "Survey responses" }), picker] }), _jsx("p", { className: "page-sub", children: title }), detail ? _jsx("p", { className: "page-sub", children: detail }) : null, retry ? (_jsx("button", { className: "btn", onClick: retry, type: "button", children: "Retry" })) : null] }));
255
410
  }
256
- function CountTable({ values }) {
257
- const total = Object.values(values).reduce((sum, count) => sum + count, 0);
258
- return (_jsx("dl", { className: "count-list", children: Object.entries(values).map(([label, count]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [count, total > 0 ? ` · ${Math.round((count / total) * 100)}%` : ''] })] }, label))) }));
411
+ function surveyStatusLabel(status) {
412
+ if (status === 'active')
413
+ return 'open';
414
+ if (status === 'closed')
415
+ return 'closed';
416
+ return 'draft';
259
417
  }
260
418
  function segmentLabel(segment) {
261
419
  if (segment === 'member')
262
420
  return 'Member';
263
421
  if (segment === 'non_member')
264
422
  return 'Non-member';
265
- return 'Unknown';
423
+ return 'Membership unknown';
266
424
  }
267
425
  function questionAudienceLabel(audience) {
268
426
  if (audience === 'member')
269
- return 'Members';
427
+ return 'asked members';
270
428
  if (audience === 'non_member')
271
- return 'Non-members';
272
- return 'Everyone';
429
+ return 'asked non-members';
430
+ return 'asked everyone';
273
431
  }
274
432
  function formatTime(value) {
275
433
  const date = new Date(value);
276
434
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
277
435
  }
436
+ function formatDate(value) {
437
+ const date = new Date(value);
438
+ return Number.isNaN(date.getTime())
439
+ ? value
440
+ : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
441
+ }
278
442
  //# sourceMappingURL=SurveyResponsesScreen.js.map