@growth-labs/cms 0.8.19 → 0.8.23

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/fronts-publish-intent.d.ts +1 -1
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  7. package/dist/engine/fronts-publish-intent.js +12 -1
  8. package/dist/engine/fronts-publish-intent.js.map +1 -1
  9. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  10. package/dist/engine/publisher.d.ts +16 -1
  11. package/dist/engine/publisher.d.ts.map +1 -1
  12. package/dist/engine/publisher.js +55 -4
  13. package/dist/engine/publisher.js.map +1 -1
  14. package/dist/engine/tags.d.ts.map +1 -1
  15. package/dist/engine/tags.js +18 -1
  16. package/dist/engine/tags.js.map +1 -1
  17. package/dist/engine/topics.d.ts.map +1 -1
  18. package/dist/engine/topics.js +4 -1
  19. package/dist/engine/topics.js.map +1 -1
  20. package/dist/engine/video-source.d.ts +21 -0
  21. package/dist/engine/video-source.d.ts.map +1 -1
  22. package/dist/engine/video-source.js +40 -0
  23. package/dist/engine/video-source.js.map +1 -1
  24. package/dist/foundry-emit.d.ts +146 -0
  25. package/dist/foundry-emit.d.ts.map +1 -0
  26. package/dist/foundry-emit.js +189 -0
  27. package/dist/foundry-emit.js.map +1 -0
  28. package/dist/integration/index.d.ts.map +1 -1
  29. package/dist/integration/index.js +9 -0
  30. package/dist/integration/index.js.map +1 -1
  31. package/dist/integration/options.d.ts +24 -0
  32. package/dist/integration/options.d.ts.map +1 -1
  33. package/dist/integration/options.js +22 -0
  34. package/dist/integration/options.js.map +1 -1
  35. package/dist/lib/publish-timezone.d.ts +38 -0
  36. package/dist/lib/publish-timezone.d.ts.map +1 -0
  37. package/dist/lib/publish-timezone.js +135 -0
  38. package/dist/lib/publish-timezone.js.map +1 -0
  39. package/dist/providers/null.d.ts.map +1 -1
  40. package/dist/providers/null.js +6 -0
  41. package/dist/providers/null.js.map +1 -1
  42. package/dist/providers/types.d.ts +56 -0
  43. package/dist/providers/types.d.ts.map +1 -1
  44. package/dist/providers/types.js.map +1 -1
  45. package/dist/routes/ai.d.ts.map +1 -1
  46. package/dist/routes/ai.js +21 -13
  47. package/dist/routes/ai.js.map +1 -1
  48. package/dist/routes/analytics.d.ts +1 -0
  49. package/dist/routes/analytics.d.ts.map +1 -1
  50. package/dist/routes/analytics.js +20 -0
  51. package/dist/routes/analytics.js.map +1 -1
  52. package/dist/routes/content.d.ts +27 -0
  53. package/dist/routes/content.d.ts.map +1 -1
  54. package/dist/routes/content.js +60 -114
  55. package/dist/routes/content.js.map +1 -1
  56. package/dist/routes/foundry-publish.d.ts +50 -0
  57. package/dist/routes/foundry-publish.d.ts.map +1 -1
  58. package/dist/routes/foundry-publish.js +540 -27
  59. package/dist/routes/foundry-publish.js.map +1 -1
  60. package/dist/routes/machine-context.d.ts +6 -0
  61. package/dist/routes/machine-context.d.ts.map +1 -0
  62. package/dist/routes/machine-context.js +20 -0
  63. package/dist/routes/machine-context.js.map +1 -0
  64. package/dist/routes/surveys.d.ts +14 -1
  65. package/dist/routes/surveys.d.ts.map +1 -1
  66. package/dist/routes/surveys.js +515 -10
  67. package/dist/routes/surveys.js.map +1 -1
  68. package/dist/schema/insights-ingest.d.ts +8 -8
  69. package/dist/schema/migrations.d.ts.map +1 -1
  70. package/dist/schema/migrations.js +15 -0
  71. package/dist/schema/migrations.js.map +1 -1
  72. package/dist/schema/tables.d.ts +1 -1
  73. package/dist/schema/tables.d.ts.map +1 -1
  74. package/dist/schema/tables.js +2 -0
  75. package/dist/schema/tables.js.map +1 -1
  76. package/dist/surveys/access.d.ts +1 -0
  77. package/dist/surveys/access.d.ts.map +1 -1
  78. package/dist/surveys/access.js +13 -4
  79. package/dist/surveys/access.js.map +1 -1
  80. package/dist/surveys/auto-label.d.ts +100 -0
  81. package/dist/surveys/auto-label.d.ts.map +1 -0
  82. package/dist/surveys/auto-label.js +325 -0
  83. package/dist/surveys/auto-label.js.map +1 -0
  84. package/dist/surveys/schema.d.ts +38 -12
  85. package/dist/surveys/schema.d.ts.map +1 -1
  86. package/dist/surveys/schema.js +18 -1
  87. package/dist/surveys/schema.js.map +1 -1
  88. package/dist/ui/api/_content-config.d.ts.map +1 -1
  89. package/dist/ui/api/_content-config.js +4 -0
  90. package/dist/ui/api/_content-config.js.map +1 -1
  91. package/dist/ui/api/analytics.d.ts.map +1 -1
  92. package/dist/ui/api/analytics.js +5 -2
  93. package/dist/ui/api/analytics.js.map +1 -1
  94. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  95. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  96. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  97. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  99. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  100. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  101. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  102. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  103. package/dist/ui/api/surveys/[surveyId].js +28 -1
  104. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  105. package/dist/ui/commands.d.ts.map +1 -1
  106. package/dist/ui/commands.js +2 -0
  107. package/dist/ui/commands.js.map +1 -1
  108. package/dist/ui/hash-router.d.ts +1 -1
  109. package/dist/ui/hash-router.d.ts.map +1 -1
  110. package/dist/ui/hash-router.js +1 -0
  111. package/dist/ui/hash-router.js.map +1 -1
  112. package/dist/ui/inspector/PublishTab.d.ts +7 -1
  113. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  114. package/dist/ui/inspector/PublishTab.js +24 -26
  115. package/dist/ui/inspector/PublishTab.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/fronts-publish-intent.ts +23 -1
  147. package/src/engine/publisher.ts +80 -4
  148. package/src/engine/tags.ts +21 -1
  149. package/src/engine/topics.ts +4 -1
  150. package/src/engine/video-source.ts +44 -0
  151. package/src/foundry-emit.ts +255 -0
  152. package/src/integration/index.ts +9 -0
  153. package/src/integration/options.ts +23 -0
  154. package/src/lib/publish-timezone.ts +154 -0
  155. package/src/providers/null.ts +6 -0
  156. package/src/providers/types.ts +52 -0
  157. package/src/routes/ai.ts +29 -17
  158. package/src/routes/analytics.ts +21 -0
  159. package/src/routes/content.ts +86 -131
  160. package/src/routes/foundry-publish.ts +746 -29
  161. package/src/routes/machine-context.ts +24 -0
  162. package/src/routes/surveys.ts +587 -12
  163. package/src/schema/migrations.ts +15 -0
  164. package/src/schema/tables.ts +2 -0
  165. package/src/surveys/access.ts +14 -4
  166. package/src/surveys/auto-label.ts +420 -0
  167. package/src/surveys/schema.ts +25 -1
  168. package/src/ui/api/_content-config.ts +6 -0
  169. package/src/ui/api/analytics.ts +5 -2
  170. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  171. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  172. package/src/ui/api/surveys/[surveyId].ts +37 -3
  173. package/src/ui/commands.ts +2 -0
  174. package/src/ui/hash-router.ts +1 -0
  175. package/src/ui/inspector/PublishTab.tsx +33 -25
  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
@@ -0,0 +1,529 @@
1
+ // src/ui/screens/PodcastListeningScreen.tsx — the "Podcasts" listening panel.
2
+ //
3
+ // Backed by AnalyticsProvider.getPodcastListening via
4
+ // GET /admin/api/analytics?type=podcastListening&range=<Nd>. The provider
5
+ // returns null on sites with no podcast listening analytics — the route 404s
6
+ // and this screen shows an explicit "not available" state, never zeros.
7
+ //
8
+ // States, each visually distinct (the live/partial/empty convention adapted
9
+ // to this panel):
10
+ // - loading → kicker
11
+ // - HTTP error → red error line + Retry (a provider that is
12
+ // implemented but fails throws; the route 500s loudly)
13
+ // - 404 → "not available on this site" panel
14
+ // - no episodes → honest empty panel
15
+ // - data → totals cards + sortable episode table with inline
16
+ // drop-off bars (10→100%)
17
+ //
18
+ // Listening seconds come from the `audio_played` event, which the player
19
+ // reports only after the listening-time instrumentation ships. Until then
20
+ // every listening cell reads "—" and a banner explains why — an unmeasured
21
+ // metric must look different from zero minutes listened.
22
+
23
+ import { useCallback, useEffect, useReducer, useState } from 'react'
24
+ import type { PodcastListening, PodcastListeningEpisode } from '../../providers/types.js'
25
+ import { Icon } from '../icons.js'
26
+ import {
27
+ episodeLabel,
28
+ normalizePodcastListening,
29
+ type PodcastSortKey,
30
+ sortEpisodes,
31
+ } from './podcast-listening-data.js'
32
+
33
+ type RangeOption = '7d' | '30d' | '90d'
34
+
35
+ const RANGE_LABELS: Array<{ value: RangeOption; label: string }> = [
36
+ { value: '7d', label: '7 days' },
37
+ { value: '30d', label: '30 days' },
38
+ { value: '90d', label: '90 days' },
39
+ ]
40
+
41
+ // Workers Analytics Engine retains roughly 90 days of events; the widest
42
+ // picker is therefore 90 days and the header says so.
43
+ const RETENTION_NOTE = 'Event analytics retain about 90 days.'
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Reducer
47
+ // ---------------------------------------------------------------------------
48
+
49
+ interface PodcastState {
50
+ loading: boolean
51
+ // 'unavailable' = the provider returned null (route 404); the panel is not
52
+ // offered on this site. 'error' = the fetch failed or the route 500ed.
53
+ state: 'loading' | 'unavailable' | 'error' | 'data'
54
+ error: string | null
55
+ data: PodcastListening | null
56
+ }
57
+
58
+ type PodcastAction =
59
+ | { type: 'fetch-start' }
60
+ | { type: 'fetch-unavailable' }
61
+ | { type: 'fetch-ok'; data: PodcastListening }
62
+ | { type: 'fetch-error'; error: string }
63
+
64
+ function init(): PodcastState {
65
+ return { loading: true, state: 'loading', error: null, data: null }
66
+ }
67
+
68
+ function reducer(state: PodcastState, action: PodcastAction): PodcastState {
69
+ switch (action.type) {
70
+ case 'fetch-start':
71
+ return { ...state, loading: true, error: null }
72
+ case 'fetch-unavailable':
73
+ return { loading: false, state: 'unavailable', error: null, data: null }
74
+ case 'fetch-ok':
75
+ return { loading: false, state: 'data', error: null, data: action.data }
76
+ case 'fetch-error':
77
+ return { loading: false, state: 'error', error: action.error, data: null }
78
+ default:
79
+ return state
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Screen
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export function PodcastListeningScreen() {
88
+ const [range, setRange] = useState<RangeOption>('30d')
89
+ const [s, dispatch] = useReducer(reducer, undefined, init)
90
+
91
+ const fetchData = useCallback(async (r: RangeOption) => {
92
+ dispatch({ type: 'fetch-start' })
93
+ try {
94
+ const res = await fetch(`/admin/api/analytics?type=podcastListening&range=${r}`)
95
+ if (res.status === 404) {
96
+ dispatch({ type: 'fetch-unavailable' })
97
+ return
98
+ }
99
+ if (!res.ok) {
100
+ dispatch({ type: 'fetch-error', error: `${res.status} ${res.statusText}` })
101
+ return
102
+ }
103
+ const body = await res.json()
104
+ const data = normalizePodcastListening(body)
105
+ if (!data) {
106
+ dispatch({ type: 'fetch-error', error: 'malformed podcast listening payload' })
107
+ return
108
+ }
109
+ dispatch({ type: 'fetch-ok', data })
110
+ } catch (err) {
111
+ dispatch({ type: 'fetch-error', error: String(err) })
112
+ }
113
+ }, [])
114
+
115
+ useEffect(() => {
116
+ void fetchData(range)
117
+ }, [fetchData, range])
118
+
119
+ return (
120
+ <div className="page page-wide podcasts-screen fade-in" style={{ maxWidth: 1340 }}>
121
+ <div className="page-head">
122
+ <div>
123
+ <h1 className="page-title">Podcasts</h1>
124
+ <div className="page-sub">
125
+ How far listeners get into each episode, and how long they listen. {RETENTION_NOTE}
126
+ </div>
127
+ </div>
128
+ <RangeSegment value={range} onChange={setRange} />
129
+ </div>
130
+
131
+ {s.state === 'loading' && <div className="kicker">Loading podcast listening…</div>}
132
+
133
+ {s.state === 'error' && (
134
+ <div style={{ color: 'var(--red)', fontSize: 13.5 }}>
135
+ {s.error}
136
+ <button
137
+ type="button"
138
+ className="btn sm"
139
+ onClick={() => void fetchData(range)}
140
+ style={{ marginLeft: 12 }}
141
+ >
142
+ Retry
143
+ </button>
144
+ </div>
145
+ )}
146
+
147
+ {s.state === 'unavailable' && (
148
+ <EmptyPanel
149
+ icon="mic"
150
+ message="Podcast listening analytics is not available on this site."
151
+ />
152
+ )}
153
+
154
+ {s.state === 'data' && s.data && <PodcastListeningBody data={s.data} />}
155
+ </div>
156
+ )
157
+ }
158
+
159
+ function PodcastListeningBody({ data }: { data: PodcastListening }) {
160
+ if (data.episodes.length === 0) {
161
+ return <EmptyPanel icon="mic" message="No podcast listening data in this range yet." />
162
+ }
163
+
164
+ const listeningMeasured = data.totals.totalListeningSeconds !== null
165
+
166
+ return (
167
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
168
+ {!listeningMeasured && <ListeningPendingBanner />}
169
+ <PodcastTotalsCards data={data} />
170
+ <PodcastEpisodeTable episodes={data.episodes} />
171
+ </div>
172
+ )
173
+ }
174
+
175
+ function ListeningPendingBanner() {
176
+ return (
177
+ <div
178
+ style={{
179
+ padding: '8px 14px',
180
+ background: 'color-mix(in srgb, var(--amber) 8%, transparent)',
181
+ border: '1px solid color-mix(in srgb, var(--amber) 20%, transparent)',
182
+ borderRadius: 'var(--radius-sm)',
183
+ fontSize: 12,
184
+ color: 'var(--amber)',
185
+ display: 'flex',
186
+ alignItems: 'center',
187
+ gap: 7,
188
+ }}
189
+ >
190
+ <Icon name="bolt" size={13} />
191
+ Listening time is not reported yet — it appears here from the date the player starts sending
192
+ listening events. Until then those cells read “—”, not zero.
193
+ </div>
194
+ )
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Totals cards
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function PodcastTotalsCards({ data }: { data: PodcastListening }) {
202
+ const t = data.totals
203
+ const cards: Array<{ label: string; value: string; note: string | null }> = [
204
+ { label: 'Episodes played', value: formatNumber(t.episodes), note: null },
205
+ { label: 'Plays', value: formatNumber(t.plays), note: 'user-initiated' },
206
+ { label: 'Listeners', value: formatNumber(t.listeners), note: 'distinct sessions' },
207
+ { label: 'Completes', value: formatNumber(t.completes), note: null },
208
+ { label: 'Completion', value: formatPercent(t.completionRate), note: 'completes / plays' },
209
+ {
210
+ label: 'Listening time',
211
+ value: t.totalListeningSeconds === null ? '—' : formatDuration(t.totalListeningSeconds),
212
+ note: t.totalListeningSeconds === null ? 'not reported yet' : 'total, all episodes',
213
+ },
214
+ ]
215
+ return (
216
+ <div className="grid-stats">
217
+ {cards.map((card) => (
218
+ <div key={card.label} className="panel panel-pad">
219
+ <div className="kicker">{card.label}</div>
220
+ <div
221
+ className="serif tnum"
222
+ style={{ fontSize: 26, fontWeight: 600, color: 'var(--ink)', margin: '6px 0 4px' }}
223
+ >
224
+ {card.value === '—' ? (
225
+ <span style={{ fontSize: 18, color: 'var(--ink-muted)' }}>—</span>
226
+ ) : (
227
+ card.value
228
+ )}
229
+ </div>
230
+ {card.note && (
231
+ <span style={{ fontSize: 12, color: 'var(--ink-faint)' }}>{card.note}</span>
232
+ )}
233
+ </div>
234
+ ))}
235
+ </div>
236
+ )
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Episode table (sortable, with inline drop-off bars)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ interface ColumnDef {
244
+ key: PodcastSortKey
245
+ label: string
246
+ align: 'left' | 'right'
247
+ }
248
+
249
+ const COLUMNS: ColumnDef[] = [
250
+ { key: 'episode', label: 'Episode', align: 'left' },
251
+ { key: 'plays', label: 'Plays', align: 'right' },
252
+ { key: 'listeners', label: 'Listeners', align: 'right' },
253
+ { key: 'reached10', label: '10%', align: 'right' },
254
+ { key: 'reached25', label: '25%', align: 'right' },
255
+ { key: 'reached50', label: '50%', align: 'right' },
256
+ { key: 'reached75', label: '75%', align: 'right' },
257
+ { key: 'reached90', label: '90%', align: 'right' },
258
+ { key: 'completes', label: 'Done', align: 'right' },
259
+ { key: 'completionRate', label: 'Rate', align: 'right' },
260
+ { key: 'totalListeningSeconds', label: 'Listening', align: 'right' },
261
+ ]
262
+
263
+ export function PodcastEpisodeTable({
264
+ episodes,
265
+ }: {
266
+ episodes: readonly PodcastListeningEpisode[]
267
+ }) {
268
+ const [sortKey, setSortKey] = useState<PodcastSortKey>('plays')
269
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
270
+
271
+ const handleSort = (key: PodcastSortKey) => {
272
+ if (key === sortKey) {
273
+ setSortDir(sortDir === 'desc' ? 'asc' : 'desc')
274
+ } else {
275
+ setSortKey(key)
276
+ setSortDir(key === 'episode' ? 'asc' : 'desc')
277
+ }
278
+ }
279
+
280
+ const rows = sortEpisodes(episodes, sortKey, sortDir)
281
+
282
+ return (
283
+ <div className="panel" style={{ overflow: 'hidden' }}>
284
+ <div
285
+ style={{
286
+ display: 'flex',
287
+ justifyContent: 'space-between',
288
+ alignItems: 'baseline',
289
+ padding: '16px 18px 12px',
290
+ }}
291
+ >
292
+ <div className="card-title">Episodes</div>
293
+ <div className="kicker" style={{ fontSize: 10.5 }}>
294
+ Drop-off 10 → 100% per episode
295
+ </div>
296
+ </div>
297
+ <hr className="masthead-rule" style={{ margin: '0 18px' }} />
298
+ <div style={{ overflowX: 'auto' }}>
299
+ <table className="tbl">
300
+ <thead>
301
+ <tr>
302
+ {COLUMNS.map((col) => (
303
+ <th
304
+ key={col.key}
305
+ style={{
306
+ paddingLeft: col.key === 'episode' ? 18 : undefined,
307
+ textAlign: col.align,
308
+ whiteSpace: 'nowrap',
309
+ }}
310
+ >
311
+ <button
312
+ type="button"
313
+ onClick={() => handleSort(col.key)}
314
+ style={{
315
+ all: 'unset',
316
+ cursor: 'pointer',
317
+ display: 'inline-flex',
318
+ alignItems: 'center',
319
+ gap: 4,
320
+ fontWeight: sortKey === col.key ? 700 : undefined,
321
+ color: sortKey === col.key ? 'var(--ink)' : 'inherit',
322
+ }}
323
+ title={`Sort by ${col.label}`}
324
+ data-sort-active={sortKey === col.key ? 'true' : undefined}
325
+ >
326
+ {col.label}
327
+ {sortKey === col.key && (
328
+ <span style={{ fontSize: 9 }}>{sortDir === 'desc' ? '▼' : '▲'}</span>
329
+ )}
330
+ </button>
331
+ </th>
332
+ ))}
333
+ <th
334
+ style={{ paddingRight: 18 }}
335
+ title="Share of plays reaching each mark; the last bar is completes"
336
+ >
337
+ Drop-off
338
+ </th>
339
+ </tr>
340
+ </thead>
341
+ <tbody>
342
+ {rows.map((episode) => (
343
+ <tr key={episode.slug}>
344
+ <td style={{ paddingLeft: 18, maxWidth: 360 }}>
345
+ <div
346
+ style={{
347
+ fontWeight: 600,
348
+ color: 'var(--ink)',
349
+ whiteSpace: 'nowrap',
350
+ overflow: 'hidden',
351
+ textOverflow: 'ellipsis',
352
+ }}
353
+ title={episode.slug}
354
+ >
355
+ {episodeLabel(episode)}
356
+ </div>
357
+ {episode.title && (
358
+ <div
359
+ style={{
360
+ fontSize: 11,
361
+ color: 'var(--ink-faint)',
362
+ whiteSpace: 'nowrap',
363
+ overflow: 'hidden',
364
+ textOverflow: 'ellipsis',
365
+ }}
366
+ >
367
+ {episode.slug}
368
+ </div>
369
+ )}
370
+ </td>
371
+ <td className="tnum" style={{ fontWeight: 600, textAlign: 'right' }}>
372
+ {formatNumber(episode.plays)}
373
+ </td>
374
+ <td className="tnum" style={{ textAlign: 'right' }}>
375
+ {formatNumber(episode.listeners)}
376
+ </td>
377
+ <td className="tnum" style={{ textAlign: 'right' }}>
378
+ {formatNumber(episode.reached10)}
379
+ </td>
380
+ <td className="tnum" style={{ textAlign: 'right' }}>
381
+ {formatNumber(episode.reached25)}
382
+ </td>
383
+ <td className="tnum" style={{ textAlign: 'right' }}>
384
+ {formatNumber(episode.reached50)}
385
+ </td>
386
+ <td className="tnum" style={{ textAlign: 'right' }}>
387
+ {formatNumber(episode.reached75)}
388
+ </td>
389
+ <td className="tnum" style={{ textAlign: 'right' }}>
390
+ {formatNumber(episode.reached90)}
391
+ </td>
392
+ <td className="tnum" style={{ textAlign: 'right' }}>
393
+ {formatNumber(episode.completes)}
394
+ </td>
395
+ <td className="tnum" style={{ textAlign: 'right' }}>
396
+ {formatPercent(episode.completionRate)}
397
+ </td>
398
+ <td className="tnum" style={{ textAlign: 'right' }}>
399
+ {episode.totalListeningSeconds === null ? (
400
+ <span title="No listening events in this range yet">—</span>
401
+ ) : (
402
+ formatDuration(episode.totalListeningSeconds)
403
+ )}
404
+ </td>
405
+ <td style={{ paddingRight: 18 }}>
406
+ <DropoffBars episode={episode} />
407
+ </td>
408
+ </tr>
409
+ ))}
410
+ </tbody>
411
+ </table>
412
+ </div>
413
+ </div>
414
+ )
415
+ }
416
+
417
+ /**
418
+ * Six inline bars: the share of an episode's plays that reached 10/25/50/75/90
419
+ * percent, with the sixth bar completes (the 100% mark). Height is relative to
420
+ * plays; an episode with zero plays renders flat bars — an honest zero.
421
+ */
422
+ function DropoffBars({ episode }: { episode: PodcastListeningEpisode }) {
423
+ const stages: Array<{ label: string; count: number }> = [
424
+ { label: '10%', count: episode.reached10 },
425
+ { label: '25%', count: episode.reached25 },
426
+ { label: '50%', count: episode.reached50 },
427
+ { label: '75%', count: episode.reached75 },
428
+ { label: '90%', count: episode.reached90 },
429
+ { label: '100%', count: episode.completes },
430
+ ]
431
+ const base = Math.max(episode.plays, 1)
432
+ return (
433
+ <div
434
+ style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 3, height: 22 }}
435
+ title={stages
436
+ .map((s) => `${s.label}: ${formatNumber(s.count)} (${formatPercent(s.count / base)})`)
437
+ .join(' · ')}
438
+ >
439
+ {stages.map((stage) => {
440
+ const pct = Math.max(0, Math.min(100, (stage.count / base) * 100))
441
+ return (
442
+ <div
443
+ key={stage.label}
444
+ style={{
445
+ width: 7,
446
+ height: `${Math.max(pct, 2)}%`,
447
+ minHeight: 1,
448
+ background:
449
+ stage.label === '100%'
450
+ ? 'var(--accent)'
451
+ : 'color-mix(in srgb, var(--accent) 45%, transparent)',
452
+ borderRadius: '2px 2px 0 0',
453
+ }}
454
+ />
455
+ )
456
+ })}
457
+ </div>
458
+ )
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Shared micro-components
463
+ // ---------------------------------------------------------------------------
464
+
465
+ function RangeSegment({
466
+ value,
467
+ onChange,
468
+ }: {
469
+ value: RangeOption
470
+ onChange: (r: RangeOption) => void
471
+ }) {
472
+ return (
473
+ <div className="seg">
474
+ {RANGE_LABELS.map((opt) => (
475
+ <button
476
+ key={opt.value}
477
+ type="button"
478
+ className={value === opt.value ? 'on' : ''}
479
+ onClick={() => onChange(opt.value)}
480
+ >
481
+ {opt.label}
482
+ </button>
483
+ ))}
484
+ </div>
485
+ )
486
+ }
487
+
488
+ function EmptyPanel({
489
+ icon,
490
+ message,
491
+ }: {
492
+ icon: Parameters<typeof Icon>[0]['name']
493
+ message: string
494
+ }) {
495
+ return (
496
+ <div
497
+ className="panel panel-pad"
498
+ style={{
499
+ textAlign: 'center',
500
+ fontSize: 13,
501
+ color: 'var(--ink-muted)',
502
+ display: 'flex',
503
+ flexDirection: 'column',
504
+ alignItems: 'center',
505
+ gap: 8,
506
+ padding: '28px 20px',
507
+ }}
508
+ >
509
+ <Icon name={icon} size={22} style={{ color: 'var(--border)', opacity: 0.7 }} />
510
+ <span>{message}</span>
511
+ </div>
512
+ )
513
+ }
514
+
515
+ function formatNumber(value: number): string {
516
+ return Math.round(value).toLocaleString('en-US')
517
+ }
518
+
519
+ function formatDuration(seconds: number): string {
520
+ const rounded = Math.max(0, Math.round(seconds))
521
+ const minutes = Math.floor(rounded / 60)
522
+ const rest = rounded % 60
523
+ if (minutes === 0) return `${rest}s`
524
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
525
+ }
526
+
527
+ function formatPercent(value: number): string {
528
+ return `${(Math.max(0, value) * 100).toFixed(1)}%`
529
+ }