@growth-labs/cms 0.5.24 → 0.5.26

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 (50) hide show
  1. package/README.md +1 -1
  2. package/dist/integration/index.d.ts.map +1 -1
  3. package/dist/integration/index.js +5 -2
  4. package/dist/integration/index.js.map +1 -1
  5. package/dist/integration/options.d.ts +5 -0
  6. package/dist/integration/options.d.ts.map +1 -1
  7. package/dist/integration/options.js +7 -0
  8. package/dist/integration/options.js.map +1 -1
  9. package/dist/integration/vite-plugin.d.ts +2 -0
  10. package/dist/integration/vite-plugin.d.ts.map +1 -1
  11. package/dist/integration/vite-plugin.js +35 -0
  12. package/dist/integration/vite-plugin.js.map +1 -1
  13. package/dist/providers/null.d.ts.map +1 -1
  14. package/dist/providers/null.js +8 -0
  15. package/dist/providers/null.js.map +1 -1
  16. package/dist/providers/types.d.ts +26 -1
  17. package/dist/providers/types.d.ts.map +1 -1
  18. package/dist/providers/types.js.map +1 -1
  19. package/dist/ui/editor/boundary-marks.d.ts +10 -0
  20. package/dist/ui/editor/boundary-marks.d.ts.map +1 -0
  21. package/dist/ui/editor/boundary-marks.js +94 -0
  22. package/dist/ui/editor/boundary-marks.js.map +1 -0
  23. package/dist/ui/editor/extensions.d.ts +1 -1
  24. package/dist/ui/editor/portable-text.js +1 -1
  25. package/dist/ui/editor/portable-text.js.map +1 -1
  26. package/dist/ui/editor/serialize.js +1 -1
  27. package/dist/ui/editor/serialize.js.map +1 -1
  28. package/dist/ui/editor/trim-boundary-marks.d.ts +3 -8
  29. package/dist/ui/editor/trim-boundary-marks.d.ts.map +1 -1
  30. package/dist/ui/editor/trim-boundary-marks.js +4 -93
  31. package/dist/ui/editor/trim-boundary-marks.js.map +1 -1
  32. package/dist/ui/screens/AnalyticsScreen.d.ts +7 -0
  33. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  34. package/dist/ui/screens/AnalyticsScreen.js +38 -9
  35. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  36. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  37. package/dist/ui/screens/analytics-data.js +16 -1
  38. package/dist/ui/screens/analytics-data.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/integration/index.ts +4 -2
  41. package/src/integration/options.ts +7 -0
  42. package/src/integration/vite-plugin.ts +33 -0
  43. package/src/providers/null.ts +8 -0
  44. package/src/providers/types.ts +27 -1
  45. package/src/ui/editor/boundary-marks.ts +116 -0
  46. package/src/ui/editor/portable-text.ts +1 -1
  47. package/src/ui/editor/serialize.ts +1 -1
  48. package/src/ui/editor/trim-boundary-marks.ts +5 -110
  49. package/src/ui/screens/AnalyticsScreen.tsx +86 -7
  50. package/src/ui/screens/analytics-data.ts +16 -2
@@ -41,6 +41,13 @@ export const cmsIntegrationOptionsSchema = z
41
41
  activeWorkspaceId: z.string().optional(),
42
42
  adminBasePath: z.string().default('/admin'),
43
43
  searchEnabled: z.boolean().default(true),
44
+ // Drops @puckeditor/core's lazily imported RichTextRender chunk (tiptap +
45
+ // prosemirror, ~176KB gzip) from SSR/worker bundles. Safe because the
46
+ // createPuckConfig component library never declares a `richtext` field —
47
+ // RichText blocks store Portable Text and render via renderRichTextHtml.
48
+ // Disable only if a host site adds its own `richtext`-typed Puck field to
49
+ // a server-rendered config.
50
+ stubPuckSsrRichtext: z.boolean().default(true),
44
51
  theme: themeSchema,
45
52
  socialSharing: socialSharingSchema,
46
53
  surveyResults: surveyResultsSchema.optional(),
@@ -4,6 +4,39 @@ import type { ResolvedCmsIntegrationOptions } from './options.js'
4
4
  const VIRTUAL_MODULE_ID = 'virtual:growth-labs/cms/config'
5
5
  const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`
6
6
 
7
+ export const PUCK_SSR_RICHTEXT_STUB_ID = '\0growth-labs/cms/puck-ssr-richtext-stub'
8
+
9
+ // @puckeditor/core's rsc entry wires `lazy(() => import('./Render-<hash>.mjs'))`
10
+ // for `richtext`-typed fields, so every SSR bundle that renders <Render> ships
11
+ // the whole tiptap/prosemirror editor even though createPuckConfig never
12
+ // declares such a field (RichText blocks store Portable Text; see
13
+ // src/puck/config.tsx). Stubbing the dynamic import at resolve time lets
14
+ // Rollup drop that dead subtree from worker bundles. Client builds are left
15
+ // alone: the admin editor legitimately code-splits the same chunk.
16
+ export function growthLabsPuckSsrRichtextStubPlugin(): Plugin {
17
+ return {
18
+ name: 'growth-labs-cms-puck-ssr-richtext-stub',
19
+ enforce: 'pre',
20
+ resolveId(source, importer, options) {
21
+ if (!options?.ssr) return undefined
22
+ if (!importer || !/@puckeditor[\\/]core[\\/]dist[\\/]/.test(importer)) return undefined
23
+ if (!/^\.\/Render-[\w-]+\.mjs$/.test(source)) return undefined
24
+ return PUCK_SSR_RICHTEXT_STUB_ID
25
+ },
26
+ load(id) {
27
+ if (id !== PUCK_SSR_RICHTEXT_STUB_ID) return undefined
28
+ // A loud marker, same convention as data-pt-unknown / gl-layout-unknown:
29
+ // if a host ever server-renders a real `richtext` field with the stub
30
+ // active, the gap is visible and greppable instead of silent.
31
+ return [
32
+ "import { jsx } from 'react/jsx-runtime'",
33
+ 'export const RichTextRender = () =>',
34
+ "\tjsx('div', { 'data-gl-layout-unknown': 'puck-richtext-ssr-stubbed' })",
35
+ ].join('\n')
36
+ },
37
+ }
38
+ }
39
+
7
40
  export function growthLabsCmsPlugin(config: ResolvedCmsIntegrationOptions): Plugin {
8
41
  const needsTransform = config.adminBasePath !== '/admin'
9
42
  const replacePath = `${config.adminBasePath}/api`
@@ -59,6 +59,14 @@ export const nullAnalytics: AnalyticsProvider = {
59
59
  },
60
60
  readsTimeSeries: [],
61
61
  sources: [],
62
+ funnel: {
63
+ views: null,
64
+ uniqueVisitors: null,
65
+ readCompletionRate: null,
66
+ paywallShown: null,
67
+ subscribeClicks: null,
68
+ boundaryConversionRate: null,
69
+ },
62
70
  })
63
71
  },
64
72
  }
@@ -5,6 +5,13 @@ export type ProviderStatus = 'live' | 'partial' | 'empty'
5
5
  export interface ProviderResult<T> {
6
6
  status: ProviderStatus
7
7
  data: T
8
+ /**
9
+ * Optional per-key data lineage: where each number in `data` comes from
10
+ * (e.g. `{ subscriptionsStarted: 'billing (site D1)', reads: 'rollup' }`).
11
+ * The UI shows these so a reader never has to guess whether a figure is
12
+ * ground truth or a derived rollup.
13
+ */
14
+ sources?: Record<string, string>
8
15
  }
9
16
 
10
17
  export interface DateRange {
@@ -23,6 +30,24 @@ export interface ArticleAnalyticsSource {
23
30
  paidStarts: number
24
31
  }
25
32
 
33
+ /**
34
+ * Per-article free/premium boundary funnel. Every stage is `number | null`:
35
+ * null means NOT MEASURED for this article/range (event not instrumented,
36
+ * history predates slug attribution, or the fact table has no rows) and the
37
+ * UI must say so — never render a silent zero.
38
+ */
39
+ export interface ArticleFunnelStages {
40
+ views: number | null
41
+ uniqueVisitors: number | null
42
+ /** 0..1 fraction of views that reached the 90% scroll mark. */
43
+ readCompletionRate: number | null
44
+ paywallShown: number | null
45
+ /** Boundary clicks: paywall CTA + inline membership CTA, per article. */
46
+ subscribeClicks: number | null
47
+ /** subscribeClicks / paywallShown (or the host's documented equivalent). */
48
+ boundaryConversionRate: number | null
49
+ }
50
+
26
51
  export interface ArticleAnalyticsDetail {
27
52
  id: string
28
53
  slug: string
@@ -61,10 +86,11 @@ export interface ArticleAnalyticsDetail {
61
86
  }
62
87
  readsTimeSeries: Array<{ t: number; reads: number }>
63
88
  sources: ArticleAnalyticsSource[]
89
+ funnel?: ArticleFunnelStages
64
90
  }
65
91
 
66
92
  export interface AnalyticsProvider {
67
- getKpis(range: DateRange): Promise<ProviderResult<Record<string, number>>>
93
+ getKpis(range: DateRange): Promise<ProviderResult<Record<string, number | null>>>
68
94
  getReadsTimeSeries(range: DateRange): Promise<ProviderResult<Array<{ t: number; reads: number }>>>
69
95
  getTopContent(
70
96
  range: DateRange,
@@ -0,0 +1,116 @@
1
+ // Pure JSONContent boundary-mark normalization. Split from
2
+ // trim-boundary-marks.ts so server-safe consumers (serialize.ts,
3
+ // portable-text.ts, and through them the @growth-labs/cms/puck subpath) can
4
+ // use it without dragging the Tiptap/ProseMirror runtime into SSR bundles —
5
+ // the live-editor Extension stays in trim-boundary-marks.ts.
6
+ import type { JSONContent } from '@tiptap/core'
7
+
8
+ export const BOUNDARY_MARKS = ['link'] as const
9
+
10
+ function markKey(mark: NonNullable<JSONContent['marks']>[number]): string {
11
+ return `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`
12
+ }
13
+
14
+ function sameMarks(
15
+ a: JSONContent['marks'] | undefined,
16
+ b: JSONContent['marks'] | undefined,
17
+ ): boolean {
18
+ return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
19
+ }
20
+
21
+ function mergeAdjacentText(nodes: JSONContent[]): JSONContent[] {
22
+ const merged: JSONContent[] = []
23
+ for (const node of nodes) {
24
+ const previous = merged.at(-1)
25
+ if (
26
+ previous?.type === 'text' &&
27
+ node.type === 'text' &&
28
+ sameMarks(previous.marks, node.marks)
29
+ ) {
30
+ previous.text = (previous.text ?? '') + (node.text ?? '')
31
+ } else {
32
+ merged.push(node)
33
+ }
34
+ }
35
+ return merged
36
+ }
37
+
38
+ function trimJsonMark(nodes: JSONContent[], markType: (typeof BOUNDARY_MARKS)[number]) {
39
+ const output: JSONContent[] = []
40
+ let index = 0
41
+
42
+ while (index < nodes.length) {
43
+ const node = nodes[index]
44
+ const mark =
45
+ node.type === 'text'
46
+ ? node.marks?.find((candidate) => candidate.type === markType)
47
+ : undefined
48
+ if (!mark) {
49
+ output.push(node)
50
+ index += 1
51
+ continue
52
+ }
53
+
54
+ const key = markKey(mark)
55
+ const run: JSONContent[] = []
56
+ while (index < nodes.length) {
57
+ const candidate = nodes[index]
58
+ const candidateMark =
59
+ candidate.type === 'text'
60
+ ? candidate.marks?.find((item) => item.type === markType && markKey(item) === key)
61
+ : undefined
62
+ if (!candidateMark) break
63
+ run.push(candidate)
64
+ index += 1
65
+ }
66
+
67
+ const text = run.map((item) => item.text ?? '').join('')
68
+ const leading = text.match(/^\s+/u)?.[0].length ?? 0
69
+ const trailing = text.match(/\s+$/u)?.[0].length ?? 0
70
+ const markedEnd = Math.max(leading, text.length - trailing)
71
+ let offset = 0
72
+
73
+ for (const item of run) {
74
+ const itemText = item.text ?? ''
75
+ const itemStart = offset
76
+ const itemEnd = offset + itemText.length
77
+ const cuts = [itemStart, Math.max(itemStart, leading), Math.min(itemEnd, markedEnd), itemEnd]
78
+ .filter(
79
+ (value, cutIndex, all) =>
80
+ value >= itemStart && value <= itemEnd && all.indexOf(value) === cutIndex,
81
+ )
82
+ .sort((a, b) => a - b)
83
+
84
+ for (let cutIndex = 0; cutIndex < cuts.length - 1; cutIndex += 1) {
85
+ const from = cuts[cutIndex]
86
+ const to = cuts[cutIndex + 1]
87
+ if (from === to) continue
88
+ const keepMark = from >= leading && to <= markedEnd
89
+ const marks = keepMark
90
+ ? item.marks
91
+ : item.marks?.filter((candidate) => candidate.type !== markType)
92
+ output.push({
93
+ ...item,
94
+ text: itemText.slice(from - itemStart, to - itemStart),
95
+ ...(marks?.length ? { marks } : { marks: undefined }),
96
+ })
97
+ }
98
+ offset = itemEnd
99
+ }
100
+ }
101
+
102
+ return mergeAdjacentText(output)
103
+ }
104
+
105
+ /**
106
+ * Remove link and underline marks from leading/trailing whitespace in every
107
+ * inline mark run. Underline follows the same rule as links: whitespace is
108
+ * layout, not editorial emphasis, and pasted underline is the visible half of
109
+ * the malformed-link report.
110
+ */
111
+ export function normalizeBoundaryMarks(node: JSONContent): JSONContent {
112
+ if (!node.content) return node
113
+ let content = node.content.map(normalizeBoundaryMarks)
114
+ for (const markType of BOUNDARY_MARKS) content = trimJsonMark(content, markType)
115
+ return { ...node, content }
116
+ }
@@ -19,7 +19,7 @@
19
19
 
20
20
  import type { JSONContent } from '@tiptap/core'
21
21
  import type { PortableTextObject } from '../../schema/portable-text.js'
22
- import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
22
+ import { normalizeBoundaryMarks } from './boundary-marks.js'
23
23
 
24
24
  type KeyGenerator = () => string
25
25
 
@@ -29,7 +29,7 @@
29
29
  // (inner markdown re-serializes byte-identically)
30
30
 
31
31
  import type { JSONContent } from '@tiptap/core'
32
- import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
32
+ import { normalizeBoundaryMarks } from './boundary-marks.js'
33
33
 
34
34
  // ─────────────────────────────────────────────────────────────────────────────
35
35
  // docToMarkdown — JSONContent → string
@@ -1,116 +1,11 @@
1
- import { Extension, type JSONContent } from '@tiptap/core'
1
+ import { Extension } from '@tiptap/core'
2
2
  import type { Mark, MarkType, Node as ProseMirrorNode } from '@tiptap/pm/model'
3
3
  import { Plugin } from '@tiptap/pm/state'
4
+ import { BOUNDARY_MARKS, normalizeBoundaryMarks } from './boundary-marks.js'
4
5
 
5
- const BOUNDARY_MARKS = ['link'] as const
6
-
7
- function markKey(mark: NonNullable<JSONContent['marks']>[number]): string {
8
- return `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`
9
- }
10
-
11
- function sameMarks(
12
- a: JSONContent['marks'] | undefined,
13
- b: JSONContent['marks'] | undefined,
14
- ): boolean {
15
- return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
16
- }
17
-
18
- function mergeAdjacentText(nodes: JSONContent[]): JSONContent[] {
19
- const merged: JSONContent[] = []
20
- for (const node of nodes) {
21
- const previous = merged.at(-1)
22
- if (
23
- previous?.type === 'text' &&
24
- node.type === 'text' &&
25
- sameMarks(previous.marks, node.marks)
26
- ) {
27
- previous.text = (previous.text ?? '') + (node.text ?? '')
28
- } else {
29
- merged.push(node)
30
- }
31
- }
32
- return merged
33
- }
34
-
35
- function trimJsonMark(nodes: JSONContent[], markType: (typeof BOUNDARY_MARKS)[number]) {
36
- const output: JSONContent[] = []
37
- let index = 0
38
-
39
- while (index < nodes.length) {
40
- const node = nodes[index]
41
- const mark =
42
- node.type === 'text'
43
- ? node.marks?.find((candidate) => candidate.type === markType)
44
- : undefined
45
- if (!mark) {
46
- output.push(node)
47
- index += 1
48
- continue
49
- }
50
-
51
- const key = markKey(mark)
52
- const run: JSONContent[] = []
53
- while (index < nodes.length) {
54
- const candidate = nodes[index]
55
- const candidateMark =
56
- candidate.type === 'text'
57
- ? candidate.marks?.find((item) => item.type === markType && markKey(item) === key)
58
- : undefined
59
- if (!candidateMark) break
60
- run.push(candidate)
61
- index += 1
62
- }
63
-
64
- const text = run.map((item) => item.text ?? '').join('')
65
- const leading = text.match(/^\s+/u)?.[0].length ?? 0
66
- const trailing = text.match(/\s+$/u)?.[0].length ?? 0
67
- const markedEnd = Math.max(leading, text.length - trailing)
68
- let offset = 0
69
-
70
- for (const item of run) {
71
- const itemText = item.text ?? ''
72
- const itemStart = offset
73
- const itemEnd = offset + itemText.length
74
- const cuts = [itemStart, Math.max(itemStart, leading), Math.min(itemEnd, markedEnd), itemEnd]
75
- .filter(
76
- (value, cutIndex, all) =>
77
- value >= itemStart && value <= itemEnd && all.indexOf(value) === cutIndex,
78
- )
79
- .sort((a, b) => a - b)
80
-
81
- for (let cutIndex = 0; cutIndex < cuts.length - 1; cutIndex += 1) {
82
- const from = cuts[cutIndex]
83
- const to = cuts[cutIndex + 1]
84
- if (from === to) continue
85
- const keepMark = from >= leading && to <= markedEnd
86
- const marks = keepMark
87
- ? item.marks
88
- : item.marks?.filter((candidate) => candidate.type !== markType)
89
- output.push({
90
- ...item,
91
- text: itemText.slice(from - itemStart, to - itemStart),
92
- ...(marks?.length ? { marks } : { marks: undefined }),
93
- })
94
- }
95
- offset = itemEnd
96
- }
97
- }
98
-
99
- return mergeAdjacentText(output)
100
- }
101
-
102
- /**
103
- * Remove link and underline marks from leading/trailing whitespace in every
104
- * inline mark run. Underline follows the same rule as links: whitespace is
105
- * layout, not editorial emphasis, and pasted underline is the visible half of
106
- * the malformed-link report.
107
- */
108
- export function normalizeBoundaryMarks(node: JSONContent): JSONContent {
109
- if (!node.content) return node
110
- let content = node.content.map(normalizeBoundaryMarks)
111
- for (const markType of BOUNDARY_MARKS) content = trimJsonMark(content, markType)
112
- return { ...node, content }
113
- }
6
+ // Server-safe callers import normalizeBoundaryMarks from ./boundary-marks.js
7
+ // directly; this re-export keeps the historical editor-side import path alive.
8
+ export { normalizeBoundaryMarks }
114
9
 
115
10
  function matchingMark(
116
11
  node: ProseMirrorNode,
@@ -21,7 +21,11 @@
21
21
  // a .card-title + .masthead-rule hairline. NO BData, NO mock numbers.
22
22
 
23
23
  import { useCallback, useEffect, useReducer, useState } from 'react'
24
- import type { ArticleAnalyticsDetail, ProviderResult } from '../../providers/types.js'
24
+ import type {
25
+ ArticleAnalyticsDetail,
26
+ ArticleFunnelStages,
27
+ ProviderResult,
28
+ } from '../../providers/types.js'
25
29
  import { Icon } from '../icons.js'
26
30
  import {
27
31
  type AnalyticsData,
@@ -358,6 +362,8 @@ function ArticleAnalyticsDetailBody({
358
362
  />
359
363
  </div>
360
364
 
365
+ <ArticleFunnelPanel funnel={detail.funnel} />
366
+
361
367
  <div className="grid-split-wide">
362
368
  <ReadsChart series={readsResult} />
363
369
  <ScrollDepthPanel detail={detail} />
@@ -368,6 +374,63 @@ function ArticleAnalyticsDetailBody({
368
374
  )
369
375
  }
370
376
 
377
+ // ---------------------------------------------------------------------------
378
+ // ArticleFunnelPanel — the free/premium boundary, per article
379
+ // ---------------------------------------------------------------------------
380
+ // Each stage is number | null; null renders an explicit "not measured", never
381
+ // a silent zero (unmeasured history and uninstrumented events must look
382
+ // different from a genuinely quiet article).
383
+
384
+ const FUNNEL_STAGE_DEFS: Array<{
385
+ key: keyof ArticleFunnelStages & string
386
+ label: string
387
+ fmt: 'num' | 'pct'
388
+ }> = [
389
+ { key: 'views', label: 'Views', fmt: 'num' },
390
+ { key: 'uniqueVisitors', label: 'Unique visitors', fmt: 'num' },
391
+ { key: 'readCompletionRate', label: 'Read completion', fmt: 'pct' },
392
+ { key: 'paywallShown', label: 'Boundary shown', fmt: 'num' },
393
+ { key: 'subscribeClicks', label: 'Boundary clicks', fmt: 'num' },
394
+ { key: 'boundaryConversionRate', label: 'Boundary conversion', fmt: 'pct' },
395
+ ]
396
+
397
+ export function ArticleFunnelPanel({ funnel }: { funnel: ArticleFunnelStages | undefined }) {
398
+ return (
399
+ <div className="panel panel-pad">
400
+ <div className="kicker" style={{ marginBottom: 10 }}>
401
+ Conversion funnel
402
+ </div>
403
+ <div className="grid-stats">
404
+ {FUNNEL_STAGE_DEFS.map((stage) => {
405
+ const raw = funnel?.[stage.key] ?? null
406
+ const measured = typeof raw === 'number' && Number.isFinite(raw)
407
+ return (
408
+ <div key={stage.key}>
409
+ <div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{stage.label}</div>
410
+ <div
411
+ className="serif tnum"
412
+ style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', margin: '4px 0 2px' }}
413
+ >
414
+ {measured ? (
415
+ stage.fmt === 'pct' ? (
416
+ formatPercent(raw)
417
+ ) : (
418
+ formatNumber(raw)
419
+ )
420
+ ) : (
421
+ <span style={{ fontSize: 13, color: 'var(--ink-faint)', fontStyle: 'italic' }}>
422
+ not measured
423
+ </span>
424
+ )}
425
+ </div>
426
+ </div>
427
+ )
428
+ })}
429
+ </div>
430
+ </div>
431
+ )
432
+ }
433
+
371
434
  function ScrollDepthPanel({ detail }: { detail: ArticleAnalyticsDetail }) {
372
435
  const rows = [
373
436
  { label: '25%', count: detail.scrollDepth.scroll25, rate: detail.scrollDepth.scroll25Rate },
@@ -520,7 +583,7 @@ function AnalyticsBody({ data, isPartial }: { data: AnalyticsData; isPartial: bo
520
583
  return (
521
584
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
522
585
  {isPartial && (
523
- <PartialBanner message="Showing reads onlyfull metrics arrive with the analytics rollup." />
586
+ <PartialBanner message="Some metrics are unavailable for this range each tile shows its own state and source." />
524
587
  )}
525
588
 
526
589
  {/* KPI cards */}
@@ -561,6 +624,12 @@ const KPI_DEFS: Array<{
561
624
  fmt: 'num',
562
625
  emptyNote: 'No visitor data',
563
626
  },
627
+ {
628
+ key: 'subscriptionsStarted',
629
+ label: 'New subscriptions',
630
+ fmt: 'num',
631
+ emptyNote: 'No subscription data',
632
+ },
564
633
  {
565
634
  key: 'conversionRate',
566
635
  label: 'Conversion',
@@ -569,7 +638,7 @@ const KPI_DEFS: Array<{
569
638
  },
570
639
  ]
571
640
 
572
- function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>> }) {
641
+ export function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>> }) {
573
642
  const kpiStatus = selectKpiState(kpis)
574
643
 
575
644
  if (kpiStatus === 'empty') {
@@ -586,19 +655,22 @@ function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>
586
655
  {KPI_DEFS.map((def) => {
587
656
  const raw = kpis.data?.[def.key] ?? null
588
657
  const formatted = formatKpi(raw, def.fmt)
589
- const isPending =
658
+ const isUnmeasured =
590
659
  formatted !== null && typeof formatted === 'object' && (formatted as KpiPending).pending
591
660
 
592
- const displayValue = isPending ? null : (formatted as string)
593
- const displayNote = isPending ? 'pending backfill' : null
661
+ const displayValue = isUnmeasured ? null : (formatted as string)
662
+ // A null KPI is an honest gap the provider could not measure it
663
+ // for this range — not a promise that a backfill will fill it.
664
+ const displayNote = isUnmeasured ? 'not measured' : null
594
665
 
595
666
  return (
596
667
  <KpiCard
597
668
  key={def.key}
598
669
  label={def.label}
599
- status={isPending ? 'partial' : kpiStatus}
670
+ status={isUnmeasured ? 'partial' : kpiStatus}
600
671
  value={displayValue}
601
672
  note={displayNote}
673
+ source={kpis.sources?.[def.key] ?? null}
602
674
  />
603
675
  )
604
676
  })}
@@ -611,11 +683,13 @@ function KpiCard({
611
683
  status,
612
684
  value,
613
685
  note,
686
+ source,
614
687
  }: {
615
688
  label: string
616
689
  status: 'live' | 'partial' | 'empty'
617
690
  value: string | null
618
691
  note: string | null
692
+ source?: string | null
619
693
  }) {
620
694
  const empty = status === 'empty' || value === null
621
695
  return (
@@ -631,6 +705,11 @@ function KpiCard({
631
705
  {status === 'partial' && !note && (
632
706
  <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--amber)' }}>partial</span>
633
707
  )}
708
+ {source && (
709
+ <div style={{ fontSize: 10, color: 'var(--ink-faint)', marginTop: 4 }} title="Data source">
710
+ {source}
711
+ </div>
712
+ )}
634
713
  </div>
635
714
  )
636
715
  }
@@ -24,14 +24,28 @@ function providerStatus(value: unknown): ProviderStatus {
24
24
  return value === 'live' || value === 'partial' || value === 'empty' ? value : 'empty'
25
25
  }
26
26
 
27
+ function normalizeSources(value: unknown): Record<string, string> | undefined {
28
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
29
+ const out: Record<string, string> = {}
30
+ for (const [key, label] of Object.entries(value as Record<string, unknown>)) {
31
+ if (typeof label === 'string' && label.length > 0) out[key] = label
32
+ }
33
+ return Object.keys(out).length > 0 ? out : undefined
34
+ }
35
+
27
36
  function normalizeObjectResult(result: unknown): ProviderResult<Record<string, number | null>> {
28
37
  if (!result || typeof result !== 'object') return { status: 'empty', data: {} }
29
- const record = result as { status?: unknown; data?: unknown }
38
+ const record = result as { status?: unknown; data?: unknown; sources?: unknown }
30
39
  const data =
31
40
  record.data && typeof record.data === 'object' && !Array.isArray(record.data)
32
41
  ? (record.data as Record<string, number | null>)
33
42
  : {}
34
- return { status: providerStatus(record.status), data }
43
+ const sources = normalizeSources(record.sources)
44
+ return {
45
+ status: providerStatus(record.status),
46
+ data,
47
+ ...(sources ? { sources } : {}),
48
+ }
35
49
  }
36
50
 
37
51
  function normalizeArrayResult<T>(result: unknown): ProviderResult<T[]> {