@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.
- 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/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/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/lib/publish-timezone.d.ts +38 -0
- package/dist/lib/publish-timezone.d.ts.map +1 -0
- package/dist/lib/publish-timezone.js +135 -0
- package/dist/lib/publish-timezone.js.map +1 -0
- 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 +60 -114
- 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/inspector/PublishTab.d.ts +7 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +24 -26
- package/dist/ui/inspector/PublishTab.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/fronts-publish-intent.ts +23 -1
- 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/lib/publish-timezone.ts +154 -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 +86 -131
- 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/inspector/PublishTab.tsx +33 -25
- 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
|
@@ -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
|
+
}
|