@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,1139 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Project Status Dashboard — a program / launch status tracker.
|
|
7
|
+
*
|
|
8
|
+
* Content-only (root `Layout`); the host supplies the app shell. Widgets stack
|
|
9
|
+
* down a content column capped at 1440px:
|
|
10
|
+
*
|
|
11
|
+
* page header (title, program status, phase filter)
|
|
12
|
+
* | milestone Gantt with a "today" marker
|
|
13
|
+
* | progress donut + scope burndown + blockers & risks, an equal-width
|
|
14
|
+
* three-up that steps down to two columns, then one
|
|
15
|
+
* | workstream table
|
|
16
|
+
*
|
|
17
|
+
* The release-phase control (All / Build / Beta / GA) filters milestones,
|
|
18
|
+
* workstreams and risks, each of which falls back to an empty state. Donut
|
|
19
|
+
* arcs are weighted by story points rather than task count. All data is
|
|
20
|
+
* deterministic (fixed fixtures, no clocks/random) so previews stay stable.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {useMemo, useState, type CSSProperties} from 'react';
|
|
24
|
+
import * as stylex from '@stylexjs/stylex';
|
|
25
|
+
import {
|
|
26
|
+
VStack,
|
|
27
|
+
HStack,
|
|
28
|
+
Layout,
|
|
29
|
+
LayoutContent,
|
|
30
|
+
LayoutHeader,
|
|
31
|
+
} from '@astryxdesign/core/Layout';
|
|
32
|
+
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
33
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
34
|
+
import {Grid} from '@astryxdesign/core/Grid';
|
|
35
|
+
import {Section} from '@astryxdesign/core/Section';
|
|
36
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
37
|
+
import {Icon} from '@astryxdesign/core/Icon';
|
|
38
|
+
import type {IconType} from '@astryxdesign/core/Icon';
|
|
39
|
+
import {Badge} from '@astryxdesign/core/Badge';
|
|
40
|
+
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
|
41
|
+
import {Avatar} from '@astryxdesign/core/Avatar';
|
|
42
|
+
import {List, ListItem} from '@astryxdesign/core/List';
|
|
43
|
+
import {useHoverCard} from '@astryxdesign/core/HoverCard';
|
|
44
|
+
import {
|
|
45
|
+
SegmentedControl,
|
|
46
|
+
SegmentedControlItem,
|
|
47
|
+
} from '@astryxdesign/core/SegmentedControl';
|
|
48
|
+
import {Table, proportional} from '@astryxdesign/core/Table';
|
|
49
|
+
import type {TableColumn} from '@astryxdesign/core/Table';
|
|
50
|
+
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
|
51
|
+
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
|
52
|
+
import {
|
|
53
|
+
BarChart,
|
|
54
|
+
Bar,
|
|
55
|
+
Cell,
|
|
56
|
+
PieChart,
|
|
57
|
+
Pie,
|
|
58
|
+
ReferenceLine,
|
|
59
|
+
LineChart,
|
|
60
|
+
Line,
|
|
61
|
+
XAxis,
|
|
62
|
+
YAxis,
|
|
63
|
+
CartesianGrid,
|
|
64
|
+
Tooltip,
|
|
65
|
+
ResponsiveContainer,
|
|
66
|
+
} from 'recharts';
|
|
67
|
+
import {
|
|
68
|
+
CheckCircleIcon,
|
|
69
|
+
ExclamationTriangleIcon,
|
|
70
|
+
ArrowDownTrayIcon,
|
|
71
|
+
FlagIcon,
|
|
72
|
+
} from '@heroicons/react/24/outline';
|
|
73
|
+
import {StopIcon} from '@heroicons/react/24/solid';
|
|
74
|
+
|
|
75
|
+
// ============= TYPES =============
|
|
76
|
+
|
|
77
|
+
type Rag = 'onTrack' | 'atRisk' | 'blocked';
|
|
78
|
+
type Phase = 'all' | 'build' | 'beta' | 'ga';
|
|
79
|
+
type ReleasePhase = Exclude<Phase, 'all'>;
|
|
80
|
+
|
|
81
|
+
interface Milestone {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
phase: ReleasePhase;
|
|
85
|
+
// Day offsets on the shared TIMELINE_DAYS scale (project day 0 = kickoff).
|
|
86
|
+
start: number;
|
|
87
|
+
end: number;
|
|
88
|
+
status: Rag;
|
|
89
|
+
dateLabel: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Workstream extends Record<string, unknown> {
|
|
93
|
+
id: string;
|
|
94
|
+
name: string;
|
|
95
|
+
owner: string;
|
|
96
|
+
phase: ReleasePhase;
|
|
97
|
+
percent: number;
|
|
98
|
+
due: string;
|
|
99
|
+
status: Rag;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Risk {
|
|
103
|
+
id: string;
|
|
104
|
+
title: string;
|
|
105
|
+
workstream: string;
|
|
106
|
+
phase: ReleasePhase;
|
|
107
|
+
severity: Rag;
|
|
108
|
+
owner: string;
|
|
109
|
+
detail: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface TaskStatusSegment {
|
|
113
|
+
key: string;
|
|
114
|
+
label: string;
|
|
115
|
+
tasks: number;
|
|
116
|
+
// Effort, not head count: `points` is what sizes the donut arc.
|
|
117
|
+
points: number;
|
|
118
|
+
color: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============= LABEL & TONE MAPPING =============
|
|
122
|
+
|
|
123
|
+
const PHASE_LABEL: Record<ReleasePhase, string> = {
|
|
124
|
+
build: 'Build',
|
|
125
|
+
beta: 'Beta',
|
|
126
|
+
ga: 'GA',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const RAG_LABEL: Record<Rag, string> = {
|
|
130
|
+
onTrack: 'On track',
|
|
131
|
+
atRisk: 'At risk',
|
|
132
|
+
blocked: 'Blocked',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const RAG_BADGE: Record<Rag, 'green' | 'yellow' | 'red'> = {
|
|
136
|
+
onTrack: 'green',
|
|
137
|
+
atRisk: 'yellow',
|
|
138
|
+
blocked: 'red',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Leading icon for the program-status badge in the page header, the one badge
|
|
142
|
+
// loud enough to earn one.
|
|
143
|
+
const RAG_ICON: Record<Rag, IconType> = {
|
|
144
|
+
onTrack: CheckCircleIcon,
|
|
145
|
+
atRisk: ExclamationTriangleIcon,
|
|
146
|
+
blocked: ExclamationTriangleIcon,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Chart fills for RAG status: the data-viz semantic anchors, not the UI status
|
|
150
|
+
// tokens (--color-success/warning/error), so a milestone bar and a "blocked"
|
|
151
|
+
// donut slice land on the same red.
|
|
152
|
+
const RAG_FILL: Record<Rag, string> = {
|
|
153
|
+
onTrack: 'var(--color-data-categorical-green, #0B991F)',
|
|
154
|
+
atRisk: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
155
|
+
blocked: 'var(--color-data-categorical-red, #F5394F)',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ============= CHART CHROME (design tokens w/ hex fallbacks) =============
|
|
159
|
+
|
|
160
|
+
// The same slots `getChartColors()` resolves in @astryxdesign/charts, as CSS
|
|
161
|
+
// vars rather than the hook because page templates depend only on core +
|
|
162
|
+
// recharts. CHART_NEUTRAL is the reference-line / not-started tone.
|
|
163
|
+
const CHART_GRID = 'var(--color-border, rgba(5, 54, 89, 0.1))';
|
|
164
|
+
const CHART_LABEL = 'var(--color-text-secondary, #4E606F)';
|
|
165
|
+
const CHART_NEUTRAL = 'var(--color-data-neutral, #8494A3)';
|
|
166
|
+
|
|
167
|
+
const IDEAL_COLOR = CHART_NEUTRAL;
|
|
168
|
+
const ACTUAL_COLOR = 'var(--color-data-categorical-blue, #0171E3)';
|
|
169
|
+
const AXIS_TICK = {
|
|
170
|
+
fontSize: 'var(--font-size-sm, 12px)',
|
|
171
|
+
fill: CHART_LABEL,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ============= TIMELINE SCALE =============
|
|
175
|
+
|
|
176
|
+
const TIMELINE_DAYS = 90;
|
|
177
|
+
const TIMELINE_TICK_STEP = 15;
|
|
178
|
+
const TIMELINE_TICKS = Array.from(
|
|
179
|
+
{length: TIMELINE_DAYS / TIMELINE_TICK_STEP + 1},
|
|
180
|
+
(_, i) => i * TIMELINE_TICK_STEP,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// "Today" marker, as a day offset on the same scale.
|
|
184
|
+
const PROJECT_DAY = 58;
|
|
185
|
+
|
|
186
|
+
// ============= DATA =============
|
|
187
|
+
|
|
188
|
+
// Task backlog by status. Tasks are not equally sized, so the fixture makes
|
|
189
|
+
// count and effort diverge deliberately; points sum to the 320 the burndown
|
|
190
|
+
// starts from. Statuses are categories rather than a scale, so the fills come
|
|
191
|
+
// from the categorical palette, reusing the Gantt's anchors so one status means
|
|
192
|
+
// one color everywhere on the page.
|
|
193
|
+
const TASK_STATUS: TaskStatusSegment[] = [
|
|
194
|
+
{
|
|
195
|
+
key: 'closed',
|
|
196
|
+
label: 'Closed',
|
|
197
|
+
tasks: 86,
|
|
198
|
+
points: 190,
|
|
199
|
+
color: 'var(--color-data-categorical-blue, #0171E3)',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
key: 'inProgress',
|
|
203
|
+
label: 'In progress',
|
|
204
|
+
tasks: 24,
|
|
205
|
+
points: 58,
|
|
206
|
+
color: 'var(--color-data-categorical-green, #0B991F)',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
key: 'inReview',
|
|
210
|
+
label: 'In review',
|
|
211
|
+
tasks: 12,
|
|
212
|
+
points: 24,
|
|
213
|
+
color: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
key: 'blocked',
|
|
217
|
+
label: 'Blocked',
|
|
218
|
+
tasks: 5,
|
|
219
|
+
points: 22,
|
|
220
|
+
color: 'var(--color-data-categorical-red, #F5394F)',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
key: 'planned',
|
|
224
|
+
label: 'Planned',
|
|
225
|
+
tasks: 21,
|
|
226
|
+
points: 26,
|
|
227
|
+
color: CHART_NEUTRAL,
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const MILESTONES: Milestone[] = [
|
|
232
|
+
{
|
|
233
|
+
id: 'm1',
|
|
234
|
+
name: 'Kickoff & scoping',
|
|
235
|
+
phase: 'build',
|
|
236
|
+
start: 0,
|
|
237
|
+
end: 8,
|
|
238
|
+
status: 'onTrack',
|
|
239
|
+
dateLabel: 'Apr 1 – Apr 9',
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: 'm2',
|
|
243
|
+
name: 'Architecture sign-off',
|
|
244
|
+
phase: 'build',
|
|
245
|
+
start: 8,
|
|
246
|
+
end: 20,
|
|
247
|
+
status: 'onTrack',
|
|
248
|
+
dateLabel: 'Apr 9 – Apr 21',
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: 'm3',
|
|
252
|
+
name: 'Feature complete',
|
|
253
|
+
phase: 'build',
|
|
254
|
+
start: 20,
|
|
255
|
+
end: 44,
|
|
256
|
+
status: 'atRisk',
|
|
257
|
+
dateLabel: 'Apr 21 – May 15',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'm4',
|
|
261
|
+
name: 'Private beta',
|
|
262
|
+
phase: 'beta',
|
|
263
|
+
start: 44,
|
|
264
|
+
end: 62,
|
|
265
|
+
status: 'atRisk',
|
|
266
|
+
dateLabel: 'May 15 – Jun 2',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: 'm5',
|
|
270
|
+
name: 'Bug bash & hardening',
|
|
271
|
+
phase: 'beta',
|
|
272
|
+
start: 58,
|
|
273
|
+
end: 72,
|
|
274
|
+
status: 'blocked',
|
|
275
|
+
dateLabel: 'May 29 – Jun 12',
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'm6',
|
|
279
|
+
name: 'GA readiness review',
|
|
280
|
+
phase: 'ga',
|
|
281
|
+
start: 72,
|
|
282
|
+
end: 82,
|
|
283
|
+
status: 'onTrack',
|
|
284
|
+
dateLabel: 'Jun 12 – Jun 22',
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: 'm7',
|
|
288
|
+
name: 'Public launch',
|
|
289
|
+
phase: 'ga',
|
|
290
|
+
start: 82,
|
|
291
|
+
end: 90,
|
|
292
|
+
status: 'onTrack',
|
|
293
|
+
dateLabel: 'Jun 22 – Jun 30',
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const WORKSTREAMS: Workstream[] = [
|
|
298
|
+
{
|
|
299
|
+
id: 'w1',
|
|
300
|
+
name: 'Core platform',
|
|
301
|
+
owner: 'Priya Raman',
|
|
302
|
+
phase: 'build',
|
|
303
|
+
percent: 100,
|
|
304
|
+
due: '2026-04-21',
|
|
305
|
+
status: 'onTrack',
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: 'w2',
|
|
309
|
+
name: 'Data pipeline',
|
|
310
|
+
owner: 'Marcus Webb',
|
|
311
|
+
phase: 'build',
|
|
312
|
+
percent: 92,
|
|
313
|
+
due: '2026-05-15',
|
|
314
|
+
status: 'atRisk',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: 'w3',
|
|
318
|
+
name: 'Web client',
|
|
319
|
+
owner: 'Ana Duarte',
|
|
320
|
+
phase: 'build',
|
|
321
|
+
percent: 88,
|
|
322
|
+
due: '2026-05-15',
|
|
323
|
+
status: 'onTrack',
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: 'w4',
|
|
327
|
+
name: 'Beta onboarding',
|
|
328
|
+
owner: 'Tom Okafor',
|
|
329
|
+
phase: 'beta',
|
|
330
|
+
percent: 64,
|
|
331
|
+
due: '2026-06-02',
|
|
332
|
+
status: 'atRisk',
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: 'w5',
|
|
336
|
+
name: 'Load & reliability',
|
|
337
|
+
owner: 'Dana Whitfield',
|
|
338
|
+
phase: 'beta',
|
|
339
|
+
percent: 38,
|
|
340
|
+
due: '2026-06-12',
|
|
341
|
+
status: 'blocked',
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: 'w6',
|
|
345
|
+
name: 'Docs & enablement',
|
|
346
|
+
owner: 'Sofia Marín',
|
|
347
|
+
phase: 'ga',
|
|
348
|
+
percent: 55,
|
|
349
|
+
due: '2026-06-22',
|
|
350
|
+
status: 'onTrack',
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: 'w7',
|
|
354
|
+
name: 'Launch marketing',
|
|
355
|
+
owner: 'Kenji Watanabe',
|
|
356
|
+
phase: 'ga',
|
|
357
|
+
percent: 41,
|
|
358
|
+
due: '2026-06-22',
|
|
359
|
+
status: 'onTrack',
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: 'w8',
|
|
363
|
+
name: 'Go-to-market ops',
|
|
364
|
+
owner: 'Lena Fischer',
|
|
365
|
+
phase: 'ga',
|
|
366
|
+
percent: 30,
|
|
367
|
+
due: '2026-06-30',
|
|
368
|
+
status: 'atRisk',
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
const RISKS: Risk[] = [
|
|
373
|
+
{
|
|
374
|
+
id: 'r1',
|
|
375
|
+
title: 'Load tests failing at 3x target concurrency',
|
|
376
|
+
workstream: 'Load & reliability',
|
|
377
|
+
phase: 'beta',
|
|
378
|
+
severity: 'blocked',
|
|
379
|
+
owner: 'Dana Whitfield',
|
|
380
|
+
detail:
|
|
381
|
+
'p99 latency degrades past SLO above 12k concurrent sessions. Root cause traced to connection-pool saturation; fix depends on the data-pipeline retry change landing first.',
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
id: 'r2',
|
|
385
|
+
title: 'Data pipeline backfill slower than planned',
|
|
386
|
+
workstream: 'Data pipeline',
|
|
387
|
+
phase: 'build',
|
|
388
|
+
severity: 'atRisk',
|
|
389
|
+
owner: 'Marcus Webb',
|
|
390
|
+
detail:
|
|
391
|
+
'Historical backfill is tracking two days behind. Mitigation: parallelizing the backfill workers; feature-complete date at risk if not recovered this week.',
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
id: 'r3',
|
|
395
|
+
title: 'Beta onboarding email deliverability',
|
|
396
|
+
workstream: 'Beta onboarding',
|
|
397
|
+
phase: 'beta',
|
|
398
|
+
severity: 'atRisk',
|
|
399
|
+
owner: 'Tom Okafor',
|
|
400
|
+
detail:
|
|
401
|
+
'Invite emails landing in spam for two enterprise domains. Working with IT to allowlist sending IPs before the private beta opens.',
|
|
402
|
+
},
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
// Ideal vs. actual remaining scope (story points), one entry per week. Actual
|
|
406
|
+
// trails ideal mid-project (scope discovery), then recovers late.
|
|
407
|
+
const BURNDOWN = (() => {
|
|
408
|
+
const total = 320;
|
|
409
|
+
const actualRemaining = [
|
|
410
|
+
320, 300, 286, 262, 240, 226, 210, 182, 150, 132, 96, 54, 20,
|
|
411
|
+
];
|
|
412
|
+
const idealStep = total / (actualRemaining.length - 1);
|
|
413
|
+
return actualRemaining.map((actual, i) => ({
|
|
414
|
+
week: i,
|
|
415
|
+
label: `W${i + 1}`,
|
|
416
|
+
ideal: Math.max(0, Math.round(total - idealStep * i)),
|
|
417
|
+
actual,
|
|
418
|
+
}));
|
|
419
|
+
})();
|
|
420
|
+
|
|
421
|
+
const BURNDOWN_TICKS = [0, 3, 6, 9, 12];
|
|
422
|
+
|
|
423
|
+
// ============= DERIVED FROM DATA =============
|
|
424
|
+
|
|
425
|
+
// Program-wide aggregates: unaffected by the phase filter, so computed once.
|
|
426
|
+
const TASK_POINTS_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.points, 0);
|
|
427
|
+
const TASK_COUNT_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.tasks, 0);
|
|
428
|
+
|
|
429
|
+
// Worst status across every workstream wins.
|
|
430
|
+
const PROGRAM_STATUS: Rag = WORKSTREAMS.some(w => w.status === 'blocked')
|
|
431
|
+
? 'blocked'
|
|
432
|
+
: WORKSTREAMS.some(w => w.status === 'atRisk')
|
|
433
|
+
? 'atRisk'
|
|
434
|
+
: 'onTrack';
|
|
435
|
+
|
|
436
|
+
function byPhase<T extends {phase: ReleasePhase}>(
|
|
437
|
+
items: T[],
|
|
438
|
+
phase: Phase,
|
|
439
|
+
): T[] {
|
|
440
|
+
return phase === 'all' ? items : items.filter(item => item.phase === phase);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ============= DONUT GEOMETRY =============
|
|
444
|
+
|
|
445
|
+
const DONUT_SIZE = 208;
|
|
446
|
+
const DONUT_OUTER_RADIUS = 104;
|
|
447
|
+
const DONUT_INNER_RADIUS = 70;
|
|
448
|
+
|
|
449
|
+
// The standard 2px gap between touching marks, which Recharts wants as an
|
|
450
|
+
// angle: 2px of arc at the middle of the ring.
|
|
451
|
+
const DONUT_GAP_ANGLE =
|
|
452
|
+
(2 / ((DONUT_OUTER_RADIUS + DONUT_INNER_RADIUS) / 2)) * (180 / Math.PI);
|
|
453
|
+
|
|
454
|
+
// ============= SHARED PIECES =============
|
|
455
|
+
|
|
456
|
+
const pageStyles = stylex.create({
|
|
457
|
+
contentBottomPad: {
|
|
458
|
+
paddingBlockEnd: 'var(--spacing-10)',
|
|
459
|
+
},
|
|
460
|
+
// The header and each Section run full-bleed so their dividers reach the
|
|
461
|
+
// viewport edges; the column inside them is what gets capped and centered.
|
|
462
|
+
contentColumn: {
|
|
463
|
+
width: '100%',
|
|
464
|
+
maxWidth: 1440,
|
|
465
|
+
marginInline: 'auto',
|
|
466
|
+
},
|
|
467
|
+
// Risk rows carry no action, so ListItem's interactive treatment (pointer
|
|
468
|
+
// cursor, pressed state) would be a lie. This is the hover half only — a hint
|
|
469
|
+
// that the hover card exists — guarded so it never sticks on touch.
|
|
470
|
+
riskRow: {
|
|
471
|
+
transitionProperty: 'background-color',
|
|
472
|
+
transitionDuration: 'var(--duration-fast-min)',
|
|
473
|
+
transitionTimingFunction: 'var(--ease-standard)',
|
|
474
|
+
backgroundColor: 'transparent',
|
|
475
|
+
':hover': {
|
|
476
|
+
backgroundColor: {
|
|
477
|
+
default: null,
|
|
478
|
+
'@media (hover: hover)': 'var(--color-overlay-hover)',
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
// Rows run the full width of the card, so the hover card has to cap itself.
|
|
483
|
+
riskDetail: {
|
|
484
|
+
maxWidth: 320,
|
|
485
|
+
},
|
|
486
|
+
// Fixed frame so the ring's px radii stay exact.
|
|
487
|
+
donutFrame: {
|
|
488
|
+
width: DONUT_SIZE,
|
|
489
|
+
height: DONUT_SIZE,
|
|
490
|
+
flexShrink: 0,
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Icon's `color` prop takes semantic names only, but a legend swatch has to
|
|
495
|
+
// match its series color exactly, so this one reaches for an inline color.
|
|
496
|
+
function LegendDot({color, label}: {color: string; label: string}) {
|
|
497
|
+
const dotStyle: CSSProperties = {color};
|
|
498
|
+
return (
|
|
499
|
+
<HStack gap={2} vAlign="center">
|
|
500
|
+
<Icon icon={StopIcon} size="xsm" style={dotStyle} />
|
|
501
|
+
<Text type="supporting" color="secondary">
|
|
502
|
+
{label}
|
|
503
|
+
</Text>
|
|
504
|
+
</HStack>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function SectionHeading({title, hint}: {title: string; hint?: string}) {
|
|
509
|
+
return (
|
|
510
|
+
<HStack hAlign="between" vAlign="center" gap={3}>
|
|
511
|
+
<Heading level={2}>{title}</Heading>
|
|
512
|
+
{hint ? (
|
|
513
|
+
<Text type="supporting" color="secondary">
|
|
514
|
+
{hint}
|
|
515
|
+
</Text>
|
|
516
|
+
) : null}
|
|
517
|
+
</HStack>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Card titles are visually level 3 but sit at level 2 in the outline, as peers
|
|
522
|
+
// of the section headings. `hint` describes the card, `meta` is the count or
|
|
523
|
+
// figure that trails on the opposite edge.
|
|
524
|
+
function CardHeading({
|
|
525
|
+
title,
|
|
526
|
+
hint,
|
|
527
|
+
meta,
|
|
528
|
+
}: {
|
|
529
|
+
title: string;
|
|
530
|
+
hint?: string;
|
|
531
|
+
meta?: string;
|
|
532
|
+
}) {
|
|
533
|
+
return (
|
|
534
|
+
<HStack gap={3} hAlign="between" vAlign="center" wrap="wrap">
|
|
535
|
+
<VStack>
|
|
536
|
+
<Heading level={3} accessibilityLevel={2}>
|
|
537
|
+
{title}
|
|
538
|
+
</Heading>
|
|
539
|
+
{hint ? (
|
|
540
|
+
<Text type="supporting" color="secondary">
|
|
541
|
+
{hint}
|
|
542
|
+
</Text>
|
|
543
|
+
) : null}
|
|
544
|
+
</VStack>
|
|
545
|
+
{meta ? (
|
|
546
|
+
<Text type="body" color="secondary">
|
|
547
|
+
{meta}
|
|
548
|
+
</Text>
|
|
549
|
+
) : null}
|
|
550
|
+
</HStack>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function PhaseEmptyState({noun}: {noun: string}) {
|
|
555
|
+
return (
|
|
556
|
+
<EmptyState
|
|
557
|
+
title={`No ${noun} in this phase`}
|
|
558
|
+
description={`Switch phases to see other ${noun}.`}
|
|
559
|
+
icon={<Icon icon={FlagIcon} size="lg" />}
|
|
560
|
+
isCompact
|
|
561
|
+
/>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ============= PAGE HEADER =============
|
|
566
|
+
|
|
567
|
+
function ProgramHeader({
|
|
568
|
+
phase,
|
|
569
|
+
onPhaseChange,
|
|
570
|
+
}: {
|
|
571
|
+
phase: Phase;
|
|
572
|
+
onPhaseChange: (phase: Phase) => void;
|
|
573
|
+
}) {
|
|
574
|
+
return (
|
|
575
|
+
<LayoutHeader padding={6} hasDivider>
|
|
576
|
+
<HStack
|
|
577
|
+
gap={3}
|
|
578
|
+
vAlign="center"
|
|
579
|
+
hAlign="between"
|
|
580
|
+
wrap="wrap"
|
|
581
|
+
xstyle={pageStyles.contentColumn}>
|
|
582
|
+
<VStack gap={1}>
|
|
583
|
+
<Heading level={1}>Aurora Launch</Heading>
|
|
584
|
+
<HStack gap={2} vAlign="center">
|
|
585
|
+
<Text type="body" color="secondary">
|
|
586
|
+
Target GA ·
|
|
587
|
+
</Text>
|
|
588
|
+
<Timestamp
|
|
589
|
+
value="2026-06-30T00:00:00Z"
|
|
590
|
+
format="date"
|
|
591
|
+
type="body"
|
|
592
|
+
color="secondary"
|
|
593
|
+
/>
|
|
594
|
+
<Badge
|
|
595
|
+
variant={RAG_BADGE[PROGRAM_STATUS]}
|
|
596
|
+
label={RAG_LABEL[PROGRAM_STATUS]}
|
|
597
|
+
icon={<Icon icon={RAG_ICON[PROGRAM_STATUS]} size="sm" />}
|
|
598
|
+
/>
|
|
599
|
+
</HStack>
|
|
600
|
+
</VStack>
|
|
601
|
+
<HStack gap={2}>
|
|
602
|
+
<SegmentedControl
|
|
603
|
+
label="Release phase"
|
|
604
|
+
value={phase}
|
|
605
|
+
onChange={value => onPhaseChange(value as Phase)}>
|
|
606
|
+
<SegmentedControlItem label="All" value="all" />
|
|
607
|
+
<SegmentedControlItem label="Build" value="build" />
|
|
608
|
+
<SegmentedControlItem label="Beta" value="beta" />
|
|
609
|
+
<SegmentedControlItem label="GA" value="ga" />
|
|
610
|
+
</SegmentedControl>
|
|
611
|
+
<Button
|
|
612
|
+
label="Export status"
|
|
613
|
+
variant="secondary"
|
|
614
|
+
icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
|
|
615
|
+
/>
|
|
616
|
+
</HStack>
|
|
617
|
+
</HStack>
|
|
618
|
+
</LayoutHeader>
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ============= GANTT (milestone timeline) =============
|
|
623
|
+
|
|
624
|
+
const GANTT_MIN_HEIGHT = 160;
|
|
625
|
+
const GANTT_ROW_HEIGHT = 44;
|
|
626
|
+
|
|
627
|
+
interface GanttTooltipEntry {
|
|
628
|
+
payload: Milestone & {offset: number; duration: number};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function GanttTooltip({
|
|
632
|
+
active,
|
|
633
|
+
payload,
|
|
634
|
+
}: {
|
|
635
|
+
active?: boolean;
|
|
636
|
+
payload?: GanttTooltipEntry[];
|
|
637
|
+
}) {
|
|
638
|
+
if (!active || !payload?.length) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
// The colored duration segment is stacked last, so it carries the milestone.
|
|
642
|
+
const milestone = payload[payload.length - 1].payload;
|
|
643
|
+
return (
|
|
644
|
+
<Card padding={3}>
|
|
645
|
+
<VStack gap={1}>
|
|
646
|
+
<Text type="body" weight="semibold">
|
|
647
|
+
{milestone.name}
|
|
648
|
+
</Text>
|
|
649
|
+
<Text type="supporting" color="secondary">
|
|
650
|
+
{milestone.dateLabel} · {PHASE_LABEL[milestone.phase]}
|
|
651
|
+
</Text>
|
|
652
|
+
</VStack>
|
|
653
|
+
</Card>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function GanttChart({milestones}: {milestones: Milestone[]}) {
|
|
658
|
+
// Recharts renders a floating bar as a transparent offset segment plus a
|
|
659
|
+
// colored duration segment stacked on top.
|
|
660
|
+
const data = milestones.map(m => ({
|
|
661
|
+
...m,
|
|
662
|
+
offset: m.start,
|
|
663
|
+
duration: m.end - m.start,
|
|
664
|
+
}));
|
|
665
|
+
const height = Math.max(GANTT_MIN_HEIGHT, data.length * GANTT_ROW_HEIGHT);
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<VStack gap={3}>
|
|
669
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
670
|
+
<BarChart
|
|
671
|
+
data={data}
|
|
672
|
+
layout="vertical"
|
|
673
|
+
margin={{top: 4, right: 16, left: 8, bottom: 4}}
|
|
674
|
+
barCategoryGap={12}>
|
|
675
|
+
<CartesianGrid horizontal={false} vertical stroke={CHART_GRID} />
|
|
676
|
+
<XAxis
|
|
677
|
+
type="number"
|
|
678
|
+
domain={[0, TIMELINE_DAYS]}
|
|
679
|
+
ticks={TIMELINE_TICKS}
|
|
680
|
+
tickFormatter={(v: number) => `D${v}`}
|
|
681
|
+
tick={AXIS_TICK}
|
|
682
|
+
axisLine={false}
|
|
683
|
+
tickLine={false}
|
|
684
|
+
/>
|
|
685
|
+
<YAxis
|
|
686
|
+
type="category"
|
|
687
|
+
dataKey="name"
|
|
688
|
+
tick={AXIS_TICK}
|
|
689
|
+
axisLine={false}
|
|
690
|
+
tickLine={false}
|
|
691
|
+
width={148}
|
|
692
|
+
/>
|
|
693
|
+
<Tooltip content={<GanttTooltip />} cursor={{fill: CHART_GRID}} />
|
|
694
|
+
{/* An annotation rather than a series, so it takes the neutral tone
|
|
695
|
+
instead of a categorical color. */}
|
|
696
|
+
<ReferenceLine
|
|
697
|
+
x={PROJECT_DAY}
|
|
698
|
+
stroke={CHART_NEUTRAL}
|
|
699
|
+
strokeDasharray="4 4"
|
|
700
|
+
label={{
|
|
701
|
+
value: 'Today',
|
|
702
|
+
position: 'top',
|
|
703
|
+
fontSize: 11,
|
|
704
|
+
fill: CHART_NEUTRAL,
|
|
705
|
+
}}
|
|
706
|
+
/>
|
|
707
|
+
<Bar
|
|
708
|
+
dataKey="offset"
|
|
709
|
+
stackId="gantt"
|
|
710
|
+
fill="transparent"
|
|
711
|
+
isAnimationActive={false}
|
|
712
|
+
/>
|
|
713
|
+
<Bar
|
|
714
|
+
dataKey="duration"
|
|
715
|
+
stackId="gantt"
|
|
716
|
+
radius={4}
|
|
717
|
+
isAnimationActive={false}>
|
|
718
|
+
{data.map(m => (
|
|
719
|
+
<Cell key={m.id} fill={RAG_FILL[m.status]} />
|
|
720
|
+
))}
|
|
721
|
+
</Bar>
|
|
722
|
+
</BarChart>
|
|
723
|
+
</ResponsiveContainer>
|
|
724
|
+
<HStack gap={4} vAlign="center" wrap="wrap">
|
|
725
|
+
<LegendDot color={RAG_FILL.onTrack} label={RAG_LABEL.onTrack} />
|
|
726
|
+
<LegendDot color={RAG_FILL.atRisk} label={RAG_LABEL.atRisk} />
|
|
727
|
+
<LegendDot color={RAG_FILL.blocked} label={RAG_LABEL.blocked} />
|
|
728
|
+
</HStack>
|
|
729
|
+
</VStack>
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ============= TASK PROGRESS (weighted donut) =============
|
|
734
|
+
|
|
735
|
+
function percentOfPoints(points: number): number {
|
|
736
|
+
return Math.round((points / TASK_POINTS_TOTAL) * 100);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
interface DonutTooltipEntry {
|
|
740
|
+
payload: TaskStatusSegment;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function TaskDonutTooltip({
|
|
744
|
+
active,
|
|
745
|
+
payload,
|
|
746
|
+
}: {
|
|
747
|
+
active?: boolean;
|
|
748
|
+
payload?: DonutTooltipEntry[];
|
|
749
|
+
}) {
|
|
750
|
+
if (!active || !payload?.length) {
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
const segment = payload[0].payload;
|
|
754
|
+
return (
|
|
755
|
+
<Card padding={3}>
|
|
756
|
+
<VStack gap={1}>
|
|
757
|
+
<LegendDot color={segment.color} label={segment.label} />
|
|
758
|
+
<Text type="supporting" color="secondary" hasTabularNumbers>
|
|
759
|
+
{percentOfPoints(segment.points)}% · {segment.points} pts ·{' '}
|
|
760
|
+
{segment.tasks} tasks
|
|
761
|
+
</Text>
|
|
762
|
+
</VStack>
|
|
763
|
+
</Card>
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function TaskProgressDonut() {
|
|
768
|
+
return (
|
|
769
|
+
<HStack gap={8} vAlign="center" wrap="wrap">
|
|
770
|
+
{/* Fixed size rather than a ResponsiveContainer: the ring geometry is
|
|
771
|
+
deliberate, and the card wraps the legend under it rather than
|
|
772
|
+
shrinking it. */}
|
|
773
|
+
<div {...stylex.props(pageStyles.donutFrame)}>
|
|
774
|
+
<PieChart
|
|
775
|
+
width={DONUT_SIZE}
|
|
776
|
+
height={DONUT_SIZE}
|
|
777
|
+
margin={{top: 0, right: 0, bottom: 0, left: 0}}>
|
|
778
|
+
<Pie
|
|
779
|
+
data={TASK_STATUS}
|
|
780
|
+
dataKey="points"
|
|
781
|
+
nameKey="label"
|
|
782
|
+
cx="50%"
|
|
783
|
+
cy="50%"
|
|
784
|
+
innerRadius={DONUT_INNER_RADIUS}
|
|
785
|
+
outerRadius={DONUT_OUTER_RADIUS}
|
|
786
|
+
paddingAngle={DONUT_GAP_ANGLE}
|
|
787
|
+
// Noon start, running clockwise, which puts "Closed" at the top.
|
|
788
|
+
startAngle={90}
|
|
789
|
+
endAngle={-270}
|
|
790
|
+
// The gap separates the arcs; a stroke would add non-data ink.
|
|
791
|
+
stroke="none"
|
|
792
|
+
isAnimationActive={false}>
|
|
793
|
+
{TASK_STATUS.map(s => (
|
|
794
|
+
<Cell key={s.key} fill={s.color} />
|
|
795
|
+
))}
|
|
796
|
+
</Pie>
|
|
797
|
+
<Tooltip content={<TaskDonutTooltip />} />
|
|
798
|
+
</PieChart>
|
|
799
|
+
</div>
|
|
800
|
+
{/* A key, not a table: per-segment figures live in the tooltip. */}
|
|
801
|
+
<VStack gap={2}>
|
|
802
|
+
{TASK_STATUS.map(s => (
|
|
803
|
+
<LegendDot key={s.key} color={s.color} label={s.label} />
|
|
804
|
+
))}
|
|
805
|
+
</VStack>
|
|
806
|
+
</HStack>
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ============= BURN-DOWN CHART =============
|
|
811
|
+
|
|
812
|
+
interface BurndownTooltipEntry {
|
|
813
|
+
name: string;
|
|
814
|
+
value: number;
|
|
815
|
+
color: string;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function BurndownTooltip({
|
|
819
|
+
active,
|
|
820
|
+
payload,
|
|
821
|
+
label,
|
|
822
|
+
}: {
|
|
823
|
+
active?: boolean;
|
|
824
|
+
payload?: BurndownTooltipEntry[];
|
|
825
|
+
label?: number;
|
|
826
|
+
}) {
|
|
827
|
+
if (!active || !payload?.length) {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
const week = typeof label === 'number' ? (BURNDOWN[label]?.label ?? '') : '';
|
|
831
|
+
return (
|
|
832
|
+
<Card padding={3}>
|
|
833
|
+
<VStack gap={1}>
|
|
834
|
+
<Text type="supporting" color="secondary">
|
|
835
|
+
{week}
|
|
836
|
+
</Text>
|
|
837
|
+
{payload.map(entry => (
|
|
838
|
+
<LegendDot
|
|
839
|
+
key={entry.name}
|
|
840
|
+
color={entry.color}
|
|
841
|
+
label={`${entry.name}: ${entry.value} pts`}
|
|
842
|
+
/>
|
|
843
|
+
))}
|
|
844
|
+
</VStack>
|
|
845
|
+
</Card>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Matches the donut frame so the two plots sharing the summary row read as one
|
|
850
|
+
// band.
|
|
851
|
+
const BURNDOWN_HEIGHT = DONUT_SIZE;
|
|
852
|
+
|
|
853
|
+
function BurndownChart() {
|
|
854
|
+
return (
|
|
855
|
+
<VStack gap={3}>
|
|
856
|
+
<ResponsiveContainer width="100%" height={BURNDOWN_HEIGHT}>
|
|
857
|
+
<LineChart
|
|
858
|
+
data={BURNDOWN}
|
|
859
|
+
margin={{top: 16, right: 4, left: 4, bottom: 4}}>
|
|
860
|
+
<CartesianGrid horizontal vertical={false} stroke={CHART_GRID} />
|
|
861
|
+
<XAxis
|
|
862
|
+
dataKey="week"
|
|
863
|
+
type="number"
|
|
864
|
+
domain={[0, BURNDOWN.length - 1]}
|
|
865
|
+
ticks={BURNDOWN_TICKS}
|
|
866
|
+
tickFormatter={(v: number) => BURNDOWN[v]?.label ?? ''}
|
|
867
|
+
tick={AXIS_TICK}
|
|
868
|
+
axisLine={false}
|
|
869
|
+
tickLine={false}
|
|
870
|
+
/>
|
|
871
|
+
<YAxis
|
|
872
|
+
tick={AXIS_TICK}
|
|
873
|
+
axisLine={false}
|
|
874
|
+
tickLine={false}
|
|
875
|
+
width={40}
|
|
876
|
+
unit=" pts"
|
|
877
|
+
/>
|
|
878
|
+
<Tooltip
|
|
879
|
+
content={<BurndownTooltip />}
|
|
880
|
+
cursor={{stroke: CHART_GRID}}
|
|
881
|
+
/>
|
|
882
|
+
<Line
|
|
883
|
+
type="monotone"
|
|
884
|
+
dataKey="ideal"
|
|
885
|
+
name="Ideal"
|
|
886
|
+
stroke={IDEAL_COLOR}
|
|
887
|
+
strokeWidth={1}
|
|
888
|
+
strokeDasharray="4 4"
|
|
889
|
+
dot={false}
|
|
890
|
+
isAnimationActive={false}
|
|
891
|
+
/>
|
|
892
|
+
<Line
|
|
893
|
+
type="monotone"
|
|
894
|
+
dataKey="actual"
|
|
895
|
+
name="Actual"
|
|
896
|
+
stroke={ACTUAL_COLOR}
|
|
897
|
+
strokeWidth={2}
|
|
898
|
+
dot={false}
|
|
899
|
+
isAnimationActive={false}
|
|
900
|
+
/>
|
|
901
|
+
</LineChart>
|
|
902
|
+
</ResponsiveContainer>
|
|
903
|
+
<HStack gap={4} vAlign="center" wrap="wrap">
|
|
904
|
+
<LegendDot color={ACTUAL_COLOR} label="Actual remaining" />
|
|
905
|
+
<LegendDot color={IDEAL_COLOR} label="Ideal burndown" />
|
|
906
|
+
</HStack>
|
|
907
|
+
</VStack>
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ============= RISKS LIST =============
|
|
912
|
+
|
|
913
|
+
function RiskRow({risk}: {risk: Risk}) {
|
|
914
|
+
// The `<HoverCard>` wrapper is not usable here: it wraps its trigger in a
|
|
915
|
+
// `display: contents` span, and a span between `<ul>` and `<li>` is invalid
|
|
916
|
+
// markup that drops the row out of the list in the accessibility tree. The
|
|
917
|
+
// hook puts the trigger ref straight on the `<li>` instead.
|
|
918
|
+
const hoverCard = useHoverCard({
|
|
919
|
+
placement: 'below',
|
|
920
|
+
alignment: 'start',
|
|
921
|
+
// The row is a plain `<li>` made focusable below, so leave nothing to
|
|
922
|
+
// `auto` trigger detection.
|
|
923
|
+
focusTrigger: 'always',
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
return (
|
|
927
|
+
<ListItem
|
|
928
|
+
ref={hoverCard.ref}
|
|
929
|
+
// The row has no action of its own; this tab stop exists so the detail is
|
|
930
|
+
// reachable without a pointer, and `aria-describedby` reads it out.
|
|
931
|
+
tabIndex={0}
|
|
932
|
+
aria-describedby={hoverCard.describedBy}
|
|
933
|
+
xstyle={pageStyles.riskRow}
|
|
934
|
+
label={risk.title}
|
|
935
|
+
description={`${risk.workstream} · ${PHASE_LABEL[risk.phase]} · ${risk.owner}`}
|
|
936
|
+
endContent={
|
|
937
|
+
<>
|
|
938
|
+
<Badge
|
|
939
|
+
variant={RAG_BADGE[risk.severity]}
|
|
940
|
+
label={RAG_LABEL[risk.severity]}
|
|
941
|
+
/>
|
|
942
|
+
{/* Inside the row, where the popover `<span>` is valid markup and
|
|
943
|
+
out of flow; a sibling of the `<li>` would not be. */}
|
|
944
|
+
{hoverCard.renderHoverCard(
|
|
945
|
+
<VStack gap={2} xstyle={pageStyles.riskDetail}>
|
|
946
|
+
<Text type="body" weight="semibold">
|
|
947
|
+
{risk.title}
|
|
948
|
+
</Text>
|
|
949
|
+
<Text type="supporting" color="secondary">
|
|
950
|
+
{risk.detail}
|
|
951
|
+
</Text>
|
|
952
|
+
<HStack gap={2} vAlign="center">
|
|
953
|
+
<Avatar name={risk.owner} size="sm" />
|
|
954
|
+
<Text type="supporting" color="secondary">
|
|
955
|
+
{risk.owner}
|
|
956
|
+
</Text>
|
|
957
|
+
</HStack>
|
|
958
|
+
</VStack>,
|
|
959
|
+
)}
|
|
960
|
+
</>
|
|
961
|
+
}
|
|
962
|
+
/>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function RisksPanel({risks}: {risks: Risk[]}) {
|
|
967
|
+
return (
|
|
968
|
+
<Card padding={6}>
|
|
969
|
+
<VStack gap={4}>
|
|
970
|
+
<CardHeading
|
|
971
|
+
title="Blockers & risks"
|
|
972
|
+
hint="Hover or focus a row for detail"
|
|
973
|
+
/>
|
|
974
|
+
{risks.length === 0 ? (
|
|
975
|
+
<EmptyState
|
|
976
|
+
title="No open blockers or risks"
|
|
977
|
+
description="Nothing flagged for the selected phase. Keep it up."
|
|
978
|
+
icon={<Icon icon={CheckCircleIcon} size="lg" color="success" />}
|
|
979
|
+
isCompact
|
|
980
|
+
/>
|
|
981
|
+
) : (
|
|
982
|
+
<List hasDividers>
|
|
983
|
+
{risks.map(risk => (
|
|
984
|
+
<RiskRow key={risk.id} risk={risk} />
|
|
985
|
+
))}
|
|
986
|
+
</List>
|
|
987
|
+
)}
|
|
988
|
+
</VStack>
|
|
989
|
+
</Card>
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ============= WORKSTREAM TABLE =============
|
|
994
|
+
|
|
995
|
+
const WORKSTREAM_COLUMNS: TableColumn<Workstream>[] = [
|
|
996
|
+
{
|
|
997
|
+
key: 'name',
|
|
998
|
+
header: 'Workstream',
|
|
999
|
+
width: proportional(1, {minWidth: 160}),
|
|
1000
|
+
renderCell: (item: Workstream) => (
|
|
1001
|
+
<VStack gap={0}>
|
|
1002
|
+
<Text type="body" weight="semibold">
|
|
1003
|
+
{item.name}
|
|
1004
|
+
</Text>
|
|
1005
|
+
<Text type="supporting" color="secondary">
|
|
1006
|
+
{PHASE_LABEL[item.phase]}
|
|
1007
|
+
</Text>
|
|
1008
|
+
</VStack>
|
|
1009
|
+
),
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
key: 'owner',
|
|
1013
|
+
header: 'Owner',
|
|
1014
|
+
width: proportional(1, {minWidth: 160}),
|
|
1015
|
+
renderCell: (item: Workstream) => (
|
|
1016
|
+
<HStack gap={2} vAlign="center">
|
|
1017
|
+
<Avatar name={item.owner} size="sm" />
|
|
1018
|
+
<Text type="body">{item.owner}</Text>
|
|
1019
|
+
</HStack>
|
|
1020
|
+
),
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
key: 'percent',
|
|
1024
|
+
header: '% complete',
|
|
1025
|
+
width: proportional(2, {minWidth: 200}),
|
|
1026
|
+
renderCell: (item: Workstream) => (
|
|
1027
|
+
<VStack gap={1}>
|
|
1028
|
+
<ProgressBar
|
|
1029
|
+
value={item.percent}
|
|
1030
|
+
max={100}
|
|
1031
|
+
label={`${item.name} progress`}
|
|
1032
|
+
isLabelHidden
|
|
1033
|
+
/>
|
|
1034
|
+
<Text type="supporting" color="secondary">
|
|
1035
|
+
{item.percent}%
|
|
1036
|
+
</Text>
|
|
1037
|
+
</VStack>
|
|
1038
|
+
),
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
key: 'due',
|
|
1042
|
+
header: 'Due',
|
|
1043
|
+
width: proportional(1, {minWidth: 120}),
|
|
1044
|
+
renderCell: (item: Workstream) => (
|
|
1045
|
+
<Timestamp value={item.due} format="date" />
|
|
1046
|
+
),
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
key: 'status',
|
|
1050
|
+
header: 'Status',
|
|
1051
|
+
width: proportional(1, {minWidth: 120}),
|
|
1052
|
+
renderCell: (item: Workstream) => (
|
|
1053
|
+
<Badge variant={RAG_BADGE[item.status]} label={RAG_LABEL[item.status]} />
|
|
1054
|
+
),
|
|
1055
|
+
},
|
|
1056
|
+
];
|
|
1057
|
+
|
|
1058
|
+
// ============= MAIN =============
|
|
1059
|
+
|
|
1060
|
+
export default function ProjectStatusPage() {
|
|
1061
|
+
const [phase, setPhase] = useState<Phase>('all');
|
|
1062
|
+
|
|
1063
|
+
const milestones = useMemo(() => byPhase(MILESTONES, phase), [phase]);
|
|
1064
|
+
const workstreams = useMemo(() => byPhase(WORKSTREAMS, phase), [phase]);
|
|
1065
|
+
const risks = useMemo(() => byPhase(RISKS, phase), [phase]);
|
|
1066
|
+
|
|
1067
|
+
return (
|
|
1068
|
+
<Layout
|
|
1069
|
+
height="fill"
|
|
1070
|
+
header={<ProgramHeader phase={phase} onPhaseChange={setPhase} />}
|
|
1071
|
+
content={
|
|
1072
|
+
<LayoutContent padding={0} xstyle={pageStyles.contentBottomPad}>
|
|
1073
|
+
<Section padding={6} dividers={['bottom']}>
|
|
1074
|
+
<VStack gap={6} xstyle={pageStyles.contentColumn}>
|
|
1075
|
+
<SectionHeading
|
|
1076
|
+
title="Milestone timeline"
|
|
1077
|
+
hint={`Project days 0–${TIMELINE_DAYS} · dashed line marks today`}
|
|
1078
|
+
/>
|
|
1079
|
+
{milestones.length === 0 ? (
|
|
1080
|
+
<PhaseEmptyState noun="milestones" />
|
|
1081
|
+
) : (
|
|
1082
|
+
<GanttChart milestones={milestones} />
|
|
1083
|
+
)}
|
|
1084
|
+
</VStack>
|
|
1085
|
+
</Section>
|
|
1086
|
+
|
|
1087
|
+
<Section padding={6} paddingBlock={10}>
|
|
1088
|
+
<VStack gap={6} xstyle={pageStyles.contentColumn}>
|
|
1089
|
+
<Grid gap={4} columns={{minWidth: 320, max: 3}}>
|
|
1090
|
+
<Card padding={6}>
|
|
1091
|
+
<VStack gap={5}>
|
|
1092
|
+
<CardHeading
|
|
1093
|
+
title="Overall progress"
|
|
1094
|
+
hint={`${TASK_COUNT_TOTAL} tasks · ${TASK_POINTS_TOTAL} story points`}
|
|
1095
|
+
/>
|
|
1096
|
+
<TaskProgressDonut />
|
|
1097
|
+
</VStack>
|
|
1098
|
+
</Card>
|
|
1099
|
+
|
|
1100
|
+
<Card padding={6}>
|
|
1101
|
+
<VStack gap={5}>
|
|
1102
|
+
<CardHeading
|
|
1103
|
+
title="Scope burndown"
|
|
1104
|
+
hint={`Remaining story points · ${BURNDOWN.length} weeks`}
|
|
1105
|
+
/>
|
|
1106
|
+
<BurndownChart />
|
|
1107
|
+
</VStack>
|
|
1108
|
+
</Card>
|
|
1109
|
+
|
|
1110
|
+
<RisksPanel risks={risks} />
|
|
1111
|
+
</Grid>
|
|
1112
|
+
|
|
1113
|
+
<Card padding={6}>
|
|
1114
|
+
<VStack gap={4}>
|
|
1115
|
+
<CardHeading
|
|
1116
|
+
title="Workstreams"
|
|
1117
|
+
meta={`${workstreams.length} of ${WORKSTREAMS.length}`}
|
|
1118
|
+
/>
|
|
1119
|
+
{workstreams.length === 0 ? (
|
|
1120
|
+
<PhaseEmptyState noun="workstreams" />
|
|
1121
|
+
) : (
|
|
1122
|
+
<Table<Workstream>
|
|
1123
|
+
data={workstreams}
|
|
1124
|
+
columns={WORKSTREAM_COLUMNS}
|
|
1125
|
+
idKey="id"
|
|
1126
|
+
density="balanced"
|
|
1127
|
+
dividers="rows"
|
|
1128
|
+
hasHover
|
|
1129
|
+
/>
|
|
1130
|
+
)}
|
|
1131
|
+
</VStack>
|
|
1132
|
+
</Card>
|
|
1133
|
+
</VStack>
|
|
1134
|
+
</Section>
|
|
1135
|
+
</LayoutContent>
|
|
1136
|
+
}
|
|
1137
|
+
/>
|
|
1138
|
+
);
|
|
1139
|
+
}
|