@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.
- package/CHANGELOG.md +26 -0
- package/api/component/_adapter.d.mts +22 -5
- package/api/component/_adapter.mjs +51 -3
- package/api/docs/_adapter.d.mts +37 -8
- package/api/docs/_adapter.mjs +70 -41
- package/api/docs/detail/detail.d.mts +2 -0
- package/api/docs/detail/detail.mjs +12 -10
- package/api/docs/detail/section/section.d.mts +2 -0
- package/api/docs/detail/section/section.mjs +1 -0
- package/api/docs/docs.d.mts +2 -0
- package/api/docs/docs.doc.mjs +11 -2
- package/api/docs/docs.mjs +2 -1
- package/api/docs/docs.type.d.mts +15 -0
- package/api/docs/docs.type.mjs +6 -0
- package/api/docs/integrationDocs.test.mjs +208 -0
- package/api/docs/list/list.d.mts +5 -1
- package/api/docs/list/list.mjs +30 -14
- package/api/init/run/run.mjs +9 -4
- package/api/integration/validate-integration.mjs +2 -1
- package/api/integration/validate-integration.type.d.mts +1 -0
- package/api/integration/validate-integration.type.mjs +1 -0
- package/api/search/search.mjs +26 -15
- package/api/upgrade/_adapter.d.mts +2 -2
- package/api/upgrade/_adapter.mjs +7 -3
- package/api/upgrade/run/run.mjs +1 -1
- package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
- package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
- package/assets/docs/cli-integrations.doc.mjs +41 -3
- package/assets/docs/layout.doc.dense.mjs +275 -30
- package/assets/docs/layout.doc.mjs +412 -78
- package/assets/docs/theme.doc.mjs +3 -3
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.doc.mjs +15 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.tsx +47 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Breadcrumbs/BreadcrumbsCustomSeparator.tsx +18 -5
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
- package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
- package/assets/templates/pages/dashboard-data/page.tsx +894 -0
- package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
- package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
- package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
- package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
- package/authoring/doctypes/_schema.d.mts +2 -0
- package/authoring/doctypes/_schema.mjs +5 -0
- package/authoring/doctypes/reference/reference.doc.mjs +14 -0
- package/authoring/doctypes/reference/type.ts +12 -0
- package/authoring/doctypes/template/type.ts +2 -0
- package/authoring/integration/integration.doc.mjs +10 -1
- package/authoring/integration/parse.d.mts +1 -0
- package/authoring/integration/parse.mjs +1 -0
- package/authoring/integration/parse.test.mjs +1 -0
- package/authoring/integration/type.ts +5 -0
- package/clients/cli/commands/component-ownership.test.mjs +63 -3
- package/clients/cli/commands/theme-build.doc.mjs +1 -1
- package/foundation/agent-docs/agent-docs.d.mts +19 -3
- package/foundation/agent-docs/agent-docs.mjs +30 -11
- package/foundation/agent-docs/agent-docs.test.mjs +38 -0
- package/foundation/config/project.d.mts +16 -0
- package/foundation/config/project.mjs +65 -4
- package/foundation/config/project.test.mjs +66 -0
- package/foundation/discovery/docs-discovery.d.mts +185 -0
- package/foundation/discovery/docs-discovery.mjs +544 -0
- package/foundation/discovery/docs-discovery.test.mjs +341 -0
- package/foundation/integrations/integrations.d.mts +8 -6
- package/foundation/integrations/integrations.mjs +6 -4
- package/foundation/integrations/validate-contributions.mjs +30 -2
- package/package.json +9 -9
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Data Dashboard — a generic user-analytics dashboard.
|
|
7
|
+
*
|
|
8
|
+
* Content-only (root `Layout`); the host supplies the app shell. Modeled after
|
|
9
|
+
* dense metric-monitoring dashboards: a filter bar and grids of small metric
|
|
10
|
+
* cards — each with a sparkline and multi-period deltas (d/d, w/w, m/m, y/y)
|
|
11
|
+
* with red/green direction coloring.
|
|
12
|
+
*
|
|
13
|
+
* filter bar | key metrics | devices trend | demographics | acquisition
|
|
14
|
+
*
|
|
15
|
+
* Every metric is generic and self-contained (no real users, no proprietary
|
|
16
|
+
* data). All figures are deterministic (fixed fixtures, no clocks/random) so
|
|
17
|
+
* previews stay stable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {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 {AspectRatio} from '@astryxdesign/core/AspectRatio';
|
|
31
|
+
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
32
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
33
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
34
|
+
import {Icon} from '@astryxdesign/core/Icon';
|
|
35
|
+
import {Divider} from '@astryxdesign/core/Divider';
|
|
36
|
+
import {Badge} from '@astryxdesign/core/Badge';
|
|
37
|
+
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
|
38
|
+
import {Selector} from '@astryxdesign/core/Selector';
|
|
39
|
+
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
|
|
40
|
+
import type {TableColumn} from '@astryxdesign/core/Table';
|
|
41
|
+
import {
|
|
42
|
+
BarChart,
|
|
43
|
+
Bar,
|
|
44
|
+
LineChart,
|
|
45
|
+
Line,
|
|
46
|
+
AreaChart,
|
|
47
|
+
Area,
|
|
48
|
+
XAxis,
|
|
49
|
+
YAxis,
|
|
50
|
+
CartesianGrid,
|
|
51
|
+
Tooltip,
|
|
52
|
+
ResponsiveContainer,
|
|
53
|
+
} from 'recharts';
|
|
54
|
+
import {
|
|
55
|
+
ArrowUpIcon,
|
|
56
|
+
ArrowDownIcon,
|
|
57
|
+
ArrowDownTrayIcon,
|
|
58
|
+
UsersIcon,
|
|
59
|
+
CursorArrowRaysIcon,
|
|
60
|
+
} from '@heroicons/react/24/outline';
|
|
61
|
+
import {StopIcon} from '@heroicons/react/24/solid';
|
|
62
|
+
|
|
63
|
+
// ============= TYPES =============
|
|
64
|
+
|
|
65
|
+
type Segment = 'all' | 'new' | 'returning';
|
|
66
|
+
type DateRange = '7d' | '30d' | '90d';
|
|
67
|
+
type DeviceFilter = 'all' | 'mobile' | 'desktop' | 'tablet';
|
|
68
|
+
|
|
69
|
+
// Period-over-period change, in percent: day, week, month, and year.
|
|
70
|
+
interface Deltas {
|
|
71
|
+
dd: number;
|
|
72
|
+
ww: number;
|
|
73
|
+
mm: number;
|
|
74
|
+
yy: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// One point of a sparkline, pre-shaped for recharts so the fixtures do the
|
|
78
|
+
// work once at module load instead of on every render of every card.
|
|
79
|
+
interface SparkPoint {
|
|
80
|
+
i: number;
|
|
81
|
+
v: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Metric {
|
|
85
|
+
key: string;
|
|
86
|
+
label: string;
|
|
87
|
+
value: string;
|
|
88
|
+
// Whether a rising value is good (engagement) or bad (bounce rate).
|
|
89
|
+
higherIsBetter: boolean;
|
|
90
|
+
deltas: Deltas;
|
|
91
|
+
spark: SparkPoint[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============= COLORS (design tokens w/ hex fallbacks) =============
|
|
95
|
+
|
|
96
|
+
const COLORS = {
|
|
97
|
+
blue: 'var(--color-data-categorical-blue, #0171E3)',
|
|
98
|
+
green: 'var(--color-data-categorical-green, #0B991F)',
|
|
99
|
+
orange: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
100
|
+
purple: 'var(--color-data-categorical-purple, #6B1EFD)',
|
|
101
|
+
teal: 'var(--color-data-categorical-teal, #008E80)',
|
|
102
|
+
neutral: 'var(--color-data-neutral, #8494A3)',
|
|
103
|
+
};
|
|
104
|
+
const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
|
|
105
|
+
const AXIS_TICK = {
|
|
106
|
+
fontSize: 'var(--font-size-sm, 12px)',
|
|
107
|
+
fill: 'var(--color-text-secondary, #4E606F)',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ============= DETERMINISTIC SERIES =============
|
|
111
|
+
|
|
112
|
+
// A full quarter. The sparklines and the active-users trend both span this
|
|
113
|
+
// window, so the card-level and chart-level views of the data agree.
|
|
114
|
+
const TREND_DAYS = 90;
|
|
115
|
+
|
|
116
|
+
// Hash-based noise in [0, 1) — deterministic, so previews never shift.
|
|
117
|
+
// Shared by the metric sparklines and the active-users trend below.
|
|
118
|
+
function noise(i: number, seed: number): number {
|
|
119
|
+
const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453;
|
|
120
|
+
return x - Math.floor(x);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Build a stable but spiky sparkline series: a trend line, a weekly rhythm, a
|
|
124
|
+
// mean-reverting drift, per-point jitter, and the occasional outlier burst.
|
|
125
|
+
function spark(seed: number, trend: number): SparkPoint[] {
|
|
126
|
+
const out: SparkPoint[] = [];
|
|
127
|
+
let drift = 0;
|
|
128
|
+
for (let i = 0; i < TREND_DAYS; i++) {
|
|
129
|
+
const base = 50 + (trend * i * 24) / TREND_DAYS;
|
|
130
|
+
const weekly = Math.sin((i * Math.PI * 2) / 7 + seed) * 2.5;
|
|
131
|
+
drift = drift * 0.72 + (noise(i, seed) - 0.5) * 7;
|
|
132
|
+
const jitter = (noise(i, seed + 17) - 0.5) * 8;
|
|
133
|
+
const burst =
|
|
134
|
+
noise(i, seed + 91) > 0.9 ? (noise(i, seed + 43) - 0.4) * 20 : 0;
|
|
135
|
+
out.push({i, v: Math.round(base + weekly + drift + jitter + burst)});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============= OVERVIEW METRICS =============
|
|
141
|
+
|
|
142
|
+
const OVERVIEW_METRICS: Metric[] = [
|
|
143
|
+
{
|
|
144
|
+
key: 'active-users',
|
|
145
|
+
label: 'Active users',
|
|
146
|
+
value: '1.28M',
|
|
147
|
+
higherIsBetter: true,
|
|
148
|
+
deltas: {dd: 0.4, ww: 2.1, mm: 6.4, yy: 18.2},
|
|
149
|
+
spark: spark(1, 1.1),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
key: 'new-users',
|
|
153
|
+
label: 'New users',
|
|
154
|
+
value: '184.2K',
|
|
155
|
+
higherIsBetter: true,
|
|
156
|
+
deltas: {dd: 1.2, ww: 3.8, mm: 9.1, yy: 24.6},
|
|
157
|
+
spark: spark(2, 1.4),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
key: 'sessions',
|
|
161
|
+
label: 'Sessions',
|
|
162
|
+
value: '4.62M',
|
|
163
|
+
higherIsBetter: true,
|
|
164
|
+
deltas: {dd: 0.2, ww: 1.4, mm: 5.2, yy: 15.9},
|
|
165
|
+
spark: spark(3, 0.9),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
key: 'avg-session',
|
|
169
|
+
label: 'Avg. session',
|
|
170
|
+
value: '6m 42s',
|
|
171
|
+
higherIsBetter: true,
|
|
172
|
+
deltas: {dd: -0.3, ww: 0.8, mm: 2.1, yy: -4.3},
|
|
173
|
+
spark: spark(4, 0.3),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
key: 'pages-session',
|
|
177
|
+
label: 'Pages / session',
|
|
178
|
+
value: '5.3',
|
|
179
|
+
higherIsBetter: true,
|
|
180
|
+
deltas: {dd: 0.1, ww: -0.6, mm: 1.2, yy: 3.7},
|
|
181
|
+
spark: spark(5, 0.4),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
key: 'bounce',
|
|
185
|
+
label: 'Bounce rate',
|
|
186
|
+
value: '38.6%',
|
|
187
|
+
higherIsBetter: false,
|
|
188
|
+
deltas: {dd: -0.2, ww: -1.1, mm: -2.4, yy: -6.8},
|
|
189
|
+
spark: spark(6, -0.5),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: 'dau-mau',
|
|
193
|
+
label: 'DAU / MAU',
|
|
194
|
+
value: '52.1%',
|
|
195
|
+
higherIsBetter: true,
|
|
196
|
+
deltas: {dd: 0.3, ww: 1.0, mm: 3.2, yy: 8.4},
|
|
197
|
+
spark: spark(7, 0.7),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
key: 'conversion',
|
|
201
|
+
label: 'Conversion rate',
|
|
202
|
+
value: '3.84%',
|
|
203
|
+
higherIsBetter: true,
|
|
204
|
+
deltas: {dd: -0.1, ww: 0.9, mm: 2.6, yy: 11.2},
|
|
205
|
+
spark: spark(8, 0.6),
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
key: 'activation',
|
|
209
|
+
label: 'Activation rate',
|
|
210
|
+
value: '61.4%',
|
|
211
|
+
higherIsBetter: true,
|
|
212
|
+
deltas: {dd: 0.2, ww: 1.3, mm: 4.1, yy: 12.7},
|
|
213
|
+
spark: spark(9, 0.8),
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
key: 'retention-30',
|
|
217
|
+
label: '30-day retention',
|
|
218
|
+
value: '27.4%',
|
|
219
|
+
higherIsBetter: true,
|
|
220
|
+
deltas: {dd: 0.1, ww: -0.4, mm: 1.8, yy: 5.3},
|
|
221
|
+
spark: spark(10, -0.3),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
key: 'churn',
|
|
225
|
+
label: 'Churn rate',
|
|
226
|
+
value: '4.7%',
|
|
227
|
+
higherIsBetter: false,
|
|
228
|
+
deltas: {dd: -0.1, ww: -0.5, mm: -1.3, yy: -3.9},
|
|
229
|
+
spark: spark(11, -0.6),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
key: 'load-time',
|
|
233
|
+
label: 'Avg. load time',
|
|
234
|
+
value: '1.4s',
|
|
235
|
+
higherIsBetter: false,
|
|
236
|
+
deltas: {dd: 0.2, ww: 0.6, mm: -2.2, yy: -8.1},
|
|
237
|
+
spark: spark(12, 0.35),
|
|
238
|
+
},
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
// Active-users trend: the desktop/mobile/tablet split across TREND_DAYS.
|
|
242
|
+
// Weekends swing traffic from desktop to mobile, and each day carries its own
|
|
243
|
+
// jitter plus the occasional spike, so the series reads like real telemetry.
|
|
244
|
+
const ACTIVE_TREND = (() => {
|
|
245
|
+
const out: {
|
|
246
|
+
t: number;
|
|
247
|
+
label: string;
|
|
248
|
+
desktop: number;
|
|
249
|
+
mobile: number;
|
|
250
|
+
tablet: number;
|
|
251
|
+
}[] = [];
|
|
252
|
+
for (let i = 0; i < TREND_DAYS; i++) {
|
|
253
|
+
const weekend = i % 7 === 5 || i % 7 === 6;
|
|
254
|
+
const spike = noise(i, 61) > 0.88 ? 1 : 0;
|
|
255
|
+
// Slopes are expressed as total drift across the window, so the shape
|
|
256
|
+
// holds if TREND_DAYS changes.
|
|
257
|
+
out.push({
|
|
258
|
+
t: i,
|
|
259
|
+
label: `Day ${i + 1}`,
|
|
260
|
+
desktop: Math.round(
|
|
261
|
+
520 +
|
|
262
|
+
(i * 90) / TREND_DAYS +
|
|
263
|
+
(weekend ? -110 : 20) +
|
|
264
|
+
(noise(i, 11) - 0.5) * 80,
|
|
265
|
+
),
|
|
266
|
+
mobile: Math.round(
|
|
267
|
+
680 +
|
|
268
|
+
(i * 150) / TREND_DAYS +
|
|
269
|
+
(weekend ? 85 : -15) +
|
|
270
|
+
(noise(i, 23) - 0.5) * 125 +
|
|
271
|
+
spike * 130,
|
|
272
|
+
),
|
|
273
|
+
tablet: Math.round(
|
|
274
|
+
140 + (weekend ? 24 : -5) + (noise(i, 37) - 0.5) * 40 + spike * 18,
|
|
275
|
+
),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
})();
|
|
280
|
+
|
|
281
|
+
// Four evenly spaced labels (roughly month boundaries), derived so the axis
|
|
282
|
+
// stays readable if TREND_DAYS changes.
|
|
283
|
+
const ACTIVE_TICK_COUNT = 4;
|
|
284
|
+
const ACTIVE_TICKS = Array.from({length: ACTIVE_TICK_COUNT}, (_, i) =>
|
|
285
|
+
Math.round((i * (TREND_DAYS - 1)) / (ACTIVE_TICK_COUNT - 1)),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// ============= DEMOGRAPHICS =============
|
|
289
|
+
|
|
290
|
+
interface BreakdownSegment {
|
|
291
|
+
label: string;
|
|
292
|
+
value: number;
|
|
293
|
+
color: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const AGE_DATA: BreakdownSegment[] = [
|
|
297
|
+
{label: '18–24', value: 26, color: COLORS.blue},
|
|
298
|
+
{label: '25–34', value: 34, color: COLORS.teal},
|
|
299
|
+
{label: '35–44', value: 21, color: COLORS.green},
|
|
300
|
+
{label: '45–54', value: 12, color: COLORS.orange},
|
|
301
|
+
{label: '55+', value: 7, color: COLORS.neutral},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
const GENDER_DATA: BreakdownSegment[] = [
|
|
305
|
+
{label: 'Female', value: 48, color: COLORS.purple},
|
|
306
|
+
{label: 'Male', value: 46, color: COLORS.blue},
|
|
307
|
+
{label: 'Other / undisclosed', value: 6, color: COLORS.neutral},
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
const DEVICE_DATA: BreakdownSegment[] = [
|
|
311
|
+
{label: 'Mobile', value: 58, color: COLORS.blue},
|
|
312
|
+
{label: 'Desktop', value: 34, color: COLORS.teal},
|
|
313
|
+
{label: 'Tablet', value: 8, color: COLORS.orange},
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
interface AcqRow extends Record<string, unknown> {
|
|
317
|
+
id: string;
|
|
318
|
+
channel: string;
|
|
319
|
+
users: number;
|
|
320
|
+
share: number;
|
|
321
|
+
delta: number;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const ACQUISITION: AcqRow[] = [
|
|
325
|
+
{id: 'a1', channel: 'Organic search', users: 486200, share: 38, delta: 4.2},
|
|
326
|
+
{id: 'a2', channel: 'Direct', users: 295400, share: 23, delta: 1.1},
|
|
327
|
+
{id: 'a3', channel: 'Social', users: 217800, share: 17, delta: 8.6},
|
|
328
|
+
{id: 'a4', channel: 'Referral', users: 141900, share: 11, delta: -2.3},
|
|
329
|
+
{id: 'a5', channel: 'Email', users: 89200, share: 7, delta: 3.4},
|
|
330
|
+
{id: 'a6', channel: 'Paid ads', users: 51300, share: 4, delta: -1.8},
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
const ACQ_MAX = Math.max(...ACQUISITION.map(a => a.users));
|
|
334
|
+
|
|
335
|
+
// ============= FILTER OPTIONS =============
|
|
336
|
+
|
|
337
|
+
const RANGE_OPTIONS = [
|
|
338
|
+
{value: '7d', label: 'Last 7 days'},
|
|
339
|
+
{value: '30d', label: 'Last 30 days'},
|
|
340
|
+
{value: '90d', label: 'Last 90 days'},
|
|
341
|
+
];
|
|
342
|
+
const SEGMENT_OPTIONS = [
|
|
343
|
+
{value: 'all', label: 'All users'},
|
|
344
|
+
{value: 'new', label: 'New users'},
|
|
345
|
+
{value: 'returning', label: 'Returning users'},
|
|
346
|
+
];
|
|
347
|
+
const DEVICE_OPTIONS = [
|
|
348
|
+
{value: 'all', label: 'All devices'},
|
|
349
|
+
{value: 'mobile', label: 'Mobile'},
|
|
350
|
+
{value: 'desktop', label: 'Desktop'},
|
|
351
|
+
{value: 'tablet', label: 'Tablet'},
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
// ============= SHARED PIECES =============
|
|
355
|
+
|
|
356
|
+
// Icon's `color` prop only takes semantic names, but the legend swatch must
|
|
357
|
+
// match the exact chart series color — inline color required (same pattern as
|
|
358
|
+
// the shipped dashboard templates).
|
|
359
|
+
function LegendDot({color, label}: {color: string; label: string}) {
|
|
360
|
+
const dotStyle: CSSProperties = {color};
|
|
361
|
+
return (
|
|
362
|
+
<HStack gap={2} vAlign="center">
|
|
363
|
+
<Icon icon={StopIcon} size="xsm" style={dotStyle} />
|
|
364
|
+
<Text type="supporting" color="secondary">
|
|
365
|
+
{label}
|
|
366
|
+
</Text>
|
|
367
|
+
</HStack>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Locale is pinned: this is a client component, and letting the server and the
|
|
372
|
+
// browser pick different default locales produces a hydration mismatch (and
|
|
373
|
+
// breaks the deterministic previews this template promises).
|
|
374
|
+
function formatNumber(n: number): string {
|
|
375
|
+
return n.toLocaleString('en-US');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ============= METRIC MINI-CARD (with sparkline + 4 deltas) =============
|
|
379
|
+
|
|
380
|
+
// Direction is carried by the arrow next to the metric value; the delta
|
|
381
|
+
// figures themselves stay neutral so a card with four of them doesn't turn
|
|
382
|
+
// into a wall of red and green.
|
|
383
|
+
function DeltaValue({label, value}: {label: string; value: number}) {
|
|
384
|
+
const flat = value === 0;
|
|
385
|
+
const sign = value > 0 ? '+' : '';
|
|
386
|
+
return (
|
|
387
|
+
<Text type="body" weight="bold" color="primary" hasTabularNumbers>
|
|
388
|
+
{flat ? '—' : `${sign}${value.toFixed(1)}%`}{' '}
|
|
389
|
+
<Text type="inherit" weight="normal" color="secondary">
|
|
390
|
+
{label}
|
|
391
|
+
</Text>
|
|
392
|
+
</Text>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// The sparkline restates the trend the value and arrow already convey, so it
|
|
397
|
+
// is decorative: hidden from assistive tech rather than announced as an
|
|
398
|
+
// unlabelled chart.
|
|
399
|
+
function MiniSparkline({data, color}: {data: SparkPoint[]; color: string}) {
|
|
400
|
+
return (
|
|
401
|
+
<div aria-hidden="true">
|
|
402
|
+
<ResponsiveContainer width="100%" height={48}>
|
|
403
|
+
<LineChart data={data} margin={{top: 4, right: 0, left: 0, bottom: 0}}>
|
|
404
|
+
<Line
|
|
405
|
+
type="linear"
|
|
406
|
+
dataKey="v"
|
|
407
|
+
stroke={color}
|
|
408
|
+
strokeWidth={1.25}
|
|
409
|
+
strokeLinecap="butt"
|
|
410
|
+
strokeLinejoin="miter"
|
|
411
|
+
dot={false}
|
|
412
|
+
isAnimationActive={false}
|
|
413
|
+
/>
|
|
414
|
+
</LineChart>
|
|
415
|
+
</ResponsiveContainer>
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Deltas sit in a 2x2 grid rather than a wrapping row so the four figures line
|
|
421
|
+
// up across every card regardless of how wide the number renders.
|
|
422
|
+
function MetricCard({metric}: {metric: Metric}) {
|
|
423
|
+
const trendUp = metric.deltas.ww >= 0;
|
|
424
|
+
const favorable = metric.higherIsBetter ? trendUp : !trendUp;
|
|
425
|
+
const sparkColor = favorable ? COLORS.green : COLORS.orange;
|
|
426
|
+
return (
|
|
427
|
+
<Card padding={4}>
|
|
428
|
+
<VStack gap={4}>
|
|
429
|
+
<VStack gap={1}>
|
|
430
|
+
<Text type="label" color="secondary">
|
|
431
|
+
{metric.label}
|
|
432
|
+
</Text>
|
|
433
|
+
<HStack gap={2} vAlign="center">
|
|
434
|
+
{/* Sized as the card's hero number, but demoted in the document
|
|
435
|
+
outline: these sit under the section's h2, not beside the
|
|
436
|
+
page's h1. */}
|
|
437
|
+
<Heading level={1} accessibilityLevel={3}>
|
|
438
|
+
{metric.value}
|
|
439
|
+
</Heading>
|
|
440
|
+
<Icon
|
|
441
|
+
icon={trendUp ? ArrowUpIcon : ArrowDownIcon}
|
|
442
|
+
size="sm"
|
|
443
|
+
color={favorable ? 'success' : 'error'}
|
|
444
|
+
/>
|
|
445
|
+
</HStack>
|
|
446
|
+
</VStack>
|
|
447
|
+
<MiniSparkline data={metric.spark} color={sparkColor} />
|
|
448
|
+
<Grid columns={2} gap={2}>
|
|
449
|
+
<DeltaValue label="d/d" value={metric.deltas.dd} />
|
|
450
|
+
<DeltaValue label="w/w" value={metric.deltas.ww} />
|
|
451
|
+
<DeltaValue label="m/m" value={metric.deltas.mm} />
|
|
452
|
+
<DeltaValue label="y/y" value={metric.deltas.yy} />
|
|
453
|
+
</Grid>
|
|
454
|
+
</VStack>
|
|
455
|
+
</Card>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Grid's `columns` prop carries a single minWidth, so the roomier track floor
|
|
460
|
+
// for wide viewports rides in as an xstyle override: Grid routes its track
|
|
461
|
+
// template through a CSS var and applies `xstyle` last, so this wins. Below
|
|
462
|
+
// the breakpoint the value is left unset and the `columns` prop stands.
|
|
463
|
+
const METRICS_WIDE = '@media (min-width: 900px)';
|
|
464
|
+
|
|
465
|
+
const metricsGridStyles = stylex.create({
|
|
466
|
+
grid: {
|
|
467
|
+
gridTemplateColumns: {
|
|
468
|
+
default: null,
|
|
469
|
+
[METRICS_WIDE]: 'repeat(auto-fit, minmax(320px, 1fr))',
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// ============= ACTIVE USERS TREND =============
|
|
475
|
+
|
|
476
|
+
interface TrendTooltipEntry {
|
|
477
|
+
name: string;
|
|
478
|
+
value: number;
|
|
479
|
+
color: string;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function ActiveTrendTooltip({
|
|
483
|
+
active,
|
|
484
|
+
payload,
|
|
485
|
+
label,
|
|
486
|
+
}: {
|
|
487
|
+
active?: boolean;
|
|
488
|
+
payload?: TrendTooltipEntry[];
|
|
489
|
+
label?: number;
|
|
490
|
+
}) {
|
|
491
|
+
if (!active || !payload?.length) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const day =
|
|
495
|
+
typeof label === 'number' ? (ACTIVE_TREND[label]?.label ?? '') : '';
|
|
496
|
+
return (
|
|
497
|
+
<Card padding={3}>
|
|
498
|
+
<VStack gap={1}>
|
|
499
|
+
<Text type="supporting" color="secondary">
|
|
500
|
+
{day}
|
|
501
|
+
</Text>
|
|
502
|
+
{payload.map(entry => (
|
|
503
|
+
<LegendDot
|
|
504
|
+
key={entry.name}
|
|
505
|
+
color={entry.color}
|
|
506
|
+
label={`${entry.name}: ${formatNumber(entry.value)}K`}
|
|
507
|
+
/>
|
|
508
|
+
))}
|
|
509
|
+
</VStack>
|
|
510
|
+
</Card>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function ActiveUsersTrend() {
|
|
515
|
+
return (
|
|
516
|
+
<VStack gap={3}>
|
|
517
|
+
<ResponsiveContainer width="100%" height={280}>
|
|
518
|
+
<AreaChart
|
|
519
|
+
data={ACTIVE_TREND}
|
|
520
|
+
margin={{top: 5, right: 12, left: 0, bottom: 5}}>
|
|
521
|
+
<defs>
|
|
522
|
+
<linearGradient id="au-mobile" x1="0" y1="0" x2="0" y2="1">
|
|
523
|
+
<stop offset="5%" stopColor={COLORS.blue} stopOpacity={0.3} />
|
|
524
|
+
<stop offset="95%" stopColor={COLORS.blue} stopOpacity={0.04} />
|
|
525
|
+
</linearGradient>
|
|
526
|
+
<linearGradient id="au-desktop" x1="0" y1="0" x2="0" y2="1">
|
|
527
|
+
<stop offset="5%" stopColor={COLORS.teal} stopOpacity={0.3} />
|
|
528
|
+
<stop offset="95%" stopColor={COLORS.teal} stopOpacity={0.04} />
|
|
529
|
+
</linearGradient>
|
|
530
|
+
</defs>
|
|
531
|
+
<CartesianGrid horizontal vertical={false} stroke={GRID_STROKE} />
|
|
532
|
+
<XAxis
|
|
533
|
+
dataKey="t"
|
|
534
|
+
type="number"
|
|
535
|
+
domain={[0, TREND_DAYS - 1]}
|
|
536
|
+
ticks={ACTIVE_TICKS}
|
|
537
|
+
tickFormatter={(v: number) => ACTIVE_TREND[v]?.label ?? ''}
|
|
538
|
+
tick={AXIS_TICK}
|
|
539
|
+
axisLine={false}
|
|
540
|
+
tickLine={false}
|
|
541
|
+
/>
|
|
542
|
+
<YAxis
|
|
543
|
+
tick={AXIS_TICK}
|
|
544
|
+
axisLine={false}
|
|
545
|
+
tickLine={false}
|
|
546
|
+
width={40}
|
|
547
|
+
unit="K"
|
|
548
|
+
/>
|
|
549
|
+
<Tooltip
|
|
550
|
+
content={<ActiveTrendTooltip />}
|
|
551
|
+
cursor={{stroke: GRID_STROKE}}
|
|
552
|
+
/>
|
|
553
|
+
<Area
|
|
554
|
+
type="linear"
|
|
555
|
+
dataKey="mobile"
|
|
556
|
+
name="Mobile"
|
|
557
|
+
stroke={COLORS.blue}
|
|
558
|
+
strokeWidth={1.5}
|
|
559
|
+
strokeLinejoin="miter"
|
|
560
|
+
fill="url(#au-mobile)"
|
|
561
|
+
dot={false}
|
|
562
|
+
isAnimationActive={false}
|
|
563
|
+
/>
|
|
564
|
+
<Area
|
|
565
|
+
type="linear"
|
|
566
|
+
dataKey="desktop"
|
|
567
|
+
name="Desktop"
|
|
568
|
+
stroke={COLORS.teal}
|
|
569
|
+
strokeWidth={1.5}
|
|
570
|
+
strokeLinejoin="miter"
|
|
571
|
+
fill="url(#au-desktop)"
|
|
572
|
+
dot={false}
|
|
573
|
+
isAnimationActive={false}
|
|
574
|
+
/>
|
|
575
|
+
<Area
|
|
576
|
+
type="linear"
|
|
577
|
+
dataKey="tablet"
|
|
578
|
+
name="Tablet"
|
|
579
|
+
stroke={COLORS.orange}
|
|
580
|
+
strokeWidth={1.5}
|
|
581
|
+
strokeLinejoin="miter"
|
|
582
|
+
fill="none"
|
|
583
|
+
dot={false}
|
|
584
|
+
isAnimationActive={false}
|
|
585
|
+
/>
|
|
586
|
+
</AreaChart>
|
|
587
|
+
</ResponsiveContainer>
|
|
588
|
+
<HStack gap={5} vAlign="center" wrap="wrap">
|
|
589
|
+
<LegendDot color={COLORS.blue} label="Mobile" />
|
|
590
|
+
<LegendDot color={COLORS.teal} label="Desktop" />
|
|
591
|
+
<LegendDot color={COLORS.orange} label="Tablet" />
|
|
592
|
+
</HStack>
|
|
593
|
+
</VStack>
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ============= BREAKDOWN (horizontal stacked bar) =============
|
|
598
|
+
|
|
599
|
+
function BreakdownCard({
|
|
600
|
+
title,
|
|
601
|
+
data,
|
|
602
|
+
}: {
|
|
603
|
+
title: string;
|
|
604
|
+
data: BreakdownSegment[];
|
|
605
|
+
}) {
|
|
606
|
+
const total = data.reduce((s, d) => s + d.value, 0);
|
|
607
|
+
const chartData = [Object.fromEntries(data.map(d => [d.label, d.value]))];
|
|
608
|
+
return (
|
|
609
|
+
<Card>
|
|
610
|
+
<VStack gap={4}>
|
|
611
|
+
{/* Card-title sizing, but h3 in the outline — these sit under the
|
|
612
|
+
"Audience demographics" h2, so level 4 would skip a rank. */}
|
|
613
|
+
<Heading level={4} accessibilityLevel={3}>
|
|
614
|
+
{title}
|
|
615
|
+
</Heading>
|
|
616
|
+
<ResponsiveContainer width="100%" height={24}>
|
|
617
|
+
<BarChart
|
|
618
|
+
data={chartData}
|
|
619
|
+
layout="vertical"
|
|
620
|
+
margin={{top: 0, right: 0, bottom: 0, left: 0}}
|
|
621
|
+
barCategoryGap={0}>
|
|
622
|
+
<XAxis type="number" hide domain={[0, total]} />
|
|
623
|
+
<YAxis type="category" hide />
|
|
624
|
+
{data.map((d, i) => (
|
|
625
|
+
<Bar
|
|
626
|
+
key={d.label}
|
|
627
|
+
dataKey={d.label}
|
|
628
|
+
stackId="s"
|
|
629
|
+
fill={d.color}
|
|
630
|
+
isAnimationActive={false}
|
|
631
|
+
radius={
|
|
632
|
+
i === 0
|
|
633
|
+
? [4, 0, 0, 4]
|
|
634
|
+
: i === data.length - 1
|
|
635
|
+
? [0, 4, 4, 0]
|
|
636
|
+
: [0, 0, 0, 0]
|
|
637
|
+
}
|
|
638
|
+
/>
|
|
639
|
+
))}
|
|
640
|
+
</BarChart>
|
|
641
|
+
</ResponsiveContainer>
|
|
642
|
+
<VStack gap={2}>
|
|
643
|
+
{data.map(d => (
|
|
644
|
+
<HStack key={d.label} hAlign="between" vAlign="center">
|
|
645
|
+
<LegendDot color={d.color} label={d.label} />
|
|
646
|
+
<Text type="supporting" weight="semibold" hasTabularNumbers>
|
|
647
|
+
{d.value}%
|
|
648
|
+
</Text>
|
|
649
|
+
</HStack>
|
|
650
|
+
))}
|
|
651
|
+
</VStack>
|
|
652
|
+
</VStack>
|
|
653
|
+
</Card>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ============= TABLES =============
|
|
658
|
+
|
|
659
|
+
function DeltaBadge({value}: {value: number}) {
|
|
660
|
+
const flat = value === 0;
|
|
661
|
+
return (
|
|
662
|
+
<Badge
|
|
663
|
+
variant={flat ? 'neutral' : value > 0 ? 'green' : 'red'}
|
|
664
|
+
label={`${value > 0 ? '+' : ''}${value.toFixed(1)}%`}
|
|
665
|
+
/>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const acquisitionColumns: TableColumn<AcqRow>[] = [
|
|
670
|
+
{
|
|
671
|
+
key: 'channel',
|
|
672
|
+
header: 'Channel',
|
|
673
|
+
width: pixel(160),
|
|
674
|
+
renderCell: (item: AcqRow) => (
|
|
675
|
+
<Text type="body" weight="semibold">
|
|
676
|
+
{item.channel}
|
|
677
|
+
</Text>
|
|
678
|
+
),
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
key: 'users',
|
|
682
|
+
header: 'Users',
|
|
683
|
+
width: proportional(1),
|
|
684
|
+
renderCell: (item: AcqRow) => (
|
|
685
|
+
<VStack gap={1}>
|
|
686
|
+
<ProgressBar
|
|
687
|
+
value={item.users}
|
|
688
|
+
max={ACQ_MAX}
|
|
689
|
+
label={`${item.channel} users`}
|
|
690
|
+
isLabelHidden
|
|
691
|
+
/>
|
|
692
|
+
<Text type="supporting" color="secondary" hasTabularNumbers>
|
|
693
|
+
{formatNumber(item.users)}
|
|
694
|
+
</Text>
|
|
695
|
+
</VStack>
|
|
696
|
+
),
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
key: 'share',
|
|
700
|
+
header: 'Share',
|
|
701
|
+
width: pixel(90),
|
|
702
|
+
renderCell: (item: AcqRow) => (
|
|
703
|
+
<Text type="body" hasTabularNumbers>
|
|
704
|
+
{item.share}%
|
|
705
|
+
</Text>
|
|
706
|
+
),
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
key: 'delta',
|
|
710
|
+
header: 'w/w',
|
|
711
|
+
width: pixel(100),
|
|
712
|
+
renderCell: (item: AcqRow) => <DeltaBadge value={item.delta} />,
|
|
713
|
+
},
|
|
714
|
+
];
|
|
715
|
+
|
|
716
|
+
// ============= SECTION HEADING =============
|
|
717
|
+
|
|
718
|
+
// AspectRatio takes its width from the container, and as a flex child its own
|
|
719
|
+
// `width: 100%` would resolve against the whole header row. Pinning a width
|
|
720
|
+
// here gives it the definite box it needs and makes the tile a 32px square.
|
|
721
|
+
const sectionIconStyles = stylex.create({
|
|
722
|
+
tile: {
|
|
723
|
+
width: 'var(--spacing-8)',
|
|
724
|
+
backgroundColor: 'var(--color-background-muted)',
|
|
725
|
+
borderRadius: 'var(--radius-element)',
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
function SectionHeading({
|
|
730
|
+
title,
|
|
731
|
+
hint,
|
|
732
|
+
icon,
|
|
733
|
+
}: {
|
|
734
|
+
title: string;
|
|
735
|
+
hint?: string;
|
|
736
|
+
icon?: typeof UsersIcon;
|
|
737
|
+
}) {
|
|
738
|
+
return (
|
|
739
|
+
<HStack hAlign="between" vAlign="center" gap={3}>
|
|
740
|
+
<HStack gap={3} vAlign="center">
|
|
741
|
+
{icon ? (
|
|
742
|
+
<AspectRatio ratio={1} fit="center" xstyle={sectionIconStyles.tile}>
|
|
743
|
+
<Icon icon={icon} size="sm" color="secondary" />
|
|
744
|
+
</AspectRatio>
|
|
745
|
+
) : null}
|
|
746
|
+
<Heading level={2}>{title}</Heading>
|
|
747
|
+
</HStack>
|
|
748
|
+
{hint ? (
|
|
749
|
+
<Text type="supporting" color="secondary">
|
|
750
|
+
{hint}
|
|
751
|
+
</Text>
|
|
752
|
+
) : null}
|
|
753
|
+
</HStack>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ============= MAIN =============
|
|
758
|
+
|
|
759
|
+
// The filters are presentational: the fixtures are a fixed snapshot, so the
|
|
760
|
+
// controls annotate the view rather than re-slicing the data. Swapping the
|
|
761
|
+
// fixtures for a query is the seam a consumer replaces.
|
|
762
|
+
function labelFor(
|
|
763
|
+
options: readonly {value: string; label: string}[],
|
|
764
|
+
value: string,
|
|
765
|
+
): string {
|
|
766
|
+
return options.find(o => o.value === value)?.label ?? '';
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export default function DataDashboardPage() {
|
|
770
|
+
// Matches TREND_DAYS — the fixtures render a full quarter.
|
|
771
|
+
const [range, setRange] = useState<DateRange>('90d');
|
|
772
|
+
const [segment, setSegment] = useState<Segment>('all');
|
|
773
|
+
const [device, setDevice] = useState<DeviceFilter>('all');
|
|
774
|
+
|
|
775
|
+
const summary = [
|
|
776
|
+
labelFor(SEGMENT_OPTIONS, segment),
|
|
777
|
+
labelFor(DEVICE_OPTIONS, device),
|
|
778
|
+
labelFor(RANGE_OPTIONS, range),
|
|
779
|
+
].join(' · ');
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<Layout
|
|
783
|
+
height="fill"
|
|
784
|
+
contentWidth={1440}
|
|
785
|
+
header={
|
|
786
|
+
<LayoutHeader hasDivider>
|
|
787
|
+
<HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
|
|
788
|
+
<VStack gap={0}>
|
|
789
|
+
<Heading level={1}>Data Dashboard</Heading>
|
|
790
|
+
<Text type="supporting" color="secondary">
|
|
791
|
+
User analytics overview · {summary}
|
|
792
|
+
</Text>
|
|
793
|
+
</VStack>
|
|
794
|
+
<HStack gap={1}>
|
|
795
|
+
<Selector
|
|
796
|
+
label="Date range"
|
|
797
|
+
isLabelHidden
|
|
798
|
+
options={RANGE_OPTIONS}
|
|
799
|
+
value={range}
|
|
800
|
+
onChange={value => setRange(value as DateRange)}
|
|
801
|
+
/>
|
|
802
|
+
<Selector
|
|
803
|
+
label="Segment"
|
|
804
|
+
isLabelHidden
|
|
805
|
+
options={SEGMENT_OPTIONS}
|
|
806
|
+
value={segment}
|
|
807
|
+
onChange={value => setSegment(value as Segment)}
|
|
808
|
+
/>
|
|
809
|
+
<Selector
|
|
810
|
+
label="Device"
|
|
811
|
+
isLabelHidden
|
|
812
|
+
options={DEVICE_OPTIONS}
|
|
813
|
+
value={device}
|
|
814
|
+
onChange={value => setDevice(value as DeviceFilter)}
|
|
815
|
+
/>
|
|
816
|
+
<Button
|
|
817
|
+
label="Export"
|
|
818
|
+
variant="secondary"
|
|
819
|
+
icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
|
|
820
|
+
/>
|
|
821
|
+
</HStack>
|
|
822
|
+
</HStack>
|
|
823
|
+
</LayoutHeader>
|
|
824
|
+
}
|
|
825
|
+
content={
|
|
826
|
+
<LayoutContent padding={6}>
|
|
827
|
+
<VStack gap={10}>
|
|
828
|
+
<VStack gap={6}>
|
|
829
|
+
<SectionHeading
|
|
830
|
+
title="Key metrics"
|
|
831
|
+
hint="Change vs. prior day, week, month, and year"
|
|
832
|
+
icon={UsersIcon}
|
|
833
|
+
/>
|
|
834
|
+
<Grid
|
|
835
|
+
columns={{minWidth: 240, repeat: 'fit'}}
|
|
836
|
+
gap={3}
|
|
837
|
+
xstyle={metricsGridStyles.grid}>
|
|
838
|
+
{OVERVIEW_METRICS.map(metric => (
|
|
839
|
+
<MetricCard key={metric.key} metric={metric} />
|
|
840
|
+
))}
|
|
841
|
+
</Grid>
|
|
842
|
+
</VStack>
|
|
843
|
+
|
|
844
|
+
<VStack gap={6}>
|
|
845
|
+
<SectionHeading
|
|
846
|
+
title="Devices"
|
|
847
|
+
hint="Daily active users (thousands)"
|
|
848
|
+
icon={CursorArrowRaysIcon}
|
|
849
|
+
/>
|
|
850
|
+
<Card padding={6}>
|
|
851
|
+
<VStack gap={6}>
|
|
852
|
+
<Text type="label">Daily active users by device type</Text>
|
|
853
|
+
<ActiveUsersTrend />
|
|
854
|
+
</VStack>
|
|
855
|
+
</Card>
|
|
856
|
+
</VStack>
|
|
857
|
+
|
|
858
|
+
<Divider />
|
|
859
|
+
|
|
860
|
+
<VStack gap={6}>
|
|
861
|
+
<SectionHeading
|
|
862
|
+
title="Audience demographics"
|
|
863
|
+
hint="Share of active users"
|
|
864
|
+
icon={UsersIcon}
|
|
865
|
+
/>
|
|
866
|
+
<Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
|
|
867
|
+
<BreakdownCard title="Age" data={AGE_DATA} />
|
|
868
|
+
<BreakdownCard title="Gender" data={GENDER_DATA} />
|
|
869
|
+
<BreakdownCard title="Device" data={DEVICE_DATA} />
|
|
870
|
+
</Grid>
|
|
871
|
+
</VStack>
|
|
872
|
+
|
|
873
|
+
<VStack gap={6}>
|
|
874
|
+
<SectionHeading
|
|
875
|
+
title="Acquisition channels"
|
|
876
|
+
hint="How users find the product"
|
|
877
|
+
/>
|
|
878
|
+
<Card>
|
|
879
|
+
<Table<AcqRow>
|
|
880
|
+
data={ACQUISITION}
|
|
881
|
+
columns={acquisitionColumns}
|
|
882
|
+
idKey="id"
|
|
883
|
+
density="balanced"
|
|
884
|
+
dividers="rows"
|
|
885
|
+
hasHover
|
|
886
|
+
/>
|
|
887
|
+
</Card>
|
|
888
|
+
</VStack>
|
|
889
|
+
</VStack>
|
|
890
|
+
</LayoutContent>
|
|
891
|
+
}
|
|
892
|
+
/>
|
|
893
|
+
);
|
|
894
|
+
}
|