@growth-labs/cms 0.8.24 → 0.8.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 (39) hide show
  1. package/dist/engine/published-content.d.ts.map +1 -1
  2. package/dist/engine/published-content.js +9 -3
  3. package/dist/engine/published-content.js.map +1 -1
  4. package/dist/providers/types.d.ts +7 -1
  5. package/dist/providers/types.d.ts.map +1 -1
  6. package/dist/providers/types.js.map +1 -1
  7. package/dist/schema/migrations.d.ts.map +1 -1
  8. package/dist/schema/migrations.js +7 -4
  9. package/dist/schema/migrations.js.map +1 -1
  10. package/dist/surveys/readout.d.ts +69 -0
  11. package/dist/surveys/readout.d.ts.map +1 -0
  12. package/dist/surveys/readout.js +157 -0
  13. package/dist/surveys/readout.js.map +1 -0
  14. package/dist/surveys/schema.d.ts +1 -0
  15. package/dist/surveys/schema.d.ts.map +1 -1
  16. package/dist/surveys/schema.js +1 -1
  17. package/dist/surveys/schema.js.map +1 -1
  18. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
  19. package/dist/ui/screens/PodcastListeningScreen.js +16 -2
  20. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
  21. package/dist/ui/screens/SurveyResponsesScreen.d.ts +19 -1
  22. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  23. package/dist/ui/screens/SurveyResponsesScreen.js +203 -96
  24. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  25. package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
  26. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
  27. package/dist/ui/screens/podcast-listening-data.js +26 -1
  28. package/dist/ui/screens/podcast-listening-data.js.map +1 -1
  29. package/dist/ui/styles/broadsheet.css +135 -0
  30. package/package.json +2 -3
  31. package/src/engine/published-content.ts +11 -3
  32. package/src/providers/types.ts +7 -1
  33. package/src/schema/migrations.ts +7 -4
  34. package/src/surveys/readout.ts +243 -0
  35. package/src/surveys/schema.ts +1 -1
  36. package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
  37. package/src/ui/screens/SurveyResponsesScreen.tsx +769 -290
  38. package/src/ui/screens/podcast-listening-data.ts +33 -1
  39. package/src/ui/styles/broadsheet.css +135 -0
@@ -12,6 +12,7 @@ export type PodcastSortKey =
12
12
  | 'completes'
13
13
  | 'completionRate'
14
14
  | 'totalListeningSeconds'
15
+ | 'avgListeningSecondsPerListener'
15
16
 
16
17
  /** Defensive read of one metric: a non-finite value renders as 0, not NaN. */
17
18
  function num(value: unknown): number {
@@ -19,6 +20,20 @@ function num(value: unknown): number {
19
20
  return Number.isFinite(n) ? n : 0
20
21
  }
21
22
 
23
+ function measuredCount(value: unknown): number | null {
24
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null
25
+ }
26
+
27
+ function measuredAverage(value: unknown, count: number | null): number | null {
28
+ return count !== null &&
29
+ count > 0 &&
30
+ typeof value === 'number' &&
31
+ Number.isFinite(value) &&
32
+ value >= 0
33
+ ? value
34
+ : null
35
+ }
36
+
22
37
  function normalizeEpisode(input: unknown): PodcastListeningEpisode | null {
23
38
  if (!input || typeof input !== 'object') return null
24
39
  const row = input as Record<string, unknown>
@@ -26,6 +41,7 @@ function normalizeEpisode(input: unknown): PodcastListeningEpisode | null {
26
41
  const hasListening = typeof row.totalListeningSeconds === 'number'
27
42
  const listening = hasListening ? num(row.totalListeningSeconds) : null
28
43
  const listeners = num(row.listeners)
44
+ const measuredListeners = hasListening ? measuredCount(row.measuredListeners) : null
29
45
  return {
30
46
  slug,
31
47
  ...(typeof row.title === 'string' && row.title.length > 0 ? { title: row.title } : {}),
@@ -39,8 +55,11 @@ function normalizeEpisode(input: unknown): PodcastListeningEpisode | null {
39
55
  completes: num(row.completes),
40
56
  completionRate: num(row.completionRate),
41
57
  totalListeningSeconds: listening,
58
+ measuredListeners,
42
59
  avgListeningSecondsPerListener:
43
- listening !== null && listeners > 0 ? listening / listeners : null,
60
+ listening !== null
61
+ ? measuredAverage(row.avgListeningSecondsPerListener, measuredListeners)
62
+ : null,
44
63
  }
45
64
  }
46
65
 
@@ -61,6 +80,10 @@ export function normalizePodcastListening(input: unknown): PodcastListening | nu
61
80
  const totalsRow = body.totals as Record<string, unknown> | undefined
62
81
  const totals = totalsRow ?? {}
63
82
  const rangeRow = body.range as Record<string, unknown> | undefined
83
+ const measuredListeners =
84
+ typeof totals.totalListeningSeconds === 'number'
85
+ ? measuredCount(totals.measuredListeners)
86
+ : null
64
87
  return {
65
88
  episodes,
66
89
  totals: {
@@ -71,11 +94,20 @@ export function normalizePodcastListening(input: unknown): PodcastListening | nu
71
94
  completionRate: num(totals.completionRate),
72
95
  totalListeningSeconds:
73
96
  typeof totals.totalListeningSeconds === 'number' ? num(totals.totalListeningSeconds) : null,
97
+ measuredListeners,
98
+ avgListeningSecondsPerListener: measuredAverage(
99
+ totals.avgListeningSecondsPerListener,
100
+ measuredListeners,
101
+ ),
74
102
  },
75
103
  range: {
76
104
  from: typeof rangeRow?.from === 'string' ? rangeRow.from : '',
77
105
  to: typeof rangeRow?.to === 'string' ? rangeRow.to : '',
78
106
  },
107
+ ...(typeof body.listeningStartedAt === 'string' &&
108
+ Number.isFinite(Date.parse(body.listeningStartedAt))
109
+ ? { listeningStartedAt: body.listeningStartedAt }
110
+ : {}),
79
111
  }
80
112
  }
81
113
 
@@ -398,3 +398,138 @@ a:focus-visible {
398
398
  .drag-handle { display: flex; align-items: center; justify-content: center; width: 18px; height: 22px; border-radius: 4px; color: var(--ink-faint); font-size: 11px; letter-spacing: -2px; cursor: grab; user-select: none; }
399
399
  .drag-handle:hover { background: var(--hover); color: var(--ink-soft); }
400
400
  .drag-handle:active { cursor: grabbing; }
401
+
402
+ /* ── Cards and chips (survey results and any screen that uses them) ───── */
403
+ .card {
404
+ background: var(--panel);
405
+ border: 1px solid var(--border);
406
+ box-shadow: var(--shadow-faint);
407
+ padding: 20px 22px;
408
+ min-width: 0;
409
+ }
410
+ .chip-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
411
+ .chip {
412
+ display: inline-flex; align-items: center; gap: 6px;
413
+ height: 28px; padding: 0 12px;
414
+ border: 1px solid var(--border); border-radius: var(--radius-pill);
415
+ background: var(--panel); color: var(--ink-soft);
416
+ font-size: 12.5px; font-weight: 600; white-space: nowrap;
417
+ transition: background .12s, color .12s, border-color .12s;
418
+ }
419
+ button.chip:hover { background: var(--hover); color: var(--ink); }
420
+ .chip[aria-pressed="true"] { background: var(--surface-inverse); border-color: var(--surface-inverse); color: var(--text-on-inverse); }
421
+ .chip.is-quiet { height: 22px; padding: 0 8px; font-size: 11px; font-weight: 500; color: var(--ink-muted); background: var(--panel-2); }
422
+
423
+ /* ── Survey results ───────────────────────────────────────────────────
424
+ Chart hues were run through the dataviz palette validator (lightness band,
425
+ chroma, CVD and normal-vision separation, 3:1 against the card) for each
426
+ theme. Group hues g0–g4 mark identity; mood hues mark polarity only. */
427
+ .svy {
428
+ --svy-g0: #00897B; --svy-g1: #C8701A; --svy-g2: #2F6FBD; --svy-g3: #C2185B; --svy-g4: #7A5FD6;
429
+ --svy-happy: #00897B; --svy-unhappy: #D2453C; --svy-neutral: #9AA3AA;
430
+ --svy-bar: var(--svy-g0);
431
+ --svy-bar-soft: color-mix(in srgb, var(--svy-g0) 45%, var(--panel));
432
+ }
433
+ [data-theme="dark"] .svy {
434
+ --svy-g0: #169E90; --svy-g1: #C97C2C; --svy-g2: #4A8AD8; --svy-g3: #E0588C; --svy-g4: #9580EC;
435
+ --svy-happy: #169E90; --svy-unhappy: #E5675F; --svy-neutral: #79838A;
436
+ }
437
+ .svy-section { margin-top: 36px; }
438
+ .svy-section-head { margin-bottom: 14px; }
439
+ .svy-h2 { font-size: 22px; }
440
+ .svy-control { display: inline-flex; flex-direction: column; gap: 6px; min-width: 220px; max-width: 360px; }
441
+ .svy-control .selectbox { padding: 8px 10px; font-size: 13px; }
442
+ .svy-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px; }
443
+ .svy-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex: none; margin-right: 7px; vertical-align: -1px; }
444
+ .svy .g0 { background: var(--svy-g0); } .svy .g1 { background: var(--svy-g1); } .svy .g2 { background: var(--svy-g2); }
445
+ .svy .g3 { background: var(--svy-g3); } .svy .g4 { background: var(--svy-g4); }
446
+ .svy .mood-happy { background: var(--svy-happy); }
447
+ .svy .mood-unhappy { background: var(--svy-unhappy); }
448
+ .svy .mood-neutral { background: var(--svy-neutral); }
449
+
450
+ /* Headline */
451
+ .svy-headline { margin-top: 8px; padding: 24px 28px; }
452
+ .svy-headline-prompt { font-family: var(--font-display); font-size: 19px; font-weight: 600; color: var(--ink); margin: 10px 0 18px; line-height: 1.3; }
453
+ .svy-headline-body { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(0, 2fr); gap: 36px; align-items: end; }
454
+ .svy-big { font-family: var(--font-display); font-size: 56px; font-weight: 600; line-height: 1; letter-spacing: var(--tracking-tighter); color: var(--ink); font-variant-numeric: tabular-nums; }
455
+ .svy-big-label { font-size: 14px; color: var(--ink-soft); margin-top: 8px; line-height: 1.4; }
456
+ .svy-facts { margin: 18px 0 0; display: grid; gap: 8px; }
457
+ .svy-facts > div { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border-soft); font-size: 13px; }
458
+ .svy-facts > div:last-child { border-bottom: 0; padding-bottom: 0; }
459
+ .svy-facts dt { color: var(--ink-soft); display: flex; align-items: center; }
460
+ .svy-facts dd { margin: 0; color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
461
+
462
+ /* Columns (scale questions) */
463
+ .svy-cols { margin: 0; min-width: 0; }
464
+ .svy-cols-plot { display: grid; gap: 10px; height: 170px; align-items: end; border-bottom: 1px solid var(--border); }
465
+ .svy-col { display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch; height: 100%; min-width: 0; }
466
+ .svy-col-value { text-align: center; font-size: 12px; font-weight: 600; color: var(--ink-soft); margin-bottom: 4px; font-variant-numeric: tabular-nums; }
467
+ .svy-col-bar { display: block; min-height: 2px; border-radius: 4px 4px 0 0; max-height: calc(100% - 22px); }
468
+ .svy-cols-axis { display: grid; gap: 10px; margin-top: 6px; }
469
+ .svy-cols-axis span { text-align: center; font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; }
470
+ .svy-cols-avg { position: relative; height: 20px; margin-top: 2px; }
471
+ .svy-cols-avg span { position: absolute; transform: translateX(-50%); white-space: nowrap; font-size: 11.5px; font-weight: 600; color: var(--ink); }
472
+ .svy-cols-caption { display: flex; justify-content: space-between; font-size: 11px; color: var(--ink-faint); margin-top: 4px; }
473
+
474
+ /* Question cards */
475
+ .svy-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--gap); align-items: start; }
476
+ .svy-q-prompt { font-family: var(--font-display); font-size: 16px; font-weight: 600; line-height: 1.35; color: var(--ink); margin: 8px 0 6px; }
477
+ .svy-takeaway { margin: 0 0 16px; font-size: 13.5px; line-height: 1.45; color: var(--ink-soft); }
478
+ .svy-note { margin: 12px 0 0; font-size: 12px; color: var(--ink-muted); line-height: 1.45; }
479
+ .svy-bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
480
+ .svy-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 14px; align-items: baseline; }
481
+ .svy-bar-label { font-size: 13px; line-height: 1.35; color: var(--ink); display: flex; align-items: center; }
482
+ .svy-bar-value { display: inline-flex; align-items: baseline; gap: 8px; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ink); }
483
+ .svy-count { font-size: 11.5px; color: var(--ink-muted); }
484
+ .svy-track { grid-column: 1 / -1; display: flex; height: 10px; background: var(--panel-2); overflow: hidden; }
485
+ .svy-track.sm { height: 7px; }
486
+ .svy-fill { display: block; height: 100%; background: var(--svy-bar-soft); border-radius: 0 4px 4px 0; }
487
+ .svy-fill.is-top { background: var(--svy-bar); }
488
+ .svy-stack { gap: 2px; background: transparent; }
489
+ .svy-seg { display: block; height: 100%; }
490
+ .svy-seg:first-child { border-radius: 0; }
491
+ .svy-seg:last-child { border-radius: 0 4px 4px 0; }
492
+ .svy-bar.is-grouped { grid-template-columns: minmax(0, 1fr); }
493
+ .svy-gbars { display: grid; gap: 3px; }
494
+ .svy-gbar { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 10px; align-items: center; }
495
+ .svy-gbar .svy-track { grid-column: auto; }
496
+ .svy-gbar-value { font-size: 11.5px; color: var(--ink-soft); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
497
+ .svy-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; margin: 0 0 14px; }
498
+ .svy-legend-item { display: inline-flex; align-items: center; font-size: 12.5px; color: var(--ink); }
499
+ .svy-compare-status { margin: 0 0 14px; }
500
+
501
+ /* Callouts */
502
+ .svy-callout {
503
+ margin: 0 0 12px; padding: 10px 14px; font-size: 13px; line-height: 1.5; color: var(--ink);
504
+ background: var(--amber-tint); border-left: 3px solid var(--amber);
505
+ display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
506
+ }
507
+ .svy-callout.is-alert { background: var(--red-tint); border-left-color: var(--red); }
508
+
509
+ /* Comments */
510
+ .svy-comments { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--gap); align-items: start; }
511
+ .svy-comment { display: flex; flex-direction: column; gap: 12px; }
512
+ .svy-comment-entry { display: grid; gap: 10px; }
513
+ .svy-quote {
514
+ margin: 0; padding: 2px 0 2px 14px; border-left: 3px solid var(--svy-g0);
515
+ font-size: 15px; line-height: 1.6; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere;
516
+ }
517
+ .svy-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 12px; color: var(--ink-muted); }
518
+ .svy-rating { display: inline-flex; align-items: center; font-weight: 600; color: var(--ink); }
519
+ .svy-answers summary, .svy-silent summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--ink-soft); }
520
+ .svy-answers dl { margin: 10px 0 0; display: grid; gap: 8px; }
521
+ .svy-answers dt { font-size: 11.5px; color: var(--ink-muted); }
522
+ .svy-answers dd { margin: 2px 0 0; font-size: 13px; color: var(--ink); }
523
+ .svy-silent { margin-top: 16px; }
524
+ .svy-silent ul { margin: 10px 0 0; padding-left: 18px; display: grid; gap: 4px; font-size: 12.5px; color: var(--ink-muted); }
525
+ .svy-pager { display: flex; justify-content: space-between; margin-top: 20px; }
526
+
527
+ @media (max-width: 1080px) {
528
+ .svy-grid, .svy-comments { grid-template-columns: minmax(0, 1fr); }
529
+ .svy-headline-body { grid-template-columns: minmax(0, 1fr); gap: 24px; }
530
+ }
531
+ @media (max-width: 720px) {
532
+ .svy-headline { padding: 18px; }
533
+ .svy-big { font-size: 44px; }
534
+ .svy-control { min-width: 0; width: 100%; max-width: none; }
535
+ }