@growth-labs/cms 0.8.20 → 0.8.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/foundry-emit.d.ts +14 -0
- package/dist/cli/foundry-emit.d.ts.map +1 -0
- package/dist/cli/foundry-emit.js +69 -0
- package/dist/cli/foundry-emit.js.map +1 -0
- package/dist/engine/foundry-dispatch.d.ts +1 -0
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +1 -1
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +12 -1
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-receipt/receipt.d.ts +20 -20
- package/dist/engine/publisher.d.ts +16 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +55 -4
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +18 -1
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/topics.d.ts.map +1 -1
- package/dist/engine/topics.js +4 -1
- package/dist/engine/topics.js.map +1 -1
- package/dist/engine/video-source.d.ts +21 -0
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +40 -0
- package/dist/engine/video-source.js.map +1 -1
- package/dist/foundry-emit.d.ts +146 -0
- package/dist/foundry-emit.d.ts.map +1 -0
- package/dist/foundry-emit.js +189 -0
- package/dist/foundry-emit.js.map +1 -0
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +24 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +22 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +6 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +21 -13
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/analytics.d.ts +1 -0
- package/dist/routes/analytics.d.ts.map +1 -1
- package/dist/routes/analytics.js +20 -0
- package/dist/routes/analytics.js.map +1 -1
- package/dist/routes/content.d.ts +27 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +58 -12
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts +50 -0
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +540 -27
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/machine-context.d.ts +6 -0
- package/dist/routes/machine-context.d.ts.map +1 -0
- package/dist/routes/machine-context.js +20 -0
- package/dist/routes/machine-context.js.map +1 -0
- package/dist/routes/surveys.d.ts +14 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +515 -10
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +8 -8
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/surveys/access.d.ts +1 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +13 -4
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +100 -0
- package/dist/surveys/auto-label.d.ts.map +1 -0
- package/dist/surveys/auto-label.js +325 -0
- package/dist/surveys/auto-label.js.map +1 -0
- package/dist/surveys/schema.d.ts +38 -12
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +18 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +4 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/analytics.d.ts.map +1 -1
- package/dist/ui/api/analytics.js +5 -2
- package/dist/ui/api/analytics.js.map +1 -1
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
- package/dist/ui/api/surveys/[surveyId].js +28 -1
- package/dist/ui/api/surveys/[surveyId].js.map +1 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +2 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +1 -0
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +13 -8
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
- package/dist/ui/screens/PodcastListeningScreen.js +244 -0
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/analytics-data.d.ts +14 -0
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -0
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
- package/dist/ui/screens/podcast-listening-data.js +98 -0
- package/dist/ui/screens/podcast-listening-data.js.map +1 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -0
- package/dist/ui/screens/registry.js.map +1 -1
- package/docs/foundry-publish.md +208 -0
- package/migrations/0033_survey_answer_labels.sql +15 -0
- package/package.json +9 -3
- package/src/cli/foundry-emit.ts +85 -0
- package/src/engine/foundry-dispatch.ts +1 -1
- package/src/engine/fronts-publish-intent.ts +23 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/publisher.ts +80 -4
- package/src/engine/tags.ts +21 -1
- package/src/engine/topics.ts +4 -1
- package/src/engine/video-source.ts +44 -0
- package/src/foundry-emit.ts +255 -0
- package/src/integration/index.ts +9 -0
- package/src/integration/options.ts +23 -0
- package/src/providers/null.ts +6 -0
- package/src/providers/types.ts +52 -0
- package/src/routes/ai.ts +29 -17
- package/src/routes/analytics.ts +21 -0
- package/src/routes/content.ts +81 -15
- package/src/routes/foundry-publish.ts +746 -29
- package/src/routes/machine-context.ts +24 -0
- package/src/routes/surveys.ts +587 -12
- package/src/schema/migrations.ts +15 -0
- package/src/schema/tables.ts +2 -0
- package/src/surveys/access.ts +14 -4
- package/src/surveys/auto-label.ts +420 -0
- package/src/surveys/schema.ts +25 -1
- package/src/ui/api/_content-config.ts +6 -0
- package/src/ui/api/analytics.ts +5 -2
- package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
- package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +37 -3
- package/src/ui/commands.ts +2 -0
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/nav.ts +1 -0
- package/src/ui/screens/AnalyticsScreen.tsx +34 -6
- package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
- package/src/ui/screens/analytics-data.ts +18 -0
- package/src/ui/screens/podcast-listening-data.ts +114 -0
- package/src/ui/screens/registry.tsx +2 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import type { CmsRole } from '../../schema/types.js'
|
|
3
|
-
import { canViewSurveyVerbatims } from '../../surveys/access.js'
|
|
3
|
+
import { canLabelSurveyAnswers, canViewSurveyVerbatims } from '../../surveys/access.js'
|
|
4
4
|
import {
|
|
5
5
|
isSurveyErrorCode,
|
|
6
6
|
SURVEY_ERROR_CODE_HEADER,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type SurveyErrorCode,
|
|
9
9
|
} from '../../surveys/errors.js'
|
|
10
10
|
import {
|
|
11
|
+
type SurveyAnswerLabel,
|
|
11
12
|
type SurveyQuestion,
|
|
12
13
|
type SurveyResults,
|
|
13
14
|
SurveyResultsSchema,
|
|
@@ -58,6 +59,7 @@ const PAGE_SIZE = 100
|
|
|
58
59
|
export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
|
|
59
60
|
const [state, setState] = useState<SurveyResponsesState>({ kind: 'loading' })
|
|
60
61
|
const [offset, setOffset] = useState(0)
|
|
62
|
+
const [labelSaveFailed, setLabelSaveFailed] = useState(false)
|
|
61
63
|
const load = useCallback(
|
|
62
64
|
async (nextOffset: number) => {
|
|
63
65
|
setState({ kind: 'loading' })
|
|
@@ -73,6 +75,40 @@ export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
|
|
|
73
75
|
[surveyId],
|
|
74
76
|
)
|
|
75
77
|
|
|
78
|
+
// Owner-only classification toggle: on success the label is applied to the
|
|
79
|
+
// already-loaded page (no refetch); on failure the toggle stays honest on
|
|
80
|
+
// its old value and a note says the save did not land.
|
|
81
|
+
const setLabel = useCallback(
|
|
82
|
+
async (responseId: string, questionId: string, label: SurveyAnswerLabel) => {
|
|
83
|
+
setLabelSaveFailed(false)
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(
|
|
86
|
+
`/admin/api/surveys/${encodeURIComponent(surveyId)}/answers/label`,
|
|
87
|
+
{
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ responseId, questionId, label }),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
setLabelSaveFailed(true)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
setState((previous) =>
|
|
98
|
+
previous.kind === 'ready'
|
|
99
|
+
? {
|
|
100
|
+
kind: 'ready',
|
|
101
|
+
data: withAnswerLabel(previous.data, responseId, questionId, label),
|
|
102
|
+
}
|
|
103
|
+
: previous,
|
|
104
|
+
)
|
|
105
|
+
} catch {
|
|
106
|
+
setLabelSaveFailed(true)
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[surveyId],
|
|
110
|
+
)
|
|
111
|
+
|
|
76
112
|
useEffect(() => {
|
|
77
113
|
void load(offset)
|
|
78
114
|
}, [load, offset])
|
|
@@ -87,10 +123,30 @@ export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
|
|
|
87
123
|
role={workspace.user?.role ?? null}
|
|
88
124
|
onRetry={() => void load(offset)}
|
|
89
125
|
onOffsetChange={setOffset}
|
|
126
|
+
onSetLabel={setLabel}
|
|
127
|
+
labelSaveFailed={labelSaveFailed}
|
|
90
128
|
/>
|
|
91
129
|
)
|
|
92
130
|
}
|
|
93
131
|
|
|
132
|
+
function withAnswerLabel(
|
|
133
|
+
data: SurveyResults,
|
|
134
|
+
responseId: string,
|
|
135
|
+
questionId: string,
|
|
136
|
+
label: SurveyAnswerLabel,
|
|
137
|
+
): SurveyResults {
|
|
138
|
+
return {
|
|
139
|
+
...data,
|
|
140
|
+
freeText: data.freeText.map((entry) =>
|
|
141
|
+
entry.responseId === responseId && entry.questionId === questionId
|
|
142
|
+
? // A successful owner save stores source 'owner' server-side; the
|
|
143
|
+
// optimistic copy reflects that instead of a stale 'auto' badge.
|
|
144
|
+
{ ...entry, label, labelSource: 'owner' }
|
|
145
|
+
: entry,
|
|
146
|
+
),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
export function SurveyResponsesBody({
|
|
95
151
|
surveyId,
|
|
96
152
|
state,
|
|
@@ -98,6 +154,8 @@ export function SurveyResponsesBody({
|
|
|
98
154
|
role = null,
|
|
99
155
|
onRetry,
|
|
100
156
|
onOffsetChange,
|
|
157
|
+
onSetLabel,
|
|
158
|
+
labelSaveFailed = false,
|
|
101
159
|
}: {
|
|
102
160
|
surveyId: string
|
|
103
161
|
state: SurveyResponsesState
|
|
@@ -105,8 +163,11 @@ export function SurveyResponsesBody({
|
|
|
105
163
|
role?: CmsRole | null
|
|
106
164
|
onRetry?: () => void
|
|
107
165
|
onOffsetChange?: (offset: number) => void
|
|
166
|
+
onSetLabel?: (responseId: string, questionId: string, label: SurveyAnswerLabel) => void
|
|
167
|
+
labelSaveFailed?: boolean
|
|
108
168
|
}) {
|
|
109
169
|
const canSeeVerbatims = canViewSurveyVerbatims(role)
|
|
170
|
+
const canLabel = canLabelSurveyAnswers(role)
|
|
110
171
|
|
|
111
172
|
if (state.kind === 'loading') return <StatusPage title="Loading survey responses…" />
|
|
112
173
|
if (state.kind === 'forbidden')
|
|
@@ -172,7 +233,24 @@ export function SurveyResponsesBody({
|
|
|
172
233
|
<HeadlineScale questions={data.questions} />
|
|
173
234
|
|
|
174
235
|
{canSeeVerbatims ? (
|
|
175
|
-
|
|
236
|
+
<>
|
|
237
|
+
{canLabel && (data.unlabeledCount ?? 0) > 0 ? (
|
|
238
|
+
<p className="page-sub" role="status" style={{ marginTop: 24 }}>
|
|
239
|
+
{unlabeledAnswersNotice(data.unlabeledCount ?? 0)}
|
|
240
|
+
</p>
|
|
241
|
+
) : null}
|
|
242
|
+
{canLabel && (data.personalDetailsCount ?? 0) > 0 ? (
|
|
243
|
+
<p className="page-sub" role="status" style={{ marginTop: 24 }}>
|
|
244
|
+
{personalDetailsNotice(data.personalDetailsCount ?? 0)}
|
|
245
|
+
</p>
|
|
246
|
+
) : null}
|
|
247
|
+
{labelSaveFailed && canLabel ? (
|
|
248
|
+
<p className="page-sub" role="alert" style={{ marginTop: 24 }}>
|
|
249
|
+
The last label change could not be saved. Try again.
|
|
250
|
+
</p>
|
|
251
|
+
) : null}
|
|
252
|
+
<VerbatimFeed data={data} canLabel={canLabel} onSetLabel={onSetLabel} />
|
|
253
|
+
</>
|
|
176
254
|
) : (
|
|
177
255
|
<div className="card" role="status" style={{ marginTop: 24 }}>
|
|
178
256
|
<h2 className="card-title">Individual answers are restricted</h2>
|
|
@@ -215,7 +293,7 @@ export function SurveyResponsesBody({
|
|
|
215
293
|
// distribution keeps one harsh verbatim from setting the tone of a readout.
|
|
216
294
|
function HeadlineScale({ questions }: { questions: SurveyResults['questions'] }) {
|
|
217
295
|
const scale = questions.find((question) => question.kind === 'scale')
|
|
218
|
-
if (
|
|
296
|
+
if (scale?.kind !== 'scale' || scale.answerCount === 0) return null
|
|
219
297
|
const midpoint = (scale.options.min + scale.options.max) / 2
|
|
220
298
|
const favourable = Object.entries(scale.distribution)
|
|
221
299
|
.filter(([value]) => Number(value) > midpoint)
|
|
@@ -234,23 +312,75 @@ function HeadlineScale({ questions }: { questions: SurveyResults['questions'] })
|
|
|
234
312
|
)
|
|
235
313
|
}
|
|
236
314
|
|
|
237
|
-
|
|
315
|
+
type WrittenAnswer = SurveyResults['freeText'][number]
|
|
316
|
+
|
|
317
|
+
// The owner's loud surface for answers auto-labeling has not placed yet
|
|
318
|
+
// (publishing #24): everything unlabeled is hidden from the team, so he sees
|
|
319
|
+
// the backlog instead of a silent one-warn in the logs.
|
|
320
|
+
function unlabeledAnswersNotice(count: number): string {
|
|
321
|
+
return `${
|
|
322
|
+
count === 1
|
|
323
|
+
? '1 written answer on this page isn’t labeled yet.'
|
|
324
|
+
: `${count} written answers on this page aren’t labeled yet.`
|
|
325
|
+
} They’re hidden from the team until labeled. Automatic labeling runs in the background; reload in a minute, or label them yourself.`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The subset the personal-details pre-filter will never dispatch: only the
|
|
329
|
+
// owner can label these, so he is told directly rather than left a backlog.
|
|
330
|
+
function personalDetailsNotice(count: number): string {
|
|
331
|
+
return count === 1
|
|
332
|
+
? '1 answer contains personal details and needs your label.'
|
|
333
|
+
: `${count} answers contain personal details and need your label.`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function VerbatimFeed({
|
|
337
|
+
data,
|
|
338
|
+
canLabel,
|
|
339
|
+
onSetLabel,
|
|
340
|
+
}: {
|
|
341
|
+
data: SurveyResults
|
|
342
|
+
canLabel: boolean
|
|
343
|
+
onSetLabel?: (responseId: string, questionId: string, label: SurveyAnswerLabel) => void
|
|
344
|
+
}) {
|
|
345
|
+
const [labelFilter, setLabelFilter] = useState<'all' | 'general' | 'technical'>('all')
|
|
238
346
|
const textQuestionIds = new Set(
|
|
239
347
|
data.questions.filter((question) => question.kind === 'text').map((question) => question.id),
|
|
240
348
|
)
|
|
241
349
|
const promptById = new Map(data.questions.map((question) => [question.id, question.prompt]))
|
|
242
|
-
|
|
243
|
-
for
|
|
350
|
+
// Non-owners never receive technical or unlabeled entries (the route filters
|
|
351
|
+
// server-side), so for them this feed is already the general thread.
|
|
352
|
+
const visibleFreeText =
|
|
353
|
+
labelFilter === 'all' ? data.freeText : data.freeText.filter((e) => e.label === labelFilter)
|
|
354
|
+
const freeTextByResponse = new Map<string, WrittenAnswer[]>()
|
|
355
|
+
for (const entry of visibleFreeText) {
|
|
244
356
|
const entries = freeTextByResponse.get(entry.responseId) ?? []
|
|
245
|
-
entries.push(entry
|
|
357
|
+
entries.push(entry)
|
|
246
358
|
freeTextByResponse.set(entry.responseId, entries)
|
|
247
359
|
}
|
|
248
360
|
|
|
249
361
|
return (
|
|
250
362
|
<section aria-labelledby="survey-verbatims" style={{ marginTop: 24 }}>
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
363
|
+
<div className="page-head">
|
|
364
|
+
<h2 className="page-title" id="survey-verbatims" style={{ fontSize: 22 }}>
|
|
365
|
+
Responses
|
|
366
|
+
</h2>
|
|
367
|
+
{canLabel ? (
|
|
368
|
+
// 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
|
|
369
|
+
<div className="chip-row" role="group" aria-label="Filter written answers">
|
|
370
|
+
{(['all', 'general', 'technical'] as const).map((option) => (
|
|
371
|
+
<button
|
|
372
|
+
className="chip"
|
|
373
|
+
key={option}
|
|
374
|
+
type="button"
|
|
375
|
+
aria-pressed={labelFilter === option}
|
|
376
|
+
onClick={() => setLabelFilter(option)}
|
|
377
|
+
>
|
|
378
|
+
{option === 'all' ? 'All' : option === 'general' ? 'General' : 'Technical'}
|
|
379
|
+
</button>
|
|
380
|
+
))}
|
|
381
|
+
</div>
|
|
382
|
+
) : null}
|
|
383
|
+
</div>
|
|
254
384
|
<div style={{ marginTop: 12 }}>
|
|
255
385
|
{data.responses.map((response) => {
|
|
256
386
|
const written = freeTextByResponse.get(response.id) ?? []
|
|
@@ -266,9 +396,45 @@ function VerbatimFeed({ data }: { data: SurveyResults }) {
|
|
|
266
396
|
{written.length === 0 ? (
|
|
267
397
|
<p className="page-sub">No written answer.</p>
|
|
268
398
|
) : (
|
|
269
|
-
written.map((
|
|
270
|
-
|
|
271
|
-
|
|
399
|
+
written.map((entry) => (
|
|
400
|
+
<div key={`${response.id}:${entry.questionId}`}>
|
|
401
|
+
<blockquote>{entry.value}</blockquote>
|
|
402
|
+
{canLabel && onSetLabel ? (
|
|
403
|
+
// biome-ignore lint/a11y/useSemanticElements: chip-row is a flex div throughout the admin UI; a fieldset would import UA border/padding with no reset in the publication CSS, while role="group" + aria-label keeps the grouping semantics
|
|
404
|
+
<div
|
|
405
|
+
className="chip-row"
|
|
406
|
+
role="group"
|
|
407
|
+
aria-label={`Classify the written answer from ${response.id}`}
|
|
408
|
+
>
|
|
409
|
+
<button
|
|
410
|
+
className="chip"
|
|
411
|
+
type="button"
|
|
412
|
+
aria-pressed={entry.label === 'technical'}
|
|
413
|
+
onClick={() =>
|
|
414
|
+
onSetLabel(entry.responseId, entry.questionId, 'technical')
|
|
415
|
+
}
|
|
416
|
+
>
|
|
417
|
+
Technical
|
|
418
|
+
</button>
|
|
419
|
+
<button
|
|
420
|
+
className="chip"
|
|
421
|
+
type="button"
|
|
422
|
+
aria-pressed={entry.label === 'general'}
|
|
423
|
+
onClick={() => onSetLabel(entry.responseId, entry.questionId, 'general')}
|
|
424
|
+
>
|
|
425
|
+
General
|
|
426
|
+
</button>
|
|
427
|
+
{entry.labelSource === 'auto' ? (
|
|
428
|
+
<span
|
|
429
|
+
className="chip"
|
|
430
|
+
title="Labeled automatically — your choice overrides it"
|
|
431
|
+
>
|
|
432
|
+
auto
|
|
433
|
+
</span>
|
|
434
|
+
) : null}
|
|
435
|
+
</div>
|
|
436
|
+
) : null}
|
|
437
|
+
</div>
|
|
272
438
|
))
|
|
273
439
|
)}
|
|
274
440
|
<div className="chip-row">
|
|
@@ -18,6 +18,24 @@ export interface AnalyticsData {
|
|
|
18
18
|
trafficSources: ProviderResult<TrafficSource[]>
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Caption for the traffic-sources panel: the metric is sessions summed from
|
|
23
|
+
* fact_content_source_daily, which only covers content pages — not the whole
|
|
24
|
+
* site (report P0-1).
|
|
25
|
+
*/
|
|
26
|
+
export const TRAFFIC_SOURCES_CAPTION = 'sessions by source — content pages only'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Label for the unique-visitors KPI tile (report P0-2). Ranges inside the
|
|
30
|
+
* ~90-day WAE retention window report true range uniques ("Unique
|
|
31
|
+
* visitors"); longer ranges report sum ÷ days, which the screen must never
|
|
32
|
+
* present as uniques. The 90 here mirrors the provider's
|
|
33
|
+
* RANGE_UNIQUES_MAX_DAYS.
|
|
34
|
+
*/
|
|
35
|
+
export function uniqueVisitorsKpiLabel(rangeDays: number): string {
|
|
36
|
+
return Number.isFinite(rangeDays) && rangeDays > 90 ? 'Avg daily visitors' : 'Unique visitors'
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
type ProviderStatus = ProviderResult<unknown>['status']
|
|
22
40
|
|
|
23
41
|
function providerStatus(value: unknown): ProviderStatus {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { PodcastListening, PodcastListeningEpisode } from '../../providers/types.js'
|
|
2
|
+
|
|
3
|
+
export type PodcastSortKey =
|
|
4
|
+
| 'episode'
|
|
5
|
+
| 'plays'
|
|
6
|
+
| 'listeners'
|
|
7
|
+
| 'reached10'
|
|
8
|
+
| 'reached25'
|
|
9
|
+
| 'reached50'
|
|
10
|
+
| 'reached75'
|
|
11
|
+
| 'reached90'
|
|
12
|
+
| 'completes'
|
|
13
|
+
| 'completionRate'
|
|
14
|
+
| 'totalListeningSeconds'
|
|
15
|
+
|
|
16
|
+
/** Defensive read of one metric: a non-finite value renders as 0, not NaN. */
|
|
17
|
+
function num(value: unknown): number {
|
|
18
|
+
const n = typeof value === 'number' ? value : Number(value)
|
|
19
|
+
return Number.isFinite(n) ? n : 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeEpisode(input: unknown): PodcastListeningEpisode | null {
|
|
23
|
+
if (!input || typeof input !== 'object') return null
|
|
24
|
+
const row = input as Record<string, unknown>
|
|
25
|
+
const slug = typeof row.slug === 'string' ? row.slug : ''
|
|
26
|
+
const hasListening = typeof row.totalListeningSeconds === 'number'
|
|
27
|
+
const listening = hasListening ? num(row.totalListeningSeconds) : null
|
|
28
|
+
const listeners = num(row.listeners)
|
|
29
|
+
return {
|
|
30
|
+
slug,
|
|
31
|
+
...(typeof row.title === 'string' && row.title.length > 0 ? { title: row.title } : {}),
|
|
32
|
+
plays: num(row.plays),
|
|
33
|
+
listeners,
|
|
34
|
+
reached10: num(row.reached10),
|
|
35
|
+
reached25: num(row.reached25),
|
|
36
|
+
reached50: num(row.reached50),
|
|
37
|
+
reached75: num(row.reached75),
|
|
38
|
+
reached90: num(row.reached90),
|
|
39
|
+
completes: num(row.completes),
|
|
40
|
+
completionRate: num(row.completionRate),
|
|
41
|
+
totalListeningSeconds: listening,
|
|
42
|
+
avgListeningSecondsPerListener:
|
|
43
|
+
listening !== null && listeners > 0 ? listening / listeners : null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize the GET /admin/api/analytics?type=podcastListening body. The route
|
|
49
|
+
* returns the provider's PodcastListening verbatim; this keeps a malformed
|
|
50
|
+
* host payload from crashing the screen — broken rows drop, broken numbers
|
|
51
|
+
* read as 0, and a body that is not a PodcastListening at all yields null
|
|
52
|
+
* (rendered as the explicit unavailable state, never silent zeros).
|
|
53
|
+
*/
|
|
54
|
+
export function normalizePodcastListening(input: unknown): PodcastListening | null {
|
|
55
|
+
if (!input || typeof input !== 'object') return null
|
|
56
|
+
const body = input as Record<string, unknown>
|
|
57
|
+
if (!Array.isArray(body.episodes)) return null
|
|
58
|
+
const episodes = body.episodes
|
|
59
|
+
.map(normalizeEpisode)
|
|
60
|
+
.filter((e): e is PodcastListeningEpisode => e !== null)
|
|
61
|
+
const totalsRow = body.totals as Record<string, unknown> | undefined
|
|
62
|
+
const totals = totalsRow ?? {}
|
|
63
|
+
const rangeRow = body.range as Record<string, unknown> | undefined
|
|
64
|
+
return {
|
|
65
|
+
episodes,
|
|
66
|
+
totals: {
|
|
67
|
+
episodes: num(totals.episodes),
|
|
68
|
+
plays: num(totals.plays),
|
|
69
|
+
listeners: num(totals.listeners),
|
|
70
|
+
completes: num(totals.completes),
|
|
71
|
+
completionRate: num(totals.completionRate),
|
|
72
|
+
totalListeningSeconds:
|
|
73
|
+
typeof totals.totalListeningSeconds === 'number' ? num(totals.totalListeningSeconds) : null,
|
|
74
|
+
},
|
|
75
|
+
range: {
|
|
76
|
+
from: typeof rangeRow?.from === 'string' ? rangeRow.from : '',
|
|
77
|
+
to: typeof rangeRow?.to === 'string' ? rangeRow.to : '',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sort episodes for the table. `episode` sorts by display title then slug;
|
|
84
|
+
* every metric sorts descending-first (the interesting end), then ascending on
|
|
85
|
+
* the second click.
|
|
86
|
+
*/
|
|
87
|
+
export function sortEpisodes(
|
|
88
|
+
episodes: readonly PodcastListeningEpisode[],
|
|
89
|
+
key: PodcastSortKey,
|
|
90
|
+
dir: 'asc' | 'desc',
|
|
91
|
+
): PodcastListeningEpisode[] {
|
|
92
|
+
const factor = dir === 'asc' ? 1 : -1
|
|
93
|
+
return [...episodes].sort((a, b) => {
|
|
94
|
+
if (key === 'episode') {
|
|
95
|
+
const cmp = episodeLabel(a).localeCompare(episodeLabel(b))
|
|
96
|
+
return factor * cmp
|
|
97
|
+
}
|
|
98
|
+
const av = a[key]
|
|
99
|
+
const bv = b[key]
|
|
100
|
+
// Unmeasured listening time always sinks to the bottom regardless of
|
|
101
|
+
// direction — it is an unknown, not a small number.
|
|
102
|
+
if (av === null && bv === null) return episodeLabel(a).localeCompare(episodeLabel(b))
|
|
103
|
+
if (av === null) return 1
|
|
104
|
+
if (bv === null) return -1
|
|
105
|
+
if (av === bv) return episodeLabel(a).localeCompare(episodeLabel(b))
|
|
106
|
+
return factor * (av - bv)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The table's display name: the D1 title, the slug, or the unattributed label. */
|
|
111
|
+
export function episodeLabel(episode: Pick<PodcastListeningEpisode, 'slug' | 'title'>): string {
|
|
112
|
+
if (episode.slug === 'unattributed') return '(unattributed episodes)'
|
|
113
|
+
return episode.title && episode.title.length > 0 ? episode.title : episode.slug
|
|
114
|
+
}
|
|
@@ -21,6 +21,7 @@ import { ContentInsightsScreen } from './ContentInsightsScreen.js'
|
|
|
21
21
|
import { ContentRoute } from './ContentRoute.js'
|
|
22
22
|
import { DashboardScreen } from './DashboardScreen.js'
|
|
23
23
|
import { MediaScreen } from './MediaScreen.js'
|
|
24
|
+
import { PodcastListeningScreen } from './PodcastListeningScreen.js'
|
|
24
25
|
import { SettingsScreen } from './SettingsScreen.js'
|
|
25
26
|
import { SocialShareScreen } from './SocialShareScreen.js'
|
|
26
27
|
import { SubscriptionsScreen } from './SubscriptionsScreen.js'
|
|
@@ -67,6 +68,7 @@ export function createCmsScreens({
|
|
|
67
68
|
calendar: () => <CalendarRoute />,
|
|
68
69
|
media: () => <MediaScreen />,
|
|
69
70
|
analytics: () => <AnalyticsScreen />,
|
|
71
|
+
podcasts: () => <PodcastListeningScreen />,
|
|
70
72
|
authors: () => <AuthorsScreen />,
|
|
71
73
|
topics: () => <TopicsScreen />,
|
|
72
74
|
subscriptions: () => <SubscriptionsScreen />,
|