@growth-labs/cms 0.8.24 → 0.8.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/engine/published-content.d.ts +4 -0
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +61 -19
- package/dist/engine/published-content.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/options.d.ts +226 -13
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +17 -2
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/types.d.ts +17 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content-insights.d.ts +4 -0
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +104 -0
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +80 -4
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +64 -64
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -4
- package/dist/schema/migrations.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +31 -4
- package/dist/surveys/auto-label.d.ts.map +1 -1
- package/dist/surveys/auto-label.js +160 -29
- package/dist/surveys/auto-label.js.map +1 -1
- package/dist/surveys/readout.d.ts +69 -0
- package/dist/surveys/readout.d.ts.map +1 -0
- package/dist/surveys/readout.js +157 -0
- package/dist/surveys/readout.js.map +1 -0
- package/dist/surveys/schema.d.ts +156 -0
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +50 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
- package/dist/ui/api/content/insights-inputs.js +11 -1
- package/dist/ui/api/content/insights-inputs.js.map +1 -1
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.js +16 -2
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +36 -3
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +271 -107
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
- package/dist/ui/screens/podcast-listening-data.js +26 -1
- package/dist/ui/screens/podcast-listening-data.js.map +1 -1
- package/dist/ui/screens/registry.d.ts +3 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +4 -2
- package/dist/ui/screens/registry.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +136 -0
- package/package.json +2 -3
- package/src/engine/published-content.ts +86 -19
- package/src/index.ts +1 -0
- package/src/integration/options.ts +21 -1
- package/src/providers/types.ts +16 -1
- package/src/routes/content-insights.ts +104 -0
- package/src/routes/surveys.ts +106 -4
- package/src/schema/migrations.ts +7 -4
- package/src/surveys/auto-label.ts +204 -33
- package/src/surveys/readout.ts +243 -0
- package/src/surveys/schema.ts +59 -1
- package/src/ui/api/content/insights-inputs.ts +8 -1
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +964 -305
- package/src/ui/screens/podcast-listening-data.ts +33 -1
- package/src/ui/screens/registry.tsx +6 -2
- package/src/ui/styles/broadsheet.css +136 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
120
|
-
function
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
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
|
|
266
|
+
function Comments({ data, offset, canLabel, onSetLabel, labelSaveFailed, onOffsetChange, everyone, onLoadEveryone, initialMood, }) {
|
|
147
267
|
const [labelFilter, setLabelFilter] = useState('all');
|
|
148
|
-
const
|
|
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
|
|
153
|
-
const
|
|
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 =
|
|
314
|
+
const entries = writtenByResponse.get(entry.responseId) ?? [];
|
|
156
315
|
entries.push(entry);
|
|
157
|
-
|
|
316
|
+
writtenByResponse.set(entry.responseId, entries);
|
|
158
317
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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' : '
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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 '
|
|
423
|
+
return 'Membership unknown';
|
|
266
424
|
}
|
|
267
425
|
function questionAudienceLabel(audience) {
|
|
268
426
|
if (audience === 'member')
|
|
269
|
-
return '
|
|
427
|
+
return 'asked members';
|
|
270
428
|
if (audience === 'non_member')
|
|
271
|
-
return '
|
|
272
|
-
return '
|
|
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
|