@sentry/junior-dashboard 0.249.0 → 0.250.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.249.0",
3
+ "version": "0.250.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -34,8 +34,8 @@
34
34
  "react-router": "^7.18.2",
35
35
  "shiki": "4.1.0",
36
36
  "zod": "^4.5.4",
37
- "@sentry/junior-plugin-api": "0.249.0",
38
- "@sentry/junior": "0.249.0"
37
+ "@sentry/junior": "0.250.0",
38
+ "@sentry/junior-plugin-api": "0.250.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@oxlint/plugins": "1.79.0",
@@ -38,6 +38,7 @@ import { CodePage } from "./pages/code/CodePage";
38
38
  import { LocationDetailPage } from "./pages/locations/LocationDetailPage";
39
39
  import { LocationsPage } from "./pages/locations/LocationsPage";
40
40
  import { PeoplePage } from "./pages/people/PeoplePage";
41
+ import { ConnectionsPage } from "./pages/ConnectionsPage";
41
42
  import { PersonalTokensPage } from "./pages/PersonalTokensPage";
42
43
  import { PersonProfilePage } from "./pages/people/PersonProfilePage";
43
44
  import { SettingsPage } from "./pages/SettingsPage";
@@ -562,6 +563,23 @@ export function DashboardShell() {
562
563
  }
563
564
  path="/settings/api-tokens"
564
565
  />
566
+ <Route
567
+ element={
568
+ loading ? (
569
+ <PageRouteLoading
570
+ description="Connect and disconnect your accounts."
571
+ label="Loading connected accounts"
572
+ title="Connected accounts"
573
+ variant="list"
574
+ />
575
+ ) : loggedIn ? (
576
+ <ConnectionsPage />
577
+ ) : (
578
+ <Navigate replace to="/" />
579
+ )
580
+ }
581
+ path="/settings/connections"
582
+ />
565
583
  <Route
566
584
  element={
567
585
  loading || userPagesQuery.isPending ? (
@@ -3,6 +3,7 @@ import {
3
3
  ChevronDown,
4
4
  KeyRound,
5
5
  LogOut,
6
+ Plug,
6
7
  Settings,
7
8
  ShieldCheck,
8
9
  UserRound,
@@ -194,6 +195,14 @@ export function ProfileMenu({
194
195
  <KeyRound aria-hidden="true" size={16} strokeWidth={2} />
195
196
  API tokens
196
197
  </Link>
198
+ <Link
199
+ className={profileLinkClass}
200
+ onClick={close}
201
+ to="/settings/connections"
202
+ >
203
+ <Plug aria-hidden="true" size={16} strokeWidth={2} />
204
+ Connected accounts
205
+ </Link>
197
206
  {identity.user.isAdmin ? (
198
207
  <Link className={profileLinkClass} onClick={close} to="/admin">
199
208
  <ShieldCheck aria-hidden="true" size={16} strokeWidth={2} />
@@ -266,6 +275,9 @@ export function ProfileMenu({
266
275
  <Link className={sheetItemClass} to="/settings/api-tokens">
267
276
  API tokens
268
277
  </Link>
278
+ <Link className={sheetItemClass} to="/settings/connections">
279
+ Connected accounts
280
+ </Link>
269
281
  {identity.user.isAdmin ? (
270
282
  <Link className={sheetItemClass} to="/admin">
271
283
  Admin
@@ -0,0 +1,207 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { Plug } from "lucide-react";
3
+ import type { z } from "zod";
4
+ import {
5
+ connectionListSchema,
6
+ disconnectConnectionResponseSchema,
7
+ startConnectionResponseSchema,
8
+ type connectionSchema,
9
+ } from "@sentry/junior/api/schema";
10
+ import { del, fetchDashboardJson, post } from "../http";
11
+ import { dashboardContainerClass } from "../styles";
12
+ import { getDashboardAgentName } from "../agentName";
13
+ import { useDocumentTitle } from "../documentTitle";
14
+ import { Button } from "../components/Button";
15
+ import { InlineError } from "../components/InlineError";
16
+ import { Card } from "../components/layout/Card";
17
+
18
+ type Connection = z.infer<typeof connectionSchema>;
19
+
20
+ const connectionsQueryKey = ["dashboard", "connections"] as const;
21
+
22
+ function connectionPath(provider: string): string {
23
+ return `/api/connections/${encodeURIComponent(provider)}`;
24
+ }
25
+
26
+ /** Connect and disconnect the viewer's plugin accounts. */
27
+ export function ConnectionsPage() {
28
+ useDocumentTitle("Connected accounts");
29
+ const queryClient = useQueryClient();
30
+ const connectionsQuery = useQuery({
31
+ queryKey: connectionsQueryKey,
32
+ queryFn: ({ signal }) =>
33
+ fetchDashboardJson(connectionListSchema, "/api/connections", signal),
34
+ // OAuth finishes in another tab. Refresh when the viewer returns.
35
+ refetchOnWindowFocus: true,
36
+ retry: false,
37
+ });
38
+ const connectMutation = useMutation({
39
+ mutationFn: (input: { provider: string; popup: Window | null }) =>
40
+ post(startConnectionResponseSchema, connectionPath(input.provider), {}),
41
+ onSuccess: ({ authorizationUrl }, { popup }) => {
42
+ // A blocked popup returns null, so use this tab. A tab the viewer
43
+ // closed means they cancelled; keep them on the dashboard.
44
+ if (!popup) {
45
+ window.location.assign(authorizationUrl);
46
+ return;
47
+ }
48
+ if (popup.closed) return;
49
+ popup.opener = null;
50
+ popup.location.href = authorizationUrl;
51
+ },
52
+ onError: (_error, { popup }) => {
53
+ popup?.close();
54
+ },
55
+ });
56
+ const disconnectMutation = useMutation({
57
+ mutationFn: (connection: Connection) =>
58
+ del(
59
+ disconnectConnectionResponseSchema,
60
+ connectionPath(connection.provider),
61
+ ),
62
+ onSuccess: (_result, connection) => {
63
+ queryClient.setQueryData<{ connections: Connection[] }>(
64
+ connectionsQueryKey,
65
+ (current) =>
66
+ current && {
67
+ connections: current.connections.map((item) =>
68
+ item.provider === connection.provider
69
+ ? {
70
+ provider: item.provider,
71
+ displayName: item.displayName,
72
+ description: item.description,
73
+ connected: false,
74
+ }
75
+ : item,
76
+ ),
77
+ },
78
+ );
79
+ },
80
+ });
81
+ const connections = connectionsQuery.data?.connections ?? [];
82
+ const busy = connectMutation.isPending || disconnectMutation.isPending;
83
+ const pendingProvider = connectMutation.isPending
84
+ ? connectMutation.variables?.provider
85
+ : disconnectMutation.isPending
86
+ ? disconnectMutation.variables?.provider
87
+ : undefined;
88
+ const listFailed = !connectionsQuery.data && connectionsQuery.isError;
89
+ const displayedError = connectMutation.isError
90
+ ? "Could not start the connection. Try again."
91
+ : disconnectMutation.isError
92
+ ? "Could not disconnect the account. Try again."
93
+ : listFailed
94
+ ? "Could not load connected accounts. Try again."
95
+ : undefined;
96
+
97
+ return (
98
+ <div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
99
+ <section className="mx-auto w-full max-w-3xl">
100
+ <h1 className="m-0 text-2xl font-bold">Connected accounts</h1>
101
+ <p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
102
+ Connect an account so {getDashboardAgentName()} can act as you in
103
+ conversations you start here.
104
+ </p>
105
+
106
+ <Card className="mb-0 mt-6" padding="md" variant="raised">
107
+ {displayedError ? (
108
+ <InlineError className={listFailed ? undefined : "mb-4"}>
109
+ {displayedError}
110
+ </InlineError>
111
+ ) : null}
112
+ {listFailed ? null : (
113
+ <div className="space-y-2">
114
+ {connectionsQuery.isPending ? (
115
+ <p className="m-0 text-sm text-dashboard-text-muted">
116
+ Loading connected accounts…
117
+ </p>
118
+ ) : connections.length === 0 ? (
119
+ <p className="m-0 text-sm text-dashboard-text-muted">
120
+ No installed plugins support account connections.
121
+ </p>
122
+ ) : (
123
+ connections.map((connection) => (
124
+ <div
125
+ className="flex items-center gap-3 rounded-md border border-dashboard-border-strong p-3"
126
+ key={connection.provider}
127
+ >
128
+ <Plug
129
+ className="shrink-0 text-dashboard-accent"
130
+ size={16}
131
+ />
132
+ <div className="min-w-0 flex-1">
133
+ <p className="m-0 truncate text-sm font-semibold">
134
+ {connection.displayName}
135
+ </p>
136
+ <p className="mt-1 mb-0 line-clamp-2 break-words text-xs text-dashboard-text-muted">
137
+ {connection.connected ? (
138
+ connection.account ? (
139
+ <>
140
+ Connected as{" "}
141
+ {connection.account.url ? (
142
+ <a
143
+ className="text-dashboard-text"
144
+ href={connection.account.url}
145
+ rel="noreferrer"
146
+ target="_blank"
147
+ >
148
+ {connection.account.label}
149
+ </a>
150
+ ) : (
151
+ connection.account.label
152
+ )}
153
+ </>
154
+ ) : (
155
+ "Connected"
156
+ )
157
+ ) : (
158
+ connection.description
159
+ )}
160
+ </p>
161
+ </div>
162
+ {connection.connected ? (
163
+ <Button
164
+ aria-busy={pendingProvider === connection.provider}
165
+ aria-label={`Disconnect ${connection.displayName}`}
166
+ disabled={busy}
167
+ onClick={() => {
168
+ connectMutation.reset();
169
+ disconnectMutation.mutate(connection);
170
+ }}
171
+ tone="danger"
172
+ >
173
+ {pendingProvider === connection.provider
174
+ ? "Disconnecting…"
175
+ : "Disconnect"}
176
+ </Button>
177
+ ) : (
178
+ <Button
179
+ aria-busy={pendingProvider === connection.provider}
180
+ aria-label={`Connect ${connection.displayName}`}
181
+ disabled={busy}
182
+ onClick={() => {
183
+ disconnectMutation.reset();
184
+ // Open the tab during the click so popup blockers
185
+ // allow it, then point it at the provider.
186
+ const popup = window.open("about:blank", "_blank");
187
+ connectMutation.mutate({
188
+ provider: connection.provider,
189
+ popup,
190
+ });
191
+ }}
192
+ >
193
+ {pendingProvider === connection.provider
194
+ ? "Connecting…"
195
+ : "Connect"}
196
+ </Button>
197
+ )}
198
+ </div>
199
+ ))
200
+ )}
201
+ </div>
202
+ )}
203
+ </Card>
204
+ </section>
205
+ </div>
206
+ );
207
+ }
@@ -1,17 +1,62 @@
1
+ import type { ReactNode } from "react";
1
2
  import type {
3
+ AutomationExecutionDay,
4
+ AutomationExecutionStatusDay,
5
+ CodeActivityDay,
2
6
  ConversationMetricDay,
7
+ DailyConversationActivity,
8
+ GuardianMetricDay,
3
9
  LocationActivityDayReport,
4
10
  PeopleActivityDayReport,
11
+ PluginOperationalReport,
5
12
  } from "@sentry/junior/api/schema";
6
13
  import { InputCacheChart } from "../../components/charts/InputCacheChart";
7
14
  import { SystemMetricCharts } from "../../components/charts/SystemMetricCharts";
15
+ import { WorkspaceUsageChart } from "../../components/charts/WorkspaceUsageChart";
16
+ import { AutomationCostChart } from "../automations/AutomationCostChart";
17
+ import { AutomationExecutionChart } from "../automations/AutomationExecutionChart";
18
+ import { AutomationExecutionStatusChart } from "../automations/AutomationExecutionStatusChart";
19
+ import { CodeActivityChart } from "../code/CodeActivityChart";
20
+ import { LocationActivityChart } from "../locations/LocationActivityChart";
8
21
  import { LocationDirectoryActivityChart } from "../locations/LocationDirectoryActivityChart";
22
+ import { MemoryCostChart } from "../memory/MemoryCostChart";
23
+ import { MemoryTimeline } from "../memory/MemoryTimeline";
24
+ import type { MemoryCostDay, MemoryDay } from "../memory/memoryDashboard";
9
25
  import { ContributionGrid } from "../people/ContributionGrid";
10
26
  import { PeopleActivityChart } from "../people/PeopleActivityChart";
11
27
  import { ConversationActivityChart } from "../system/ConversationActivityChart";
28
+ import { GuardianActivity } from "../system/GuardianActivity";
29
+ import { PluginBarChart } from "../system/PluginBarChart";
12
30
  import { Card } from "../../components/layout/Card";
13
31
  import { CardHeader } from "../../components/layout/CardHeader";
14
32
 
33
+ type PluginWidget = NonNullable<PluginOperationalReport["widgets"]>[number];
34
+
35
+ // The gallery browser scenario saves one image for each id.
36
+ const CHART_FIXTURE_IDS = [
37
+ "conversation-activity",
38
+ "conversation-activity-hourly",
39
+ "input-cache",
40
+ "system-metrics",
41
+ "people-activity",
42
+ "location-directory-activity",
43
+ "location-activity",
44
+ "code-activity",
45
+ "code-activity-dense",
46
+ "code-activity-empty",
47
+ "workspace-usage",
48
+ "automation-executions",
49
+ "automation-execution-status",
50
+ "automation-cost",
51
+ "memory-timeline",
52
+ "memory-cost",
53
+ "guardian-activity",
54
+ "plugin-bar-chart",
55
+ "contribution-grid",
56
+ ] as const;
57
+
58
+ type ChartFixtureId = (typeof CHART_FIXTURE_IDS)[number];
59
+
15
60
  const METRIC_DAYS: ConversationMetricDay[] = fixtureDates(14).map(
16
61
  (date, index) => ({
17
62
  conversations: 4 + ((index * 5) % 17),
@@ -22,6 +67,16 @@ const METRIC_DAYS: ConversationMetricDay[] = fixtureDates(14).map(
22
67
  }),
23
68
  );
24
69
 
70
+ const HOURLY_METRIC_DAYS: ConversationMetricDay[] = fixtureHours(24).map(
71
+ (date, index) => ({
72
+ conversations: index % 5 === 0 ? 0 : 1 + ((index * 7) % 9),
73
+ costUsd: 0.05 + ((index * 3) % 7) * 0.04,
74
+ date,
75
+ durationMs: 30_000 + ((index * 11) % 7) * 9_000,
76
+ tokens: 2_000 + ((index * 13) % 11) * 400,
77
+ }),
78
+ );
79
+
25
80
  const PEOPLE_DAYS: PeopleActivityDayReport[] = fixtureDates(30).map(
26
81
  (date, index) => ({
27
82
  activePeople: 4 + ((index * 7) % 13),
@@ -38,44 +93,233 @@ const LOCATION_DAYS: LocationActivityDayReport[] = fixtureDates(30).map(
38
93
  }),
39
94
  );
40
95
 
96
+ const LOCATION_DETAIL_DAYS: DailyConversationActivity[] = fixtureDates(90).map(
97
+ (date, index) => {
98
+ const conversations = index % 11 === 0 ? 0 : 2 + ((index * 7) % 15);
99
+ return {
100
+ active: Math.min(conversations, index % 4),
101
+ conversations,
102
+ date,
103
+ durationMs: conversations * 60_000,
104
+ failed: index % 13 === 0 ? 1 : 0,
105
+ };
106
+ },
107
+ );
108
+
109
+ const CODE_DAYS: CodeActivityDay[] = codeDays(30);
110
+
111
+ const DENSE_CODE_DAYS: CodeActivityDay[] = codeDays(90);
112
+
113
+ const EMPTY_CODE_DAYS: CodeActivityDay[] = fixtureDates(30).map((date) => ({
114
+ closed: 0,
115
+ created: 0,
116
+ date,
117
+ merged: 0,
118
+ }));
119
+
120
+ const WORKSPACE_USAGE_DAYS = fixtureDates(30).map((date, index) => ({
121
+ count: index % 6 === 0 ? 0 : 1 + ((index * 5) % 9),
122
+ date,
123
+ }));
124
+
125
+ const AUTOMATION_DAYS: AutomationExecutionDay[] = fixtureDates(30).map(
126
+ (date, index) => ({
127
+ costUsd: index % 7 === 0 ? 0 : 0.04 + ((index * 5) % 9) * 0.11,
128
+ date,
129
+ event: (index * 3) % 5,
130
+ scheduled: index % 7 === 0 ? 0 : 1 + ((index * 7) % 6),
131
+ }),
132
+ );
133
+
134
+ const AUTOMATION_STATUS_DAYS: AutomationExecutionStatusDay[] = fixtureDates(
135
+ 30,
136
+ ).map((date, index) => ({
137
+ blocked: index % 9 === 0 ? 1 : 0,
138
+ completed: index % 7 === 0 ? 0 : 1 + ((index * 5) % 4),
139
+ date,
140
+ failed: index % 5 === 0 ? 1 : 0,
141
+ }));
142
+
143
+ const MEMORY_DAYS: MemoryDay[] = fixtureDates(30).map((date, index) => ({
144
+ date,
145
+ personal: index % 8 === 0 ? 0 : (index * 3) % 7,
146
+ public: (index * 5) % 4,
147
+ }));
148
+
149
+ const MEMORY_EXTRACTION_DAYS: MemoryCostDay[] = fixtureDates(30).map(
150
+ (date, index) => ({
151
+ costUsd: index % 8 === 0 ? 0 : 0.02 + ((index * 7) % 9) * 0.015,
152
+ date,
153
+ events: index % 8 === 0 ? 0 : 1 + ((index * 3) % 6),
154
+ }),
155
+ );
156
+
157
+ const MEMORY_RECALL_DAYS: MemoryCostDay[] = fixtureDates(30).map(
158
+ (date, index) => ({
159
+ costUsd: 0.01 + ((index * 5) % 7) * 0.008,
160
+ date,
161
+ events: 2 + ((index * 5) % 9),
162
+ }),
163
+ );
164
+
165
+ const GUARDIAN_DAYS: GuardianMetricDay[] = fixtureDates(30).map(
166
+ (date, index) => {
167
+ const allow = 6 + ((index * 7) % 19);
168
+ const ask = (index * 3) % 5;
169
+ const deny = index % 6 === 0 ? 2 : 0;
170
+ return {
171
+ allow,
172
+ ask,
173
+ costUsd: (allow + ask + deny) * 0.0021,
174
+ date,
175
+ deny,
176
+ requests: allow + ask + deny,
177
+ };
178
+ },
179
+ );
180
+
181
+ const PLUGIN_WIDGET: PluginWidget = {
182
+ categories: fixtureDates(14).map((date, index) => ({
183
+ id: date,
184
+ label: date,
185
+ values: {
186
+ failed: index % 4 === 0 ? 2 : 0,
187
+ succeeded: 3 + ((index * 5) % 11),
188
+ },
189
+ })),
190
+ description: "Deliveries reported by the plugin.",
191
+ id: "deliveries",
192
+ series: [
193
+ { key: "succeeded", label: "Succeeded", tone: "good" },
194
+ { key: "failed", label: "Failed", tone: "danger" },
195
+ ],
196
+ title: "Deliveries",
197
+ type: "bar_chart",
198
+ };
199
+
41
200
  const CONTRIBUTION_DAYS = fixtureDates(70).map((date, index) => ({
42
201
  conversations: index % 9 === 0 ? 0 : 1 + ((index * 7) % 18),
43
202
  date,
44
203
  durationMs: index % 9 === 0 ? 0 : 45_000 + ((index * 13) % 20) * 18_000,
45
204
  }));
46
205
 
47
- /** Render chart examples with cache drops and missing counters for visual review. */
206
+ /** Render every dashboard chart with fixed data for visual review. */
48
207
  export function ChartFixtures() {
49
208
  return (
50
209
  <>
51
- <ConversationActivityChart days={METRIC_DAYS} />
52
- <InputCacheChart
53
- bucketUnit="day"
54
- days={METRIC_DAYS.map((day, index) => ({
55
- ...day,
56
- inputTokens: 2_000,
57
- cachedInputTokens: index === 9 ? 8_000 : 180_000,
58
- cacheCreationTokens:
59
- index === 5 ? undefined : index === 9 ? 190_000 : 10_000,
60
- }))}
61
- />
62
- <SystemMetricCharts days={METRIC_DAYS} />
63
- <PeopleActivityChart days={PEOPLE_DAYS} />
64
- <LocationDirectoryActivityChart days={LOCATION_DAYS} />
65
- <Card>
66
- <CardHeader
67
- description="Daily conversation intensity over ten weeks."
68
- title="Contribution activity"
210
+ <ChartFixture id="conversation-activity">
211
+ <ConversationActivityChart days={METRIC_DAYS} />
212
+ </ChartFixture>
213
+ <ChartFixture id="conversation-activity-hourly">
214
+ <ConversationActivityChart
215
+ bucketUnit="hour"
216
+ days={HOURLY_METRIC_DAYS}
69
217
  />
70
- <ContributionGrid days={CONTRIBUTION_DAYS} />
71
- </Card>
218
+ </ChartFixture>
219
+ <ChartFixture id="input-cache">
220
+ <InputCacheChart
221
+ bucketUnit="day"
222
+ days={METRIC_DAYS.map((day, index) => ({
223
+ ...day,
224
+ inputTokens: 2_000,
225
+ cachedInputTokens: index === 9 ? 8_000 : 180_000,
226
+ cacheCreationTokens:
227
+ index === 5 ? undefined : index === 9 ? 190_000 : 10_000,
228
+ }))}
229
+ />
230
+ </ChartFixture>
231
+ <ChartFixture id="system-metrics">
232
+ <SystemMetricCharts days={METRIC_DAYS} />
233
+ </ChartFixture>
234
+ <ChartFixture id="people-activity">
235
+ <PeopleActivityChart days={PEOPLE_DAYS} />
236
+ </ChartFixture>
237
+ <ChartFixture id="location-directory-activity">
238
+ <LocationDirectoryActivityChart days={LOCATION_DAYS} />
239
+ </ChartFixture>
240
+ <ChartFixture id="location-activity">
241
+ <LocationActivityChart days={LOCATION_DETAIL_DAYS} />
242
+ </ChartFixture>
243
+ <ChartFixture id="code-activity">
244
+ <CodeActivityChart days={CODE_DAYS} range={30} />
245
+ </ChartFixture>
246
+ <ChartFixture id="code-activity-dense">
247
+ <CodeActivityChart days={DENSE_CODE_DAYS} range={90} />
248
+ </ChartFixture>
249
+ <ChartFixture id="code-activity-empty">
250
+ <CodeActivityChart days={EMPTY_CODE_DAYS} range={30} />
251
+ </ChartFixture>
252
+ <ChartFixture id="workspace-usage">
253
+ <WorkspaceUsageChart
254
+ days={WORKSPACE_USAGE_DAYS}
255
+ range={30}
256
+ workspaceName="junior"
257
+ />
258
+ </ChartFixture>
259
+ <ChartFixture id="automation-executions">
260
+ <AutomationExecutionChart days={AUTOMATION_DAYS} range={30} />
261
+ </ChartFixture>
262
+ <ChartFixture id="automation-execution-status">
263
+ <AutomationExecutionStatusChart
264
+ days={AUTOMATION_STATUS_DAYS}
265
+ range={30}
266
+ />
267
+ </ChartFixture>
268
+ <ChartFixture id="automation-cost">
269
+ <AutomationCostChart days={AUTOMATION_DAYS} range={30} />
270
+ </ChartFixture>
271
+ <ChartFixture id="memory-timeline">
272
+ <MemoryTimeline days={MEMORY_DAYS} range={30} />
273
+ </ChartFixture>
274
+ <ChartFixture id="memory-cost">
275
+ <MemoryCostChart
276
+ extractionDays={MEMORY_EXTRACTION_DAYS}
277
+ range={30}
278
+ recallDays={MEMORY_RECALL_DAYS}
279
+ />
280
+ </ChartFixture>
281
+ <ChartFixture id="guardian-activity">
282
+ <GuardianActivity days={GUARDIAN_DAYS} />
283
+ </ChartFixture>
284
+ <ChartFixture id="plugin-bar-chart">
285
+ <PluginBarChart widget={PLUGIN_WIDGET} />
286
+ </ChartFixture>
287
+ <ChartFixture id="contribution-grid">
288
+ <Card>
289
+ <CardHeader
290
+ description="Daily conversation intensity over ten weeks."
291
+ title="Contribution activity"
292
+ />
293
+ <ContributionGrid days={CONTRIBUTION_DAYS} />
294
+ </Card>
295
+ </ChartFixture>
72
296
  </>
73
297
  );
74
298
  }
75
299
 
300
+ function ChartFixture(props: { children: ReactNode; id: ChartFixtureId }) {
301
+ return <div data-gallery-chart={props.id}>{props.children}</div>;
302
+ }
303
+
304
+ function codeDays(count: number): CodeActivityDay[] {
305
+ return fixtureDates(count).map((date, index) => ({
306
+ closed: index % 9 === 0 ? 1 : 0,
307
+ created: index % 4 === 0 ? 0 : 1 + ((index * 5) % 3),
308
+ date,
309
+ merged: index % 3 === 0 ? 1 + (index % 2) : 0,
310
+ }));
311
+ }
312
+
76
313
  function fixtureDates(count: number): string[] {
77
314
  const start = Date.UTC(2026, 4, 1);
78
315
  return Array.from({ length: count }, (_, index) =>
79
316
  new Date(start + index * 86_400_000).toISOString().slice(0, 10),
80
317
  );
81
318
  }
319
+
320
+ function fixtureHours(count: number): string[] {
321
+ const start = Date.UTC(2026, 4, 1);
322
+ return Array.from({ length: count }, (_, index) =>
323
+ new Date(start + index * 3_600_000).toISOString().slice(0, 13),
324
+ );
325
+ }