@growth-labs/cms 0.8.23 → 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.
- package/dist/engine/foundry-dispatch.d.ts +1 -0
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +1 -1
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +9 -3
- package/dist/engine/published-content.js.map +1 -1
- package/dist/providers/types.d.ts +7 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -4
- package/dist/schema/migrations.js.map +1 -1
- package/dist/surveys/readout.d.ts +69 -0
- package/dist/surveys/readout.d.ts.map +1 -0
- package/dist/surveys/readout.js +157 -0
- package/dist/surveys/readout.js.map +1 -0
- package/dist/surveys/schema.d.ts +1 -0
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +1 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.js +16 -2
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +19 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +203 -96
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
- package/dist/ui/screens/podcast-listening-data.js +26 -1
- package/dist/ui/screens/podcast-listening-data.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +135 -0
- package/package.json +2 -3
- package/src/engine/foundry-dispatch.ts +1 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/published-content.ts +11 -3
- package/src/providers/types.ts +7 -1
- package/src/schema/migrations.ts +7 -4
- package/src/surveys/readout.ts +243 -0
- package/src/surveys/schema.ts +1 -1
- package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +769 -290
- package/src/ui/screens/podcast-listening-data.ts +33 -1
- 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
|
-
|
|
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":"
|
|
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.
|
|
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/
|
|
156
|
-
"test:flat": "vitest run",
|
|
155
|
+
"test": "node scripts/copy-assets.mjs && vitest run",
|
|
157
156
|
"test:watch": "vitest"
|
|
158
157
|
}
|
|
159
158
|
}
|
|
@@ -65,7 +65,7 @@ export interface CallbackInput {
|
|
|
65
65
|
excerpt?: string | null
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
async function sha256Hex(value: string): Promise<string> {
|
|
68
|
+
export async function sha256Hex(value: string): Promise<string> {
|
|
69
69
|
const bytes = new TextEncoder().encode(value)
|
|
70
70
|
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
71
71
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
package/src/engine/index.ts
CHANGED
|
@@ -213,6 +213,11 @@ export {
|
|
|
213
213
|
isRetiredItem,
|
|
214
214
|
payloadDrift,
|
|
215
215
|
} from './publication-continuity.js'
|
|
216
|
+
// Trusted media producers use the same capture proof as the CMS dispatch path.
|
|
217
|
+
export {
|
|
218
|
+
MEDIA_PUBLICATION_ITEM_FIELDS,
|
|
219
|
+
mediaPublicationCaptureGuard,
|
|
220
|
+
} from './publication-current-guard.js'
|
|
216
221
|
export {
|
|
217
222
|
CanonicalJsonError,
|
|
218
223
|
canonicalJson,
|
|
@@ -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(
|
|
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(
|
|
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 ?`,
|
package/src/providers/types.ts
CHANGED
|
@@ -110,7 +110,9 @@ export interface PodcastListeningEpisode {
|
|
|
110
110
|
* must render "—", never a silent zero.
|
|
111
111
|
*/
|
|
112
112
|
totalListeningSeconds: number | null
|
|
113
|
-
/**
|
|
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 {
|
package/src/schema/migrations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/surveys/schema.ts
CHANGED
|
@@ -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>
|