@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.
Files changed (182) hide show
  1. package/dist/cli/foundry-emit.d.ts +14 -0
  2. package/dist/cli/foundry-emit.d.ts.map +1 -0
  3. package/dist/cli/foundry-emit.js +69 -0
  4. package/dist/cli/foundry-emit.js.map +1 -0
  5. package/dist/engine/foundry-dispatch.d.ts +1 -0
  6. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  7. package/dist/engine/foundry-dispatch.js +1 -1
  8. package/dist/engine/foundry-dispatch.js.map +1 -1
  9. package/dist/engine/fronts-publish-intent.d.ts +1 -1
  10. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  11. package/dist/engine/fronts-publish-intent.js +12 -1
  12. package/dist/engine/fronts-publish-intent.js.map +1 -1
  13. package/dist/engine/index.d.ts +1 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +2 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  18. package/dist/engine/publisher.d.ts +16 -1
  19. package/dist/engine/publisher.d.ts.map +1 -1
  20. package/dist/engine/publisher.js +55 -4
  21. package/dist/engine/publisher.js.map +1 -1
  22. package/dist/engine/tags.d.ts.map +1 -1
  23. package/dist/engine/tags.js +18 -1
  24. package/dist/engine/tags.js.map +1 -1
  25. package/dist/engine/topics.d.ts.map +1 -1
  26. package/dist/engine/topics.js +4 -1
  27. package/dist/engine/topics.js.map +1 -1
  28. package/dist/engine/video-source.d.ts +21 -0
  29. package/dist/engine/video-source.d.ts.map +1 -1
  30. package/dist/engine/video-source.js +40 -0
  31. package/dist/engine/video-source.js.map +1 -1
  32. package/dist/foundry-emit.d.ts +146 -0
  33. package/dist/foundry-emit.d.ts.map +1 -0
  34. package/dist/foundry-emit.js +189 -0
  35. package/dist/foundry-emit.js.map +1 -0
  36. package/dist/integration/index.d.ts.map +1 -1
  37. package/dist/integration/index.js +9 -0
  38. package/dist/integration/index.js.map +1 -1
  39. package/dist/integration/options.d.ts +24 -0
  40. package/dist/integration/options.d.ts.map +1 -1
  41. package/dist/integration/options.js +22 -0
  42. package/dist/integration/options.js.map +1 -1
  43. package/dist/providers/null.d.ts.map +1 -1
  44. package/dist/providers/null.js +6 -0
  45. package/dist/providers/null.js.map +1 -1
  46. package/dist/providers/types.d.ts +56 -0
  47. package/dist/providers/types.d.ts.map +1 -1
  48. package/dist/providers/types.js.map +1 -1
  49. package/dist/routes/ai.d.ts.map +1 -1
  50. package/dist/routes/ai.js +21 -13
  51. package/dist/routes/ai.js.map +1 -1
  52. package/dist/routes/analytics.d.ts +1 -0
  53. package/dist/routes/analytics.d.ts.map +1 -1
  54. package/dist/routes/analytics.js +20 -0
  55. package/dist/routes/analytics.js.map +1 -1
  56. package/dist/routes/content.d.ts +27 -0
  57. package/dist/routes/content.d.ts.map +1 -1
  58. package/dist/routes/content.js +58 -12
  59. package/dist/routes/content.js.map +1 -1
  60. package/dist/routes/foundry-publish.d.ts +50 -0
  61. package/dist/routes/foundry-publish.d.ts.map +1 -1
  62. package/dist/routes/foundry-publish.js +540 -27
  63. package/dist/routes/foundry-publish.js.map +1 -1
  64. package/dist/routes/machine-context.d.ts +6 -0
  65. package/dist/routes/machine-context.d.ts.map +1 -0
  66. package/dist/routes/machine-context.js +20 -0
  67. package/dist/routes/machine-context.js.map +1 -0
  68. package/dist/routes/surveys.d.ts +14 -1
  69. package/dist/routes/surveys.d.ts.map +1 -1
  70. package/dist/routes/surveys.js +515 -10
  71. package/dist/routes/surveys.js.map +1 -1
  72. package/dist/schema/insights-ingest.d.ts +8 -8
  73. package/dist/schema/migrations.d.ts.map +1 -1
  74. package/dist/schema/migrations.js +15 -0
  75. package/dist/schema/migrations.js.map +1 -1
  76. package/dist/schema/tables.d.ts +1 -1
  77. package/dist/schema/tables.d.ts.map +1 -1
  78. package/dist/schema/tables.js +2 -0
  79. package/dist/schema/tables.js.map +1 -1
  80. package/dist/surveys/access.d.ts +1 -0
  81. package/dist/surveys/access.d.ts.map +1 -1
  82. package/dist/surveys/access.js +13 -4
  83. package/dist/surveys/access.js.map +1 -1
  84. package/dist/surveys/auto-label.d.ts +100 -0
  85. package/dist/surveys/auto-label.d.ts.map +1 -0
  86. package/dist/surveys/auto-label.js +325 -0
  87. package/dist/surveys/auto-label.js.map +1 -0
  88. package/dist/surveys/schema.d.ts +38 -12
  89. package/dist/surveys/schema.d.ts.map +1 -1
  90. package/dist/surveys/schema.js +18 -1
  91. package/dist/surveys/schema.js.map +1 -1
  92. package/dist/ui/api/_content-config.d.ts.map +1 -1
  93. package/dist/ui/api/_content-config.js +4 -0
  94. package/dist/ui/api/_content-config.js.map +1 -1
  95. package/dist/ui/api/analytics.d.ts.map +1 -1
  96. package/dist/ui/api/analytics.js +5 -2
  97. package/dist/ui/api/analytics.js.map +1 -1
  98. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  99. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  100. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  101. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  102. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  103. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  104. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  105. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  106. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  107. package/dist/ui/api/surveys/[surveyId].js +28 -1
  108. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  109. package/dist/ui/commands.d.ts.map +1 -1
  110. package/dist/ui/commands.js +2 -0
  111. package/dist/ui/commands.js.map +1 -1
  112. package/dist/ui/hash-router.d.ts +1 -1
  113. package/dist/ui/hash-router.d.ts.map +1 -1
  114. package/dist/ui/hash-router.js +1 -0
  115. package/dist/ui/hash-router.js.map +1 -1
  116. package/dist/ui/nav.d.ts.map +1 -1
  117. package/dist/ui/nav.js +1 -0
  118. package/dist/ui/nav.js.map +1 -1
  119. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  120. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  122. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  123. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  124. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  125. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  126. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  127. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  128. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  129. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  130. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  131. package/dist/ui/screens/analytics-data.d.ts +14 -0
  132. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  133. package/dist/ui/screens/analytics-data.js +16 -0
  134. package/dist/ui/screens/analytics-data.js.map +1 -1
  135. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  136. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  137. package/dist/ui/screens/podcast-listening-data.js +98 -0
  138. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  139. package/dist/ui/screens/registry.d.ts.map +1 -1
  140. package/dist/ui/screens/registry.js +2 -0
  141. package/dist/ui/screens/registry.js.map +1 -1
  142. package/docs/foundry-publish.md +208 -0
  143. package/migrations/0033_survey_answer_labels.sql +15 -0
  144. package/package.json +9 -3
  145. package/src/cli/foundry-emit.ts +85 -0
  146. package/src/engine/foundry-dispatch.ts +1 -1
  147. package/src/engine/fronts-publish-intent.ts +23 -1
  148. package/src/engine/index.ts +5 -0
  149. package/src/engine/publisher.ts +80 -4
  150. package/src/engine/tags.ts +21 -1
  151. package/src/engine/topics.ts +4 -1
  152. package/src/engine/video-source.ts +44 -0
  153. package/src/foundry-emit.ts +255 -0
  154. package/src/integration/index.ts +9 -0
  155. package/src/integration/options.ts +23 -0
  156. package/src/providers/null.ts +6 -0
  157. package/src/providers/types.ts +52 -0
  158. package/src/routes/ai.ts +29 -17
  159. package/src/routes/analytics.ts +21 -0
  160. package/src/routes/content.ts +81 -15
  161. package/src/routes/foundry-publish.ts +746 -29
  162. package/src/routes/machine-context.ts +24 -0
  163. package/src/routes/surveys.ts +587 -12
  164. package/src/schema/migrations.ts +15 -0
  165. package/src/schema/tables.ts +2 -0
  166. package/src/surveys/access.ts +14 -4
  167. package/src/surveys/auto-label.ts +420 -0
  168. package/src/surveys/schema.ts +25 -1
  169. package/src/ui/api/_content-config.ts +6 -0
  170. package/src/ui/api/analytics.ts +5 -2
  171. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  172. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  173. package/src/ui/api/surveys/[surveyId].ts +37 -3
  174. package/src/ui/commands.ts +2 -0
  175. package/src/ui/hash-router.ts +1 -0
  176. package/src/ui/nav.ts +1 -0
  177. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  178. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  179. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  180. package/src/ui/screens/analytics-data.ts +18 -0
  181. package/src/ui/screens/podcast-listening-data.ts +114 -0
  182. 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
- <VerbatimFeed data={data} />
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 (!scale || scale.kind !== 'scale' || scale.answerCount === 0) return null
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
- function VerbatimFeed({ data }: { data: SurveyResults }) {
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
- const freeTextByResponse = new Map<string, string[]>()
243
- for (const entry of data.freeText) {
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.value)
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
- <h2 className="page-title" id="survey-verbatims" style={{ fontSize: 22 }}>
252
- Responses
253
- </h2>
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((value, index) => (
270
- // biome-ignore lint/suspicious/noArrayIndexKey: values are free text with no stable id
271
- <blockquote key={`${response.id}:${index}`}>{value}</blockquote>
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 />,