@growth-labs/cms 0.8.20 → 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/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/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/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
package/src/ui/nav.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const NAV_MODEL: NavGroup[] = [
|
|
|
26
26
|
{ id: 'calendar', label: 'Calendar', icon: 'calendar' },
|
|
27
27
|
{ id: 'media', label: 'Media', icon: 'media' },
|
|
28
28
|
{ id: 'analytics', label: 'Analytics', icon: 'chart' },
|
|
29
|
+
{ id: 'podcasts', label: 'Podcasts', icon: 'mic' },
|
|
29
30
|
],
|
|
30
31
|
},
|
|
31
32
|
{
|
|
@@ -32,7 +32,9 @@ import {
|
|
|
32
32
|
normalizeAnalyticsData,
|
|
33
33
|
normalizeArticleAnalyticsDetail,
|
|
34
34
|
type ReadsPoint,
|
|
35
|
+
TRAFFIC_SOURCES_CAPTION,
|
|
35
36
|
type TrafficSource,
|
|
37
|
+
uniqueVisitorsKpiLabel,
|
|
36
38
|
} from './analytics-data.js'
|
|
37
39
|
import {
|
|
38
40
|
formatKpi,
|
|
@@ -201,7 +203,9 @@ export function AnalyticsScreen() {
|
|
|
201
203
|
</div>
|
|
202
204
|
)}
|
|
203
205
|
|
|
204
|
-
{!s.loading && !s.error && s.data &&
|
|
206
|
+
{!s.loading && !s.error && s.data && (
|
|
207
|
+
<AnalyticsBody data={s.data} isPartial={isPartial} rangeDays={Number.parseInt(range, 10)} />
|
|
208
|
+
)}
|
|
205
209
|
</div>
|
|
206
210
|
)
|
|
207
211
|
}
|
|
@@ -579,7 +583,15 @@ function RangeSegment({
|
|
|
579
583
|
// AnalyticsBody
|
|
580
584
|
// ---------------------------------------------------------------------------
|
|
581
585
|
|
|
582
|
-
function AnalyticsBody({
|
|
586
|
+
function AnalyticsBody({
|
|
587
|
+
data,
|
|
588
|
+
isPartial,
|
|
589
|
+
rangeDays,
|
|
590
|
+
}: {
|
|
591
|
+
data: AnalyticsData
|
|
592
|
+
isPartial: boolean
|
|
593
|
+
rangeDays: number
|
|
594
|
+
}) {
|
|
583
595
|
return (
|
|
584
596
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
|
585
597
|
{isPartial && (
|
|
@@ -587,7 +599,7 @@ function AnalyticsBody({ data, isPartial }: { data: AnalyticsData; isPartial: bo
|
|
|
587
599
|
)}
|
|
588
600
|
|
|
589
601
|
{/* KPI cards */}
|
|
590
|
-
<KpiCards kpis={data.kpis} />
|
|
602
|
+
<KpiCards kpis={data.kpis} rangeDays={rangeDays} />
|
|
591
603
|
|
|
592
604
|
{/* Reads over time + traffic sources */}
|
|
593
605
|
<div className="grid-split-wide">
|
|
@@ -638,7 +650,13 @@ const KPI_DEFS: Array<{
|
|
|
638
650
|
},
|
|
639
651
|
]
|
|
640
652
|
|
|
641
|
-
export function KpiCards({
|
|
653
|
+
export function KpiCards({
|
|
654
|
+
kpis,
|
|
655
|
+
rangeDays = 7,
|
|
656
|
+
}: {
|
|
657
|
+
kpis: ProviderResult<Record<string, number | null>>
|
|
658
|
+
rangeDays?: number
|
|
659
|
+
}) {
|
|
642
660
|
const kpiStatus = selectKpiState(kpis)
|
|
643
661
|
|
|
644
662
|
if (kpiStatus === 'empty') {
|
|
@@ -666,7 +684,7 @@ export function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number
|
|
|
666
684
|
return (
|
|
667
685
|
<KpiCard
|
|
668
686
|
key={def.key}
|
|
669
|
-
label={def.label}
|
|
687
|
+
label={def.key === 'uniqueVisitors' ? uniqueVisitorsKpiLabel(rangeDays) : def.label}
|
|
670
688
|
status={isUnmeasured ? 'partial' : kpiStatus}
|
|
671
689
|
value={displayValue}
|
|
672
690
|
note={displayNote}
|
|
@@ -813,7 +831,7 @@ function ReadsChart({ series }: { series: ProviderResult<ReadsPoint[]> }) {
|
|
|
813
831
|
// TrafficSources
|
|
814
832
|
// ---------------------------------------------------------------------------
|
|
815
833
|
|
|
816
|
-
function TrafficSources({ result }: { result: ProviderResult<TrafficSource[]> }) {
|
|
834
|
+
export function TrafficSources({ result }: { result: ProviderResult<TrafficSource[]> }) {
|
|
817
835
|
if (!result || result.status === 'empty' || !result.data || result.data.length === 0) {
|
|
818
836
|
return <EmptyPanel icon="globe" message="No traffic source data yet." />
|
|
819
837
|
}
|
|
@@ -836,6 +854,16 @@ function TrafficSources({ result }: { result: ProviderResult<TrafficSource[]> })
|
|
|
836
854
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--amber)' }}>Partial</span>
|
|
837
855
|
)}
|
|
838
856
|
</div>
|
|
857
|
+
<div
|
|
858
|
+
style={{
|
|
859
|
+
fontSize: 11,
|
|
860
|
+
color: 'var(--ink-faint)',
|
|
861
|
+
marginTop: -10,
|
|
862
|
+
marginBottom: 14,
|
|
863
|
+
}}
|
|
864
|
+
>
|
|
865
|
+
{TRAFFIC_SOURCES_CAPTION}
|
|
866
|
+
</div>
|
|
839
867
|
{sources.map((src) => {
|
|
840
868
|
const pct = (src.sessions / maxSessions) * 100
|
|
841
869
|
return (
|
|
@@ -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
|
+
}
|