@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
@@ -3,6 +3,18 @@ function num(value) {
3
3
  const n = typeof value === 'number' ? value : Number(value);
4
4
  return Number.isFinite(n) ? n : 0;
5
5
  }
6
+ function measuredCount(value) {
7
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
8
+ }
9
+ function measuredAverage(value, count) {
10
+ return count !== null &&
11
+ count > 0 &&
12
+ typeof value === 'number' &&
13
+ Number.isFinite(value) &&
14
+ value >= 0
15
+ ? value
16
+ : null;
17
+ }
6
18
  function normalizeEpisode(input) {
7
19
  if (!input || typeof input !== 'object')
8
20
  return null;
@@ -11,6 +23,7 @@ function normalizeEpisode(input) {
11
23
  const hasListening = typeof row.totalListeningSeconds === 'number';
12
24
  const listening = hasListening ? num(row.totalListeningSeconds) : null;
13
25
  const listeners = num(row.listeners);
26
+ const measuredListeners = hasListening ? measuredCount(row.measuredListeners) : null;
14
27
  return {
15
28
  slug,
16
29
  ...(typeof row.title === 'string' && row.title.length > 0 ? { title: row.title } : {}),
@@ -24,7 +37,10 @@ function normalizeEpisode(input) {
24
37
  completes: num(row.completes),
25
38
  completionRate: num(row.completionRate),
26
39
  totalListeningSeconds: listening,
27
- avgListeningSecondsPerListener: listening !== null && listeners > 0 ? listening / listeners : null,
40
+ measuredListeners,
41
+ avgListeningSecondsPerListener: listening !== null
42
+ ? measuredAverage(row.avgListeningSecondsPerListener, measuredListeners)
43
+ : null,
28
44
  };
29
45
  }
30
46
  /**
@@ -46,6 +62,9 @@ export function normalizePodcastListening(input) {
46
62
  const totalsRow = body.totals;
47
63
  const totals = totalsRow ?? {};
48
64
  const rangeRow = body.range;
65
+ const measuredListeners = typeof totals.totalListeningSeconds === 'number'
66
+ ? measuredCount(totals.measuredListeners)
67
+ : null;
49
68
  return {
50
69
  episodes,
51
70
  totals: {
@@ -55,11 +74,17 @@ export function normalizePodcastListening(input) {
55
74
  completes: num(totals.completes),
56
75
  completionRate: num(totals.completionRate),
57
76
  totalListeningSeconds: typeof totals.totalListeningSeconds === 'number' ? num(totals.totalListeningSeconds) : null,
77
+ measuredListeners,
78
+ avgListeningSecondsPerListener: measuredAverage(totals.avgListeningSecondsPerListener, measuredListeners),
58
79
  },
59
80
  range: {
60
81
  from: typeof rangeRow?.from === 'string' ? rangeRow.from : '',
61
82
  to: typeof rangeRow?.to === 'string' ? rangeRow.to : '',
62
83
  },
84
+ ...(typeof body.listeningStartedAt === 'string' &&
85
+ Number.isFinite(Date.parse(body.listeningStartedAt))
86
+ ? { listeningStartedAt: body.listeningStartedAt }
87
+ : {}),
63
88
  };
64
89
  }
65
90
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"podcast-listening-data.js","sourceRoot":"","sources":["../../../src/ui/screens/podcast-listening-data.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"podcast-listening-data.js","sourceRoot":"","sources":["../../../src/ui/screens/podcast-listening-data.ts"],"names":[],"mappings":"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"}
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.8.24",
3
+ "version": "0.8.26",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "growth-labs-cms-migrations": "./dist/cli/migrations.js",
@@ -152,8 +152,7 @@
152
152
  "scripts": {
153
153
  "build": "tsc && node scripts/copy-assets.mjs",
154
154
  "check": "biome check src/",
155
- "test": "node scripts/run-tests.mjs",
156
- "test:flat": "vitest run",
155
+ "test": "node scripts/copy-assets.mjs && vitest run",
157
156
  "test:watch": "vitest"
158
157
  }
159
158
  }
@@ -563,12 +563,20 @@ export async function listPublishedContent(
563
563
  'ci.published_at IS NOT NULL',
564
564
  ]
565
565
  const values: unknown[] = []
566
+ // A missing revision or type must reach the integrity checks, rather than
567
+ // disappearing behind a type/channel filter as a successful empty list.
568
+ const invalidRevisionType = `CASE WHEN json_valid(cr.payload_json) = 1
569
+ THEN COALESCE(json_type(cr.payload_json, '$.item.type') != 'text'
570
+ OR json_extract(cr.payload_json, '$.item.type') NOT IN ('article', 'video', 'podcast', 'newsletter', 'page'), 1)
571
+ ELSE 1 END`
566
572
  if (normalized.type) {
567
- predicates.push("json_extract(cr.payload_json, '$.item.type') = ?")
573
+ predicates.push(`(${invalidRevisionType} OR json_extract(cr.payload_json, '$.item.type') = ?)`)
568
574
  values.push(normalized.type)
569
575
  }
570
576
  if (normalized.channel) {
571
- predicates.push("json_extract(cr.payload_json, '$.item.channel') = ?")
577
+ predicates.push(
578
+ `(${invalidRevisionType} OR json_extract(cr.payload_json, '$.item.channel') = ?)`,
579
+ )
572
580
  values.push(normalized.channel)
573
581
  }
574
582
  if (normalized.cursor) {
@@ -584,7 +592,7 @@ export async function listPublishedContent(
584
592
  const outlineResult = await reader
585
593
  .prepare(
586
594
  `${normalized.bodies ? SELECT_PUBLISHED_CONTENT_OUTLINE : SELECT_PUBLISHED_CONTENT_OUTLINE_BODYLESS}
587
- JOIN content_revisions cr ON cr.id = ci.published_revision_id
595
+ LEFT JOIN content_revisions cr ON cr.id = ci.published_revision_id
588
596
  WHERE ${predicates.join('\n AND ')}
589
597
  ORDER BY ci.published_at DESC, ci.id DESC
590
598
  LIMIT ?`,
@@ -110,7 +110,9 @@ export interface PodcastListeningEpisode {
110
110
  * must render "—", never a silent zero.
111
111
  */
112
112
  totalListeningSeconds: number | null
113
- /** totalListeningSeconds / listeners; null when either input is unmeasured. */
113
+ /** Sessions with measured listening seconds; absent on legacy providers. */
114
+ measuredListeners?: number | null
115
+ /** totalListeningSeconds / measuredListeners; null when either is unmeasured. */
114
116
  avgListeningSecondsPerListener: number | null
115
117
  }
116
118
 
@@ -129,9 +131,13 @@ export interface PodcastListening {
129
131
  completes: number
130
132
  completionRate: number
131
133
  totalListeningSeconds: number | null
134
+ measuredListeners?: number | null
135
+ avgListeningSecondsPerListener?: number | null
132
136
  }
133
137
  /** Echo of the requested window, ISO 8601 instants. */
134
138
  range: { from: string; to: string }
139
+ /** First recorded listening-time event; plays may cover an earlier period. */
140
+ listeningStartedAt?: string
135
141
  }
136
142
 
137
143
  export interface AnalyticsProvider {
@@ -946,16 +946,19 @@ CREATE TABLE survey_answer_labels (
946
946
  * `wrangler d1 migrations apply` for real deploys.
947
947
  */
948
948
  export async function applyMigrations(db: D1Like): Promise<void> {
949
+ // One statement per line (whitespace collapsed so D1's per-line exec parser
950
+ // sees each complete), all sent in a single exec: a call per statement cost
951
+ // ~600 ms per schema (192 round trips), a single call ~60 ms.
952
+ const lines: string[] = []
949
953
  for (const statement of splitStatements(CMS_MIGRATION_SQL)) {
950
- // Collapse internal whitespace/newlines so D1's per-line exec parser sees
951
- // one complete statement.
952
- await db.exec(`${statement.replace(/\s+/g, ' ').trim()};`)
954
+ lines.push(`${statement.replace(/\s+/g, ' ').trim()};`)
953
955
  }
954
956
  for (const migration of CMS_MIGRATIONS_0002_PLUS) {
955
957
  for (const statement of splitStatements(migration.sql)) {
956
- await db.exec(`${statement.replace(/\s+/g, ' ').trim()};`)
958
+ lines.push(`${statement.replace(/\s+/g, ' ').trim()};`)
957
959
  }
958
960
  }
961
+ await db.exec(lines.join('\n'))
959
962
  }
960
963
 
961
964
  export type { CmsTableName } from './tables.js'
@@ -0,0 +1,243 @@
1
+ import type { SurveyQuestion, SurveyResults } from './schema.js'
2
+
3
+ // Pure readout math for the survey results screen. Every share here names its
4
+ // denominator: a question's share is of the people who answered it, and a
5
+ // group's share is of that group's members who answered it — never of a page.
6
+
7
+ export type SurveyResponse = SurveyResults['responses'][number]
8
+ type ScaleQuestion = Extract<SurveyQuestion, { kind: 'scale' }>
9
+ type ChoiceQuestion = Extract<SurveyQuestion, { kind: 'single_choice' | 'multi_choice' }>
10
+
11
+ export type Mood = 'unhappy' | 'neutral' | 'happy'
12
+
13
+ export interface ScaleSummary {
14
+ min: number
15
+ max: number
16
+ answered: number
17
+ average: number | null
18
+ happy: number
19
+ neutral: number
20
+ unhappy: number
21
+ happyRange: string
22
+ unhappyRange: string
23
+ /** The midpoint value on an odd-length scale; null when the scale has no middle. */
24
+ neutralValue: number | null
25
+ }
26
+
27
+ export function scaleMood(value: number, min: number, max: number): Mood {
28
+ const midpoint = (min + max) / 2
29
+ if (value > midpoint) return 'happy'
30
+ if (value < midpoint) return 'unhappy'
31
+ return 'neutral'
32
+ }
33
+
34
+ export function summarizeScale(
35
+ options: { min: number; max: number },
36
+ distribution: Record<string, number>,
37
+ ): ScaleSummary {
38
+ const { min, max } = options
39
+ const midpoint = (min + max) / 2
40
+ let answered = 0
41
+ let sum = 0
42
+ const totals = { happy: 0, neutral: 0, unhappy: 0 }
43
+ for (let value = min; value <= max; value += 1) {
44
+ const count = distribution[String(value)] ?? 0
45
+ answered += count
46
+ sum += value * count
47
+ totals[scaleMood(value, min, max)] += count
48
+ }
49
+ return {
50
+ min,
51
+ max,
52
+ answered,
53
+ average: answered === 0 ? null : sum / answered,
54
+ ...totals,
55
+ happyRange: valueRange(Math.floor(midpoint) + 1, max),
56
+ unhappyRange: valueRange(min, Math.ceil(midpoint) - 1),
57
+ neutralValue: Number.isInteger(midpoint) ? midpoint : null,
58
+ }
59
+ }
60
+
61
+ export function share(count: number, of: number): number {
62
+ return of === 0 ? 0 : count / of
63
+ }
64
+
65
+ export function percent(count: number, of: number): string {
66
+ return `${Math.round(share(count, of) * 100)}%`
67
+ }
68
+
69
+ export interface OptionShare {
70
+ option: string
71
+ count: number
72
+ }
73
+
74
+ // A multi-choice question is a ranking ("which two would you most want"), so it
75
+ // reads best sorted; a single choice keeps its configured order, which is often
76
+ // ordinal (daily → rarely) and must not be reshuffled.
77
+ export function optionRows(question: ChoiceQuestion): OptionShare[] {
78
+ const rows = question.options.map((option) => ({ option, count: question.counts[option] ?? 0 }))
79
+ return question.kind === 'multi_choice' ? [...rows].sort((a, b) => b.count - a.count) : rows
80
+ }
81
+
82
+ export function questionTakeaway(question: SurveyQuestion, responseCount: number): string {
83
+ if (question.answerCount === 0) return 'Nobody has answered this yet.'
84
+ if (question.kind === 'scale') {
85
+ const summary = summarizeScale(question.options, question.distribution)
86
+ return `${percent(summary.happy, summary.answered)} rated it ${summary.happyRange} · average ${formatAverage(summary.average)} out of ${summary.max}`
87
+ }
88
+ if (question.kind === 'text') {
89
+ return `${question.answerCount} of ${responseCount} people wrote an answer (${percent(question.answerCount, responseCount)})`
90
+ }
91
+ const rows = [...optionRows(question)].sort((a, b) => b.count - a.count)
92
+ const [first, second] = rows
93
+ if (question.kind === 'multi_choice') {
94
+ return second && second.count > 0
95
+ ? `Most picked: “${first.option}” (${percent(first.count, question.answerCount)}), then “${second.option}” (${percent(second.count, question.answerCount)})`
96
+ : `Most picked: “${first.option}” (${percent(first.count, question.answerCount)})`
97
+ }
98
+ return `${percent(first.count, question.answerCount)} chose “${first.option}”`
99
+ }
100
+
101
+ export function formatAverage(average: number | null): string {
102
+ return average === null ? '—' : average.toFixed(2)
103
+ }
104
+
105
+ /** The first scale question is the survey's headline number. */
106
+ export function headlineScale(questions: SurveyQuestion[]): ScaleQuestion | null {
107
+ const scale = questions.find((question) => question.kind === 'scale')
108
+ return scale?.kind === 'scale' ? scale : null
109
+ }
110
+
111
+ export function answerValue(response: SurveyResponse, questionId: string): unknown {
112
+ return response.answers?.find((answer) => answer.questionId === questionId)?.value
113
+ }
114
+
115
+ // ── Comparing groups ────────────────────────────────────────────────────
116
+
117
+ export const MEMBERSHIP_SPLIT = 'membership'
118
+
119
+ export interface ResponseGroup {
120
+ key: string
121
+ label: string
122
+ responses: SurveyResponse[]
123
+ /** Set when the groups are moods: they then wear the mood colors, not group hues. */
124
+ mood?: Mood
125
+ }
126
+
127
+ /** Questions that can divide readers into groups: one answer per person. */
128
+ export function splitQuestions(questions: SurveyQuestion[]): SurveyQuestion[] {
129
+ return questions.filter(
130
+ (question) => question.kind === 'single_choice' || question.kind === 'scale',
131
+ )
132
+ }
133
+
134
+ export function groupResponses(
135
+ responses: SurveyResponse[],
136
+ split: string,
137
+ questions: SurveyQuestion[],
138
+ ): ResponseGroup[] {
139
+ if (split === MEMBERSHIP_SPLIT) {
140
+ return nonEmpty([
141
+ group('member', 'Members', responses, (r) => r.respondentSegment === 'member'),
142
+ group('non_member', 'Non-members', responses, (r) => r.respondentSegment === 'non_member'),
143
+ group('unknown', 'Membership unknown', responses, (r) => r.respondentSegment === 'unknown'),
144
+ ])
145
+ }
146
+ const question = questions.find((candidate) => candidate.id === split)
147
+ if (question?.kind === 'single_choice') {
148
+ return nonEmpty(
149
+ question.options.map((option) =>
150
+ group(option, option, responses, (r) => answerValue(r, question.id) === option),
151
+ ),
152
+ )
153
+ }
154
+ if (question?.kind === 'scale') {
155
+ // Six separate satisfaction groups are too thin to read; editors ask
156
+ // "what do unhappy readers want", so a scale splits by mood.
157
+ const { min, max } = question.options
158
+ const summary = summarizeScale(question.options, {})
159
+ const moodOf = (r: SurveyResponse) => {
160
+ const value = answerValue(r, question.id)
161
+ return typeof value === 'number' ? scaleMood(value, min, max) : null
162
+ }
163
+ const labels: Record<Mood, string> = {
164
+ unhappy: `Unhappy (${summary.unhappyRange})`,
165
+ neutral: `In the middle (${summary.neutralValue ?? ''})`,
166
+ happy: `Happy (${summary.happyRange})`,
167
+ }
168
+ return nonEmpty(
169
+ (['unhappy', 'neutral', 'happy'] as const).map((mood) => ({
170
+ ...group(mood, labels[mood], responses, (r) => moodOf(r) === mood),
171
+ mood,
172
+ })),
173
+ )
174
+ }
175
+ return []
176
+ }
177
+
178
+ export interface GroupCount {
179
+ count: number
180
+ answered: number
181
+ }
182
+
183
+ export type QuestionComparison =
184
+ | { kind: 'choice'; rows: Array<{ option: string; byGroup: GroupCount[] }> }
185
+ | { kind: 'scale'; byGroup: ScaleSummary[] }
186
+ | { kind: 'none' }
187
+
188
+ export function compareQuestion(
189
+ question: SurveyQuestion,
190
+ groups: ResponseGroup[],
191
+ ): QuestionComparison {
192
+ if (question.kind === 'single_choice' || question.kind === 'multi_choice') {
193
+ const valuesByGroup = groups.map((g) =>
194
+ g.responses
195
+ .map((r) => answerValue(r, question.id))
196
+ .filter((value) => value !== undefined)
197
+ .map((value) => (Array.isArray(value) ? value : [value])),
198
+ )
199
+ return {
200
+ kind: 'choice',
201
+ rows: optionRows(question).map(({ option }) => ({
202
+ option,
203
+ byGroup: valuesByGroup.map((answers) => ({
204
+ count: answers.filter((picked) => picked.includes(option)).length,
205
+ answered: answers.length,
206
+ })),
207
+ })),
208
+ }
209
+ }
210
+ if (question.kind === 'scale') {
211
+ return {
212
+ kind: 'scale',
213
+ byGroup: groups.map((g) => {
214
+ const distribution: Record<string, number> = {}
215
+ for (const r of g.responses) {
216
+ const value = answerValue(r, question.id)
217
+ if (typeof value === 'number') {
218
+ distribution[String(value)] = (distribution[String(value)] ?? 0) + 1
219
+ }
220
+ }
221
+ return summarizeScale(question.options, distribution)
222
+ }),
223
+ }
224
+ }
225
+ return { kind: 'none' }
226
+ }
227
+
228
+ function group(
229
+ key: string,
230
+ label: string,
231
+ responses: SurveyResponse[],
232
+ matches: (response: SurveyResponse) => boolean,
233
+ ): ResponseGroup {
234
+ return { key, label, responses: responses.filter(matches) }
235
+ }
236
+
237
+ function nonEmpty(groups: ResponseGroup[]): ResponseGroup[] {
238
+ return groups.filter((g) => g.responses.length > 0)
239
+ }
240
+
241
+ function valueRange(from: number, to: number): string {
242
+ return from === to ? String(from) : `${from}–${to}`
243
+ }
@@ -5,7 +5,7 @@ const MAX_TITLE_LENGTH = 300
5
5
  const MAX_PROMPT_LENGTH = 1_000
6
6
  const MAX_QUESTIONS = 100
7
7
  const MAX_RESPONSES = 500
8
- const MAX_FREE_TEXT_ENTRIES = 500
8
+ export const MAX_FREE_TEXT_ENTRIES = 500
9
9
  const MAX_FREE_TEXT_LENGTH = 20_000
10
10
  const MAX_CHOICE_OPTIONS = 100
11
11
  const MAX_OPTION_LENGTH = 256
@@ -165,6 +165,14 @@ function PodcastListeningBody({ data }: { data: PodcastListening }) {
165
165
 
166
166
  return (
167
167
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
168
+ <div className="page-sub">
169
+ {data.listeningStartedAt
170
+ ? `Listening time has been recorded since ${new Date(data.listeningStartedAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })} (UTC). `
171
+ : 'The start of listening-time coverage is unavailable. '}
172
+ Plays cover the selected range. Listening time counts content played on the website,
173
+ including at faster speeds. Listening inside podcast apps cannot be measured here; a feed
174
+ download is not a measured listen. Averages use only sessions with recorded listening time.
175
+ </div>
168
176
  {!listeningMeasured && <ListeningPendingBanner />}
169
177
  <PodcastTotalsCards data={data} />
170
178
  <PodcastEpisodeTable episodes={data.episodes} />
@@ -211,6 +219,17 @@ function PodcastTotalsCards({ data }: { data: PodcastListening }) {
211
219
  value: t.totalListeningSeconds === null ? '—' : formatDuration(t.totalListeningSeconds),
212
220
  note: t.totalListeningSeconds === null ? 'not reported yet' : 'total, all episodes',
213
221
  },
222
+ {
223
+ label: 'Avg / measured session',
224
+ value:
225
+ t.avgListeningSecondsPerListener == null
226
+ ? '—'
227
+ : formatDuration(t.avgListeningSecondsPerListener),
228
+ note:
229
+ t.measuredListeners == null
230
+ ? 'measurement unavailable'
231
+ : `${formatNumber(t.measuredListeners)} measured sessions`,
232
+ },
214
233
  ]
215
234
  return (
216
235
  <div className="grid-stats">
@@ -258,6 +277,7 @@ const COLUMNS: ColumnDef[] = [
258
277
  { key: 'completes', label: 'Done', align: 'right' },
259
278
  { key: 'completionRate', label: 'Rate', align: 'right' },
260
279
  { key: 'totalListeningSeconds', label: 'Listening', align: 'right' },
280
+ { key: 'avgListeningSecondsPerListener', label: 'Avg / measured session', align: 'right' },
261
281
  ]
262
282
 
263
283
  export function PodcastEpisodeTable({
@@ -402,6 +422,16 @@ export function PodcastEpisodeTable({
402
422
  formatDuration(episode.totalListeningSeconds)
403
423
  )}
404
424
  </td>
425
+ <td style={{ paddingRight: 18 }}>
426
+ {episode.avgListeningSecondsPerListener === null
427
+ ? '—'
428
+ : formatDuration(episode.avgListeningSecondsPerListener)}
429
+ {episode.measuredListeners != null && (
430
+ <div className="kicker">
431
+ {formatNumber(episode.measuredListeners)} measured sessions
432
+ </div>
433
+ )}
434
+ </td>
405
435
  <td style={{ paddingRight: 18 }}>
406
436
  <DropoffBars episode={episode} />
407
437
  </td>