@astryxdesign/cli 0.4.5 → 0.4.6-canary.cea9ecd

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 (72) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/api/component/_adapter.d.mts +22 -5
  3. package/api/component/_adapter.mjs +51 -3
  4. package/api/docs/_adapter.d.mts +37 -8
  5. package/api/docs/_adapter.mjs +70 -41
  6. package/api/docs/detail/detail.d.mts +2 -0
  7. package/api/docs/detail/detail.mjs +12 -10
  8. package/api/docs/detail/section/section.d.mts +2 -0
  9. package/api/docs/detail/section/section.mjs +1 -0
  10. package/api/docs/docs.d.mts +2 -0
  11. package/api/docs/docs.doc.mjs +11 -2
  12. package/api/docs/docs.mjs +2 -1
  13. package/api/docs/docs.type.d.mts +15 -0
  14. package/api/docs/docs.type.mjs +6 -0
  15. package/api/docs/integrationDocs.test.mjs +208 -0
  16. package/api/docs/list/list.d.mts +5 -1
  17. package/api/docs/list/list.mjs +30 -14
  18. package/api/init/run/run.mjs +9 -4
  19. package/api/integration/validate-integration.mjs +2 -1
  20. package/api/integration/validate-integration.type.d.mts +1 -0
  21. package/api/integration/validate-integration.type.mjs +1 -0
  22. package/api/search/search.mjs +26 -15
  23. package/api/upgrade/_adapter.d.mts +2 -2
  24. package/api/upgrade/_adapter.mjs +7 -3
  25. package/api/upgrade/run/run.mjs +1 -1
  26. package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
  27. package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
  28. package/assets/docs/cli-integrations.doc.mjs +41 -3
  29. package/assets/docs/layout.doc.dense.mjs +275 -30
  30. package/assets/docs/layout.doc.mjs +412 -78
  31. package/assets/docs/theme.doc.mjs +3 -3
  32. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.doc.mjs +15 -0
  33. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.tsx +47 -0
  34. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.doc.mjs +1 -1
  35. package/assets/templates/blocks/components/Breadcrumbs/BreadcrumbsCustomSeparator.tsx +18 -5
  36. package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
  37. package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
  38. package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
  39. package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
  40. package/assets/templates/pages/dashboard-data/page.tsx +894 -0
  41. package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
  42. package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
  43. package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
  44. package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
  45. package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
  46. package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
  47. package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
  48. package/authoring/doctypes/_schema.d.mts +2 -0
  49. package/authoring/doctypes/_schema.mjs +5 -0
  50. package/authoring/doctypes/reference/reference.doc.mjs +14 -0
  51. package/authoring/doctypes/reference/type.ts +12 -0
  52. package/authoring/doctypes/template/type.ts +2 -0
  53. package/authoring/integration/integration.doc.mjs +10 -1
  54. package/authoring/integration/parse.d.mts +1 -0
  55. package/authoring/integration/parse.mjs +1 -0
  56. package/authoring/integration/parse.test.mjs +1 -0
  57. package/authoring/integration/type.ts +5 -0
  58. package/clients/cli/commands/component-ownership.test.mjs +63 -3
  59. package/clients/cli/commands/theme-build.doc.mjs +1 -1
  60. package/foundation/agent-docs/agent-docs.d.mts +19 -3
  61. package/foundation/agent-docs/agent-docs.mjs +30 -11
  62. package/foundation/agent-docs/agent-docs.test.mjs +38 -0
  63. package/foundation/config/project.d.mts +16 -0
  64. package/foundation/config/project.mjs +65 -4
  65. package/foundation/config/project.test.mjs +66 -0
  66. package/foundation/discovery/docs-discovery.d.mts +185 -0
  67. package/foundation/discovery/docs-discovery.mjs +544 -0
  68. package/foundation/discovery/docs-discovery.test.mjs +341 -0
  69. package/foundation/integrations/integrations.d.mts +8 -6
  70. package/foundation/integrations/integrations.mjs +6 -4
  71. package/foundation/integrations/validate-contributions.mjs +30 -2
  72. package/package.json +9 -9
@@ -0,0 +1,914 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * Funnel & Cohort Analytics — a growth / product-analytics dashboard.
7
+ *
8
+ * Content-only (root `Layout`); the host supplies the app shell. Composed of
9
+ * distinct analytics widgets stacked in the content column:
10
+ *
11
+ * KPI row | conversion funnel (stage bars + step drop-off)
12
+ * | conversion-over-time trend | cohort retention grid
13
+ *
14
+ * The segment control (All / Web / iOS / Android) and the funnel window
15
+ * (7d / 30d / 90d) reshape the funnel, the trend chart, and the KPIs. Cohort
16
+ * retention is a table of Token-colored cells (heatmap-by-value). All data is
17
+ * deterministic (fixed fixtures, no clocks/random) so previews stay stable.
18
+ */
19
+
20
+ import {useMemo, useState, type CSSProperties} from 'react';
21
+ import * as stylex from '@stylexjs/stylex';
22
+ import {
23
+ VStack,
24
+ HStack,
25
+ Layout,
26
+ LayoutContent,
27
+ LayoutHeader,
28
+ } from '@astryxdesign/core/Layout';
29
+ import {Grid} from '@astryxdesign/core/Grid';
30
+ import {Text, Heading} from '@astryxdesign/core/Text';
31
+ import {Card} from '@astryxdesign/core/Card';
32
+ import {Button} from '@astryxdesign/core/Button';
33
+ import {Icon} from '@astryxdesign/core/Icon';
34
+ import {Divider} from '@astryxdesign/core/Divider';
35
+ import {Token} from '@astryxdesign/core/Token';
36
+ import {Selector} from '@astryxdesign/core/Selector';
37
+ import {
38
+ SegmentedControl,
39
+ SegmentedControlItem,
40
+ } from '@astryxdesign/core/SegmentedControl';
41
+ import {Table, pixel} from '@astryxdesign/core/Table';
42
+ import type {TableColumn} from '@astryxdesign/core/Table';
43
+ import {
44
+ BarChart,
45
+ Bar,
46
+ Cell,
47
+ LineChart,
48
+ Line,
49
+ XAxis,
50
+ YAxis,
51
+ CartesianGrid,
52
+ Tooltip,
53
+ ResponsiveContainer,
54
+ } from 'recharts';
55
+ import {
56
+ ArrowUpIcon,
57
+ ArrowDownIcon,
58
+ ArrowDownTrayIcon,
59
+ ArrowTrendingDownIcon,
60
+ } from '@heroicons/react/24/outline';
61
+ import {StopIcon} from '@heroicons/react/24/solid';
62
+
63
+ // ============= TYPES =============
64
+
65
+ type Segment = 'all' | 'web' | 'ios' | 'android';
66
+ type FunnelWindow = '7d' | '30d' | '90d';
67
+
68
+ interface FunnelStage {
69
+ key: string;
70
+ label: string;
71
+ // Absolute users entering this stage, per segment.
72
+ users: Record<Segment, number>;
73
+ }
74
+
75
+ // ============= COLORS (design tokens w/ hex fallbacks) =============
76
+
77
+ const COLORS = {
78
+ blue: 'var(--color-data-categorical-blue, #0171E3)',
79
+ green: 'var(--color-data-categorical-green, #0B991F)',
80
+ orange: 'var(--color-data-categorical-orange, #EB6E00)',
81
+ purple: 'var(--color-data-categorical-purple, #6B1EFD)',
82
+ teal: 'var(--color-data-categorical-teal, #008E80)',
83
+ };
84
+ const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
85
+ const AXIS_TICK = {
86
+ fontSize: 'var(--font-size-sm, 12px)',
87
+ fill: 'var(--color-text-secondary, #4E606F)',
88
+ };
89
+
90
+ // Funnel bars step through the categorical palette top-to-bottom.
91
+ const FUNNEL_COLORS = [
92
+ COLORS.blue,
93
+ COLORS.teal,
94
+ COLORS.green,
95
+ COLORS.orange,
96
+ COLORS.purple,
97
+ ];
98
+
99
+ // ============= STYLES =============
100
+
101
+ const styles = stylex.create({
102
+ // Rules between the drop-off steps. Each step draws its own leading rule,
103
+ // pulled half a gap to the left so it sits centred in the column gap. The
104
+ // step that starts a row draws its rule outside the grid's content box,
105
+ // where `overflowX: clip` hides it — so the separators stay correct as the
106
+ // grid reflows from four columns down to one.
107
+ //
108
+ // The `--spacing-3` below must match the step Grid's `gap={3}`, otherwise
109
+ // the rules sit off-centre. StyleX needs a literal here, so the two can't
110
+ // share a constant — keep them in sync by hand.
111
+ stepGrid: {
112
+ overflowX: 'clip',
113
+ },
114
+ step: {
115
+ borderInlineStartWidth: '1px',
116
+ borderInlineStartStyle: 'solid',
117
+ borderInlineStartColor: 'var(--color-border, rgba(5, 54, 89, 0.1))',
118
+ marginInlineStart: 'calc(var(--spacing-3, 12px) / -2)',
119
+ paddingInlineStart: 'calc(var(--spacing-3, 12px) / 2)',
120
+ },
121
+ });
122
+
123
+ // ============= FUNNEL DATA =============
124
+
125
+ // Window multipliers applied to the base (30d) stage counts.
126
+ const WINDOW_FACTOR: Record<FunnelWindow, number> = {
127
+ '7d': 0.24,
128
+ '30d': 1,
129
+ '90d': 3.1,
130
+ };
131
+
132
+ const FUNNEL_STAGES: FunnelStage[] = [
133
+ {
134
+ key: 'visit',
135
+ label: 'Visited site',
136
+ users: {all: 128400, web: 82600, ios: 27300, android: 18500},
137
+ },
138
+ {
139
+ key: 'signup',
140
+ label: 'Signed up',
141
+ users: {all: 41200, web: 24800, ios: 9600, android: 6800},
142
+ },
143
+ {
144
+ key: 'activate',
145
+ label: 'Activated',
146
+ users: {all: 22600, web: 12900, ios: 5900, android: 3800},
147
+ },
148
+ {
149
+ key: 'subscribe',
150
+ label: 'Subscribed',
151
+ users: {all: 9100, web: 5100, ios: 2600, android: 1400},
152
+ },
153
+ {
154
+ key: 'retain',
155
+ label: 'Retained 30d',
156
+ users: {all: 6300, web: 3600, ios: 1850, android: 850},
157
+ },
158
+ ];
159
+
160
+ // ============= CONVERSION-OVER-TIME =============
161
+
162
+ interface TrendPoint {
163
+ t: number;
164
+ label: string;
165
+ signup: number;
166
+ activate: number;
167
+ subscribe: number;
168
+ }
169
+
170
+ // Weekly stage-conversion rates (%). Signup->activate->subscribe.
171
+ //
172
+ // Shaped as a plausible growth story rather than a smooth ramp, so the chart
173
+ // reads like real telemetry: a W7 pricing test lifts paid conversion, a W9-W10
174
+ // onboarding regression sinks activation until a fix ships, a W15 acquisition
175
+ // campaign spikes signups with low-intent traffic (activation dips as signup
176
+ // peaks, and the two series cross), W18 is an outage week that knocks all three
177
+ // down together, and W19+ recovers to a net-upward trend.
178
+ const TREND: TrendPoint[] = [
179
+ {t: 0, label: 'W1', signup: 30.1, activate: 52.4, subscribe: 38.2},
180
+ {t: 1, label: 'W2', signup: 31.4, activate: 54.1, subscribe: 36.9},
181
+ {t: 2, label: 'W3', signup: 29.2, activate: 51.2, subscribe: 39.8},
182
+ {t: 3, label: 'W4', signup: 32.6, activate: 55.8, subscribe: 37.1},
183
+ {t: 4, label: 'W5', signup: 28.4, activate: 53.6, subscribe: 40.4},
184
+ {t: 5, label: 'W6', signup: 31.8, activate: 56.2, subscribe: 38.3},
185
+ {t: 6, label: 'W7', signup: 33.5, activate: 54.3, subscribe: 44.6},
186
+ {t: 7, label: 'W8', signup: 30.2, activate: 57.1, subscribe: 42.1},
187
+ {t: 8, label: 'W9', signup: 34.1, activate: 48.9, subscribe: 37.4},
188
+ {t: 9, label: 'W10', signup: 29.6, activate: 46.2, subscribe: 35.2},
189
+ {t: 10, label: 'W11', signup: 32.9, activate: 50.4, subscribe: 38.9},
190
+ {t: 11, label: 'W12', signup: 35.4, activate: 55.6, subscribe: 41.3},
191
+ {t: 12, label: 'W13', signup: 31.1, activate: 57.3, subscribe: 39.2},
192
+ {t: 13, label: 'W14', signup: 33.8, activate: 54.8, subscribe: 42.8},
193
+ {t: 14, label: 'W15', signup: 41.2, activate: 49.6, subscribe: 40.1},
194
+ {t: 15, label: 'W16', signup: 38.6, activate: 47.8, subscribe: 36.4},
195
+ {t: 16, label: 'W17', signup: 30.4, activate: 53.2, subscribe: 39.7},
196
+ {t: 17, label: 'W18', signup: 26.8, activate: 44.1, subscribe: 34.2},
197
+ {t: 18, label: 'W19', signup: 31.9, activate: 51.7, subscribe: 38.1},
198
+ {t: 19, label: 'W20', signup: 34.7, activate: 56.4, subscribe: 41.9},
199
+ {t: 20, label: 'W21', signup: 32.2, activate: 58.2, subscribe: 44.2},
200
+ {t: 21, label: 'W22', signup: 36.1, activate: 55.1, subscribe: 40.6},
201
+ {t: 22, label: 'W23', signup: 33.4, activate: 59.4, subscribe: 43.8},
202
+ {t: 23, label: 'W24', signup: 37.2, activate: 56.8, subscribe: 46.1},
203
+ {t: 24, label: 'W25', signup: 34.9, activate: 60.1, subscribe: 42.7},
204
+ {t: 25, label: 'W26', signup: 38.4, activate: 58.6, subscribe: 45.3},
205
+ ];
206
+
207
+ // One row per plotted series, so the chart lines and the legend below it can't
208
+ // drift apart — both are rendered from this list.
209
+ const TREND_SERIES = [
210
+ {key: 'signup', name: 'Visit → signup', color: COLORS.blue},
211
+ {key: 'activate', name: 'Signup → activation', color: COLORS.green},
212
+ {key: 'subscribe', name: 'Activation → paid', color: COLORS.orange},
213
+ ] as const;
214
+
215
+ // Label every fifth week. Derived from TREND so the axis stays in-domain when
216
+ // the fixture grows or shrinks.
217
+ const TREND_TICK_STEP = 5;
218
+ const TREND_TICKS = Array.from(
219
+ {length: Math.ceil(TREND.length / TREND_TICK_STEP)},
220
+ (_, i) => i * TREND_TICK_STEP,
221
+ );
222
+ const formatTrendTick = (v: number) => TREND[v]?.label ?? '';
223
+
224
+ // ============= COHORT RETENTION =============
225
+
226
+ interface Cohort {
227
+ label: string;
228
+ size: number;
229
+ // Retention % by week index (week 0 = 100 by definition).
230
+ retention: number[];
231
+ }
232
+
233
+ const COHORTS: Cohort[] = [
234
+ {
235
+ label: 'Apr 6 – Apr 12',
236
+ size: 4820,
237
+ retention: [100, 62, 48, 41, 37, 34, 32, 31],
238
+ },
239
+ {
240
+ label: 'Apr 13 – Apr 19',
241
+ size: 5140,
242
+ retention: [100, 64, 50, 43, 39, 36, 34],
243
+ },
244
+ {label: 'Apr 20 – Apr 26', size: 5390, retention: [100, 66, 52, 45, 41, 38]},
245
+ {label: 'Apr 27 – May 3', size: 5610, retention: [100, 63, 49, 42, 38]},
246
+ {label: 'May 4 – May 10', size: 5980, retention: [100, 67, 54, 47]},
247
+ {label: 'May 11 – May 17', size: 6240, retention: [100, 69, 56]},
248
+ {label: 'May 18 – May 24', size: 6510, retention: [100, 71]},
249
+ {label: 'May 25 – May 31', size: 6720, retention: [100]},
250
+ ];
251
+
252
+ const MAX_WEEKS = 8;
253
+
254
+ // Map a retention percentage to a Token color (green = sticky, red = leaky).
255
+ function retentionColor(
256
+ pct: number,
257
+ ): 'green' | 'teal' | 'yellow' | 'orange' | 'red' {
258
+ if (pct >= 70) {
259
+ return 'green';
260
+ }
261
+ if (pct >= 50) {
262
+ return 'teal';
263
+ }
264
+ if (pct >= 40) {
265
+ return 'yellow';
266
+ }
267
+ if (pct >= 32) {
268
+ return 'orange';
269
+ }
270
+ return 'red';
271
+ }
272
+
273
+ // ============= KPIS =============
274
+
275
+ interface Kpi {
276
+ key: string;
277
+ label: string;
278
+ value: string;
279
+ delta: number;
280
+ higherIsBetter: boolean;
281
+ }
282
+
283
+ const KPIS: Record<Segment, Kpi[]> = {
284
+ all: [
285
+ {
286
+ key: 'v2s',
287
+ label: 'Visit → signup',
288
+ value: '32.1%',
289
+ delta: 1.8,
290
+ higherIsBetter: true,
291
+ },
292
+ {
293
+ key: 's2a',
294
+ label: 'Signup → activation',
295
+ value: '54.9%',
296
+ delta: 2.4,
297
+ higherIsBetter: true,
298
+ },
299
+ {
300
+ key: 'a2s',
301
+ label: 'Activation → paid',
302
+ value: '40.3%',
303
+ delta: -1.1,
304
+ higherIsBetter: true,
305
+ },
306
+ {
307
+ key: 'w4',
308
+ label: 'Week-4 retention',
309
+ value: '43.2%',
310
+ delta: 3.6,
311
+ higherIsBetter: true,
312
+ },
313
+ ],
314
+ web: [
315
+ {
316
+ key: 'v2s',
317
+ label: 'Visit → signup',
318
+ value: '30.0%',
319
+ delta: 1.2,
320
+ higherIsBetter: true,
321
+ },
322
+ {
323
+ key: 's2a',
324
+ label: 'Signup → activation',
325
+ value: '52.0%',
326
+ delta: 1.9,
327
+ higherIsBetter: true,
328
+ },
329
+ {
330
+ key: 'a2s',
331
+ label: 'Activation → paid',
332
+ value: '39.5%',
333
+ delta: -0.6,
334
+ higherIsBetter: true,
335
+ },
336
+ {
337
+ key: 'w4',
338
+ label: 'Week-4 retention',
339
+ value: '41.8%',
340
+ delta: 2.9,
341
+ higherIsBetter: true,
342
+ },
343
+ ],
344
+ ios: [
345
+ {
346
+ key: 'v2s',
347
+ label: 'Visit → signup',
348
+ value: '35.2%',
349
+ delta: 2.6,
350
+ higherIsBetter: true,
351
+ },
352
+ {
353
+ key: 's2a',
354
+ label: 'Signup → activation',
355
+ value: '61.5%',
356
+ delta: 3.1,
357
+ higherIsBetter: true,
358
+ },
359
+ {
360
+ key: 'a2s',
361
+ label: 'Activation → paid',
362
+ value: '44.1%',
363
+ delta: 0.8,
364
+ higherIsBetter: true,
365
+ },
366
+ {
367
+ key: 'w4',
368
+ label: 'Week-4 retention',
369
+ value: '47.5%',
370
+ delta: 4.2,
371
+ higherIsBetter: true,
372
+ },
373
+ ],
374
+ android: [
375
+ {
376
+ key: 'v2s',
377
+ label: 'Visit → signup',
378
+ value: '36.8%',
379
+ delta: 2.1,
380
+ higherIsBetter: true,
381
+ },
382
+ {
383
+ key: 's2a',
384
+ label: 'Signup → activation',
385
+ value: '55.9%',
386
+ delta: 1.4,
387
+ higherIsBetter: true,
388
+ },
389
+ {
390
+ key: 'a2s',
391
+ label: 'Activation → paid',
392
+ value: '36.8%',
393
+ delta: -2.3,
394
+ higherIsBetter: true,
395
+ },
396
+ {
397
+ key: 'w4',
398
+ label: 'Week-4 retention',
399
+ value: '38.4%',
400
+ delta: -0.9,
401
+ higherIsBetter: true,
402
+ },
403
+ ],
404
+ };
405
+
406
+ const SEGMENT_OPTIONS = [
407
+ {value: 'all', label: 'All platforms'},
408
+ {value: 'web', label: 'Web'},
409
+ {value: 'ios', label: 'iOS'},
410
+ {value: 'android', label: 'Android'},
411
+ ];
412
+
413
+ // ============= SHARED PIECES =============
414
+
415
+ // Icon's `color` prop only takes semantic names, but the legend swatch must
416
+ // match the exact chart series color — inline color required (same pattern as
417
+ // the shipped dashboard templates).
418
+ function LegendDot({color, label}: {color: string; label: string}) {
419
+ const dotStyle: CSSProperties = {color};
420
+ return (
421
+ <HStack gap={2} vAlign="center">
422
+ <Icon icon={StopIcon} size="xsm" style={dotStyle} />
423
+ <Text type="supporting" color="secondary">
424
+ {label}
425
+ </Text>
426
+ </HStack>
427
+ );
428
+ }
429
+
430
+ function nf(n: number): string {
431
+ return n.toLocaleString();
432
+ }
433
+
434
+ // ============= KPI TILE =============
435
+
436
+ function KpiTile({kpi}: {kpi: Kpi}) {
437
+ const favorable = kpi.higherIsBetter ? kpi.delta >= 0 : kpi.delta <= 0;
438
+ return (
439
+ <Card padding={4}>
440
+ <VStack gap={1}>
441
+ <Text type="label" color="secondary">
442
+ {kpi.label}
443
+ </Text>
444
+ <HStack gap={3}>
445
+ <Heading level={1}>{kpi.value}</Heading>
446
+ <HStack gap={1} vAlign="center">
447
+ <Icon
448
+ icon={kpi.delta >= 0 ? ArrowUpIcon : ArrowDownIcon}
449
+ size="xsm"
450
+ color={favorable ? 'success' : 'error'}
451
+ />
452
+ <Text type="supporting" color="secondary">
453
+ {kpi.delta >= 0 ? '+' : ''}
454
+ {kpi.delta.toFixed(1)} pts vs. prior
455
+ </Text>
456
+ </HStack>
457
+ </HStack>
458
+ </VStack>
459
+ </Card>
460
+ );
461
+ }
462
+
463
+ // ============= FUNNEL =============
464
+
465
+ interface FunnelBar {
466
+ key: string;
467
+ label: string;
468
+ users: number;
469
+ pctOfTop: number;
470
+ stepPct: number | null;
471
+ color: string;
472
+ }
473
+
474
+ interface FunnelTooltipEntry {
475
+ payload: FunnelBar;
476
+ }
477
+
478
+ function FunnelTooltip({
479
+ active,
480
+ payload,
481
+ }: {
482
+ active?: boolean;
483
+ payload?: FunnelTooltipEntry[];
484
+ }) {
485
+ if (!active || !payload?.length) {
486
+ return null;
487
+ }
488
+ const bar = payload[payload.length - 1].payload;
489
+ return (
490
+ <Card padding={3}>
491
+ <VStack gap={1}>
492
+ <Text type="body" weight="semibold">
493
+ {bar.label}
494
+ </Text>
495
+ <Text type="supporting" color="secondary">
496
+ {nf(bar.users)} users · {bar.pctOfTop.toFixed(1)}% of top
497
+ </Text>
498
+ {bar.stepPct != null && (
499
+ <Text type="supporting" color="secondary">
500
+ {bar.stepPct.toFixed(1)}% from previous step
501
+ </Text>
502
+ )}
503
+ </VStack>
504
+ </Card>
505
+ );
506
+ }
507
+
508
+ function ConversionFunnel({segment}: {segment: Segment}) {
509
+ const bars = useMemo<FunnelBar[]>(() => {
510
+ const top = FUNNEL_STAGES[0].users[segment];
511
+ return FUNNEL_STAGES.map((stage, i) => {
512
+ const users = stage.users[segment];
513
+ const prev = i > 0 ? FUNNEL_STAGES[i - 1].users[segment] : null;
514
+ return {
515
+ key: stage.key,
516
+ label: stage.label,
517
+ users,
518
+ pctOfTop: (users / top) * 100,
519
+ stepPct: prev != null ? (users / prev) * 100 : null,
520
+ color: FUNNEL_COLORS[i % FUNNEL_COLORS.length],
521
+ };
522
+ });
523
+ }, [segment]);
524
+
525
+ return (
526
+ <VStack gap={4}>
527
+ <ResponsiveContainer width="100%" height={260}>
528
+ <BarChart
529
+ data={bars}
530
+ layout="vertical"
531
+ margin={{top: 0, right: 24, left: 8, bottom: 0}}
532
+ barCategoryGap={10}>
533
+ <CartesianGrid horizontal={false} vertical stroke={GRID_STROKE} />
534
+ <XAxis
535
+ type="number"
536
+ tick={AXIS_TICK}
537
+ axisLine={false}
538
+ tickLine={false}
539
+ />
540
+ <YAxis
541
+ type="category"
542
+ dataKey="label"
543
+ tick={AXIS_TICK}
544
+ axisLine={false}
545
+ tickLine={false}
546
+ width={110}
547
+ />
548
+ <Tooltip content={<FunnelTooltip />} cursor={{fill: GRID_STROKE}} />
549
+ <Bar dataKey="users" radius={4} isAnimationActive={false}>
550
+ {bars.map(bar => (
551
+ <Cell key={bar.key} fill={bar.color} />
552
+ ))}
553
+ </Bar>
554
+ </BarChart>
555
+ </ResponsiveContainer>
556
+ </VStack>
557
+ );
558
+ }
559
+
560
+ // Step-by-step drop-off cards beneath the funnel.
561
+ function FunnelSteps({segment}: {segment: Segment}) {
562
+ const steps = useMemo(() => {
563
+ return FUNNEL_STAGES.slice(1).map((stage, i) => {
564
+ const prev = FUNNEL_STAGES[i];
565
+ const from = prev.users[segment];
566
+ const to = stage.users[segment];
567
+ const conv = (to / from) * 100;
568
+ const drop = 100 - conv;
569
+ return {
570
+ key: stage.key,
571
+ label: `${prev.label} → ${stage.label}`,
572
+ conv,
573
+ drop,
574
+ };
575
+ });
576
+ }, [segment]);
577
+
578
+ return (
579
+ <Grid
580
+ columns={{minWidth: 200, repeat: 'fit'}}
581
+ gap={3}
582
+ xstyle={styles.stepGrid}>
583
+ {steps.map(step => {
584
+ const leaky = step.drop >= 55;
585
+ return (
586
+ <VStack key={step.key} gap={1} xstyle={styles.step}>
587
+ <Text type="label" color="secondary">
588
+ {step.label}
589
+ </Text>
590
+ <HStack gap={2} vAlign="center">
591
+ <Heading level={2}>{step.conv.toFixed(1)}%</Heading>
592
+ <Token
593
+ size="sm"
594
+ color={leaky ? 'red' : 'green'}
595
+ icon={
596
+ <Icon
597
+ icon={ArrowTrendingDownIcon}
598
+ size="xsm"
599
+ color="inherit"
600
+ />
601
+ }
602
+ label={`-${step.drop.toFixed(0)}%`}
603
+ />
604
+ </HStack>
605
+ </VStack>
606
+ );
607
+ })}
608
+ </Grid>
609
+ );
610
+ }
611
+
612
+ // ============= CONVERSION TREND =============
613
+
614
+ interface TrendTooltipEntry {
615
+ name: string;
616
+ value: number;
617
+ color: string;
618
+ }
619
+
620
+ function TrendTooltip({
621
+ active,
622
+ payload,
623
+ label,
624
+ }: {
625
+ active?: boolean;
626
+ payload?: TrendTooltipEntry[];
627
+ label?: number;
628
+ }) {
629
+ if (!active || !payload?.length) {
630
+ return null;
631
+ }
632
+ const wk = typeof label === 'number' ? (TREND[label]?.label ?? '') : '';
633
+ return (
634
+ <Card padding={3}>
635
+ <VStack gap={1}>
636
+ <Text type="supporting" color="secondary">
637
+ {wk}
638
+ </Text>
639
+ {payload.map(entry => (
640
+ <LegendDot
641
+ key={entry.name}
642
+ color={entry.color}
643
+ label={`${entry.name}: ${entry.value.toFixed(1)}%`}
644
+ />
645
+ ))}
646
+ </VStack>
647
+ </Card>
648
+ );
649
+ }
650
+
651
+ function ConversionTrend() {
652
+ return (
653
+ <VStack gap={3}>
654
+ <ResponsiveContainer width="100%" height={260}>
655
+ <LineChart
656
+ data={TREND}
657
+ margin={{top: 5, right: 12, left: 0, bottom: 5}}>
658
+ <CartesianGrid horizontal vertical={false} stroke={GRID_STROKE} />
659
+ <XAxis
660
+ dataKey="t"
661
+ type="number"
662
+ domain={[0, TREND.length - 1]}
663
+ ticks={TREND_TICKS}
664
+ tickFormatter={formatTrendTick}
665
+ tick={AXIS_TICK}
666
+ axisLine={false}
667
+ tickLine={false}
668
+ />
669
+ <YAxis
670
+ tick={AXIS_TICK}
671
+ axisLine={false}
672
+ tickLine={false}
673
+ width={40}
674
+ unit="%"
675
+ />
676
+ <Tooltip content={<TrendTooltip />} cursor={{stroke: GRID_STROKE}} />
677
+ {TREND_SERIES.map(series => (
678
+ <Line
679
+ key={series.key}
680
+ type="linear"
681
+ dataKey={series.key}
682
+ name={series.name}
683
+ stroke={series.color}
684
+ strokeWidth={1.5}
685
+ strokeLinejoin="miter"
686
+ dot={false}
687
+ isAnimationActive={false}
688
+ />
689
+ ))}
690
+ </LineChart>
691
+ </ResponsiveContainer>
692
+ <HStack gap={5} vAlign="center" wrap="wrap">
693
+ {TREND_SERIES.map(series => (
694
+ <LegendDot
695
+ key={series.key}
696
+ color={series.color}
697
+ label={series.name}
698
+ />
699
+ ))}
700
+ </HStack>
701
+ </VStack>
702
+ );
703
+ }
704
+
705
+ // ============= COHORT RETENTION GRID =============
706
+
707
+ interface CohortRow extends Record<string, unknown> {
708
+ id: string;
709
+ label: string;
710
+ size: number;
711
+ retention: number[];
712
+ }
713
+
714
+ function buildCohortColumns(): TableColumn<CohortRow>[] {
715
+ const cols: TableColumn<CohortRow>[] = [
716
+ {
717
+ key: 'label',
718
+ header: 'Cohort',
719
+ width: pixel(150),
720
+ renderCell: (item: CohortRow) => (
721
+ <Text type="body" weight="semibold">
722
+ {item.label}
723
+ </Text>
724
+ ),
725
+ },
726
+ {
727
+ key: 'size',
728
+ header: 'Users',
729
+ width: pixel(90),
730
+ renderCell: (item: CohortRow) => (
731
+ <Text type="body" color="secondary">
732
+ {nf(item.size)}
733
+ </Text>
734
+ ),
735
+ },
736
+ ];
737
+ for (let week = 0; week < MAX_WEEKS; week++) {
738
+ cols.push({
739
+ key: `w${week}`,
740
+ header: `W${week}`,
741
+ width: pixel(72),
742
+ renderCell: (item: CohortRow) => {
743
+ const pct = item.retention[week];
744
+ if (pct == null) {
745
+ return (
746
+ <Text type="supporting" color="secondary">
747
+
748
+ </Text>
749
+ );
750
+ }
751
+ return (
752
+ <Token size="sm" color={retentionColor(pct)} label={`${pct}%`} />
753
+ );
754
+ },
755
+ });
756
+ }
757
+ return cols;
758
+ }
759
+
760
+ const cohortColumns = buildCohortColumns();
761
+ const cohortRows: CohortRow[] = COHORTS.map((c, i) => ({
762
+ id: `c${i}`,
763
+ label: c.label,
764
+ size: c.size,
765
+ retention: c.retention,
766
+ }));
767
+
768
+ // ============= SECTION HEADING =============
769
+
770
+ function SectionHeading({title, hint}: {title: string; hint?: string}) {
771
+ return (
772
+ <VStack>
773
+ <Heading level={2}>{title}</Heading>
774
+ {hint ? (
775
+ <Text type="supporting" color="secondary">
776
+ {hint}
777
+ </Text>
778
+ ) : null}
779
+ </VStack>
780
+ );
781
+ }
782
+
783
+ // ============= MAIN =============
784
+
785
+ export default function FunnelCohortAnalyticsPage() {
786
+ const [segment, setSegment] = useState<Segment>('all');
787
+ const [funnelWindow, setFunnelWindow] = useState<FunnelWindow>('30d');
788
+
789
+ const kpis = KPIS[segment];
790
+ const topOfFunnel = useMemo(() => {
791
+ const base = FUNNEL_STAGES[0].users[segment];
792
+ return Math.round(base * WINDOW_FACTOR[funnelWindow]);
793
+ }, [segment, funnelWindow]);
794
+
795
+ const overallConversion = useMemo(() => {
796
+ const top = FUNNEL_STAGES[0].users[segment];
797
+ const bottom = FUNNEL_STAGES[FUNNEL_STAGES.length - 1].users[segment];
798
+ return ((bottom / top) * 100).toFixed(1);
799
+ }, [segment]);
800
+
801
+ return (
802
+ <Layout
803
+ height="fill"
804
+ padding={4}
805
+ contentWidth={1440}
806
+ header={
807
+ <LayoutHeader hasDivider>
808
+ <HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
809
+ <VStack gap={0}>
810
+ <Heading level={1}>Funnel & cohort analytics</Heading>
811
+ <Text type="supporting" color="secondary">
812
+ {nf(topOfFunnel)} visitors · {overallConversion}% end-to-end
813
+ conversion
814
+ </Text>
815
+ </VStack>
816
+ <HStack gap={2}>
817
+ <SegmentedControl
818
+ label="Funnel window"
819
+ value={funnelWindow}
820
+ onChange={value => setFunnelWindow(value as FunnelWindow)}>
821
+ <SegmentedControlItem label="7d" value="7d" />
822
+ <SegmentedControlItem label="30d" value="30d" />
823
+ <SegmentedControlItem label="90d" value="90d" />
824
+ </SegmentedControl>
825
+ <Selector
826
+ label="Segment"
827
+ isLabelHidden
828
+ options={SEGMENT_OPTIONS}
829
+ value={segment}
830
+ onChange={value => setSegment(value as Segment)}
831
+ />
832
+ <Button
833
+ label="Export"
834
+ variant="secondary"
835
+ icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
836
+ />
837
+ </HStack>
838
+ </HStack>
839
+ </LayoutHeader>
840
+ }
841
+ content={
842
+ <LayoutContent>
843
+ <VStack gap={10}>
844
+ <VStack gap={4}>
845
+ {/* KPI row */}
846
+ <Grid columns={{minWidth: 240, repeat: 'fit'}} gap={3}>
847
+ {kpis.map(kpi => (
848
+ <KpiTile key={kpi.key} kpi={kpi} />
849
+ ))}
850
+ </Grid>
851
+
852
+ {/* Conversion funnel */}
853
+ <Card padding={6}>
854
+ <VStack gap={6}>
855
+ <SectionHeading
856
+ title="Conversion funnel"
857
+ hint={`${SEGMENT_OPTIONS.find(o => o.value === segment)?.label} · ${funnelWindow}`}
858
+ />
859
+ <ConversionFunnel segment={segment} />
860
+ <Divider />
861
+ <FunnelSteps segment={segment} />
862
+ </VStack>
863
+ </Card>
864
+
865
+ {/* Conversion over time */}
866
+ <Card padding={6}>
867
+ <VStack gap={6}>
868
+ <SectionHeading
869
+ title="Conversion over time"
870
+ hint={`Stage conversion rates · trailing ${TREND.length} weeks`}
871
+ />
872
+ <ConversionTrend />
873
+ </VStack>
874
+ </Card>
875
+ </VStack>
876
+
877
+ <Divider />
878
+
879
+ {/* Cohort retention grid */}
880
+ <VStack gap={6}>
881
+ <SectionHeading
882
+ title="Weekly retention cohorts"
883
+ hint="Signup week × weeks since signup"
884
+ />
885
+ <Card padding={6}>
886
+ <VStack gap={8}>
887
+ <Table<CohortRow>
888
+ data={cohortRows}
889
+ columns={cohortColumns}
890
+ idKey="id"
891
+ density="balanced"
892
+ dividers="rows"
893
+ />
894
+ <HStack gap={4} vAlign="center" wrap="wrap">
895
+ <Text type="supporting" color="secondary">
896
+ Retention:
897
+ </Text>
898
+ <HStack gap={2} vAlign="center">
899
+ <Token size="sm" color="red" label="< 32%" />
900
+ <Token size="sm" color="orange" label="32–40%" />
901
+ <Token size="sm" color="yellow" label="40–50%" />
902
+ <Token size="sm" color="teal" label="50–70%" />
903
+ <Token size="sm" color="green" label="≥ 70%" />
904
+ </HStack>
905
+ </HStack>
906
+ </VStack>
907
+ </Card>
908
+ </VStack>
909
+ </VStack>
910
+ </LayoutContent>
911
+ }
912
+ />
913
+ );
914
+ }