@sentry/junior-dashboard 0.257.0 → 0.259.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.257.0",
3
+ "version": "0.259.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -33,8 +33,8 @@
33
33
  "react-router": "^7.18.2",
34
34
  "shiki": "4.1.0",
35
35
  "zod": "^4.5.4",
36
- "@sentry/junior": "0.257.0",
37
- "@sentry/junior-plugin-api": "0.257.0"
36
+ "@sentry/junior": "0.259.0",
37
+ "@sentry/junior-plugin-api": "0.259.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@oxlint/plugins": "1.79.0",
@@ -47,15 +47,16 @@
47
47
  "eslint": "^10.8.0",
48
48
  "oxlint": "^1.79.0",
49
49
  "tailwindcss": "^4.3.0",
50
+ "tsdown": "0.23.1",
50
51
  "tsup": "^8.5.1",
51
- "typescript": "^6.0.3",
52
+ "typescript": "^7.0.2",
52
53
  "vitest": "^4.1.7"
53
54
  },
54
55
  "scripts": {
55
56
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
56
57
  "build:client": "tsup --config tsup.client.config.ts",
57
58
  "build:css": "tailwindcss -i src/tailwind.css -o dist/tailwind.css --minify",
58
- "build:server": "tsup --config tsup.config.ts",
59
+ "build:server": "tsup --config tsup.config.ts && tsdown --config tsdown.dts.config.ts",
59
60
  "lint": "oxlint --config .oxlintrc.json --deny-warnings src",
60
61
  "test": "vitest run -c vitest.config.ts",
61
62
  "typecheck": "tsc --noEmit",
package/src/api/schema.ts CHANGED
@@ -33,8 +33,7 @@ export const dashboardProfileUpdateSchema = z
33
33
 
34
34
  export const dashboardConfigSchema = z
35
35
  .object({
36
- allowedEmailCount: z.number(),
37
- allowedGoogleDomainCount: z.number(),
36
+ allowedDomainCount: z.number(),
38
37
  authRequired: z.boolean(),
39
38
  authPath: z.string(),
40
39
  basePath: z.string(),
package/src/app.ts CHANGED
@@ -78,14 +78,14 @@ export interface JuniorDashboardOptions {
78
78
  baseURL?: string;
79
79
  authPath?: string;
80
80
  authRequired?: boolean;
81
- allowedGoogleDomains?: string[];
82
- allowedEmails?: string[];
83
81
  auth?: DashboardAuth;
84
82
  componentGallery?: boolean;
85
83
  mockConversations?: boolean;
86
84
  }
87
85
 
88
86
  interface DashboardRuntimeOptions extends JuniorDashboardOptions {
87
+ /** The app's `allowedDomains`, from core. Auth requires at least one. */
88
+ allowedDomains?: string[];
89
89
  authenticatedRoutes?: readonly AuthenticatedRoute[];
90
90
  /** The queue `createApp()` uses, so web input reaches the same worker. */
91
91
  conversationWorkQueue?: ConversationWorkQueue;
@@ -113,37 +113,6 @@ function normalizeValues(values: string[] | undefined): string[] {
113
113
  ];
114
114
  }
115
115
 
116
- /** Read dashboard list env vars as comma-separated strings or JSON arrays. */
117
- function readEnvList(name: string): string[] | undefined {
118
- const value = process.env[name];
119
- if (!value?.trim()) {
120
- return undefined;
121
- }
122
-
123
- if (value.trim().startsWith("[")) {
124
- let parsed: unknown;
125
- try {
126
- parsed = JSON.parse(value);
127
- } catch (error) {
128
- throw new Error(`${name} must be a JSON string array`, {
129
- cause: error,
130
- });
131
- }
132
- if (
133
- !Array.isArray(parsed) ||
134
- parsed.some((item) => typeof item !== "string")
135
- ) {
136
- throw new Error(`${name} must be a JSON string array`);
137
- }
138
- return parsed;
139
- }
140
-
141
- return value
142
- .split(",")
143
- .map((item) => item.trim())
144
- .filter(Boolean);
145
- }
146
-
147
116
  /** Read dashboard boolean env vars; only explicit true/false values apply. */
148
117
  function readEnvFlag(name: string): boolean | undefined {
149
118
  const value = process.env[name]?.trim();
@@ -160,11 +129,6 @@ function resolveDashboardOptions(
160
129
  ...options,
161
130
  authRequired:
162
131
  options.authRequired ?? readEnvFlag("JUNIOR_DASHBOARD_AUTH_REQUIRED"),
163
- allowedGoogleDomains:
164
- options.allowedGoogleDomains ??
165
- readEnvList("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"),
166
- allowedEmails:
167
- options.allowedEmails ?? readEnvList("JUNIOR_DASHBOARD_ALLOWED_EMAILS"),
168
132
  componentGallery:
169
133
  options.componentGallery ??
170
134
  readEnvFlag("JUNIOR_DASHBOARD_COMPONENT_GALLERY"),
@@ -404,8 +368,7 @@ export function createDashboardApp(
404
368
  options.authPath ?? DEFAULT_AUTH_PATH,
405
369
  DEFAULT_AUTH_PATH,
406
370
  );
407
- const allowedDomains = normalizeValues(options.allowedGoogleDomains);
408
- const allowedEmails = normalizeValues(options.allowedEmails);
371
+ const allowedDomains = normalizeValues(options.allowedDomains);
409
372
  const agentName = options.agentName?.trim() || "Junior";
410
373
 
411
374
  const authRequired = options.authRequired !== false;
@@ -415,17 +378,11 @@ export function createDashboardApp(
415
378
  canonicalBaseURL = resolveDashboardBaseURL({ baseURL: configuredBaseURL });
416
379
  }
417
380
 
418
- if (
419
- authRequired &&
420
- allowedDomains.length === 0 &&
421
- allowedEmails.length === 0
422
- ) {
423
- throw new Error(
424
- "Junior dashboard auth requires allowedGoogleDomains or allowedEmails",
425
- );
381
+ if (authRequired && allowedDomains.length === 0) {
382
+ throw new Error("Junior dashboard auth requires allowedDomains");
426
383
  }
427
384
 
428
- // Core owns the Google sign-in session and shares it with Slack.
385
+ // Core owns the Google sign-in session.
429
386
  const auth = options.auth;
430
387
  if (authRequired && !auth) {
431
388
  throw new Error("Junior dashboard auth requires the core sign-in session");
@@ -448,10 +405,7 @@ export function createDashboardApp(
448
405
  return Response.redirect(returnUrl, 302);
449
406
  }
450
407
  const session = await auth.getSession(c.req.raw);
451
- if (
452
- session &&
453
- dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails)
454
- ) {
408
+ if (session && dashboardSessionIsAuthorized(session, allowedDomains)) {
455
409
  return Response.redirect(returnUrl, 302);
456
410
  }
457
411
  return auth.signInWithGoogle(c.req.raw, returnUrl);
@@ -540,7 +494,7 @@ export function createDashboardApp(
540
494
  componentGallery: options.componentGallery,
541
495
  });
542
496
  }
543
- if (!dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails)) {
497
+ if (!dashboardSessionIsAuthorized(session, allowedDomains)) {
544
498
  return forbidden(c.req.raw, agentName);
545
499
  }
546
500
  const sanitizedSession = sanitizeDashboardSession(session);
@@ -563,6 +517,7 @@ export function createDashboardApp(
563
517
  c.set("viewer", viewer);
564
518
  }
565
519
  await next();
520
+ return undefined;
566
521
  };
567
522
 
568
523
  app.use("*", requireAuth);
@@ -621,8 +576,7 @@ export function createDashboardApp(
621
576
  );
622
577
  app.get("/api/config", () => {
623
578
  return jsonResponse(dashboardConfigSchema, {
624
- allowedEmailCount: allowedEmails.length,
625
- allowedGoogleDomainCount: allowedDomains.length,
579
+ allowedDomainCount: allowedDomains.length,
626
580
  authRequired,
627
581
  authPath,
628
582
  basePath,
package/src/auth.ts CHANGED
@@ -4,7 +4,7 @@ import type { DashboardSession } from "./api/schema";
4
4
  export type DashboardUser = DashboardSession["user"];
5
5
  export type { DashboardSession };
6
6
 
7
- /** Google sign-in session that core shares with Slack. */
7
+ /** Google sign-in session that core owns. */
8
8
  export interface DashboardAuth {
9
9
  handler(request: Request): Promise<Response>;
10
10
  getSession(request: Request): Promise<DashboardSession | null>;
@@ -25,19 +25,22 @@ export function dashboardBearerSession(email: string): DashboardSession {
25
25
  return { user: { email, emailVerified: true } };
26
26
  }
27
27
 
28
- /** Check the dashboard allowlist against one verified session. */
28
+ /**
29
+ * Check the allowed domains against one verified session. Core accepts a
30
+ * Google sign-in only for a Workspace account on an allowed domain. This
31
+ * check also applies a changed list to existing sessions and personal tokens.
32
+ */
29
33
  export function dashboardSessionIsAuthorized(
30
34
  session: DashboardSession,
31
35
  allowedDomains: string[],
32
- allowedEmails: string[],
33
36
  ): boolean {
34
37
  const email = session.user.email.toLowerCase();
35
38
  const separator = email.lastIndexOf("@");
36
39
  const domain = separator > 0 ? email.slice(separator + 1) : undefined;
37
40
  return Boolean(
38
41
  session.user.emailVerified &&
39
- (allowedEmails.includes(email) ||
40
- (domain !== undefined && allowedDomains.includes(domain))),
42
+ domain !== undefined &&
43
+ allowedDomains.includes(domain),
41
44
  );
42
45
  }
43
46
 
@@ -19,8 +19,7 @@ export function isAuthPath(
19
19
  const requestSegments = segments(pathname);
20
20
  return routes.some((route) => {
21
21
  const routeSegments = segments(route.path);
22
- for (let index = 0; index < routeSegments.length; index += 1) {
23
- const pattern = routeSegments[index];
22
+ for (const [index, pattern] of routeSegments.entries()) {
24
23
  if (pattern === "*") return true;
25
24
  const value = requestSegments[index];
26
25
  if (!value || (pattern !== value && !pattern.startsWith(":"))) {
@@ -101,9 +101,10 @@ export function activityGroupSummary(
101
101
  (entry) => entry.kind === "attachments_delivered",
102
102
  ).length;
103
103
  const eventCount = entries.filter((entry) => entry.kind === "message").length;
104
+ const [onlyHandoff] = handoffs;
104
105
  const handoffSummary =
105
- handoffs.length === 1
106
- ? `model handoff to ${handoffs[0].modelId} (${handoffs[0].modelProfile})`
106
+ handoffs.length === 1 && onlyHandoff
107
+ ? `model handoff to ${onlyHandoff.modelId} (${onlyHandoff.modelProfile})`
107
108
  : handoffs.length > 1
108
109
  ? countLabel(handoffs.length, "model handoff", "model handoffs")
109
110
  : undefined;
@@ -55,8 +55,6 @@ export function transcriptFailureTitle(
55
55
  case "agent_run_failed":
56
56
  return "Internal error";
57
57
  }
58
-
59
- return "Internal error";
60
58
  }
61
59
 
62
60
  /** Short description for one failed turn in the transcript. */
@@ -111,8 +109,6 @@ export function transcriptFailureDescription(
111
109
  case "agent_run_failed":
112
110
  return `${agentName} hit an internal error during this turn.`;
113
111
  }
114
-
115
- return `${agentName} hit an internal error during this turn.`;
116
112
  }
117
113
 
118
114
  /** Search text for one failed turn in the transcript. */
@@ -88,6 +88,7 @@ function findNextMarkdownLink(
88
88
  start: linkStart,
89
89
  };
90
90
  }
91
+ return undefined;
91
92
  }
92
93
 
93
94
  function findMarkdownLinkSyntaxRanges(
@@ -148,6 +149,7 @@ function readMarkdownDestination(
148
149
  const href = text.slice(start, index);
149
150
  return href ? { end: index + 1, href } : undefined;
150
151
  }
152
+ return undefined;
151
153
  }
152
154
 
153
155
  function findCommonMarkAutolinks(
@@ -199,6 +201,7 @@ function findNextBareLink(
199
201
  start: match.index,
200
202
  };
201
203
  }
204
+ return undefined;
202
205
  }
203
206
 
204
207
  function findBareLinks(
@@ -6,15 +6,23 @@ import { Card } from "../../components/layout/Card";
6
6
  import { PageHeader } from "../../components/layout/PageHeader";
7
7
  import { dashboardContainerClass } from "../../styles";
8
8
  import { pluginUserPagePath } from "../user/PluginUserPage";
9
+ import {
10
+ ServiceAccountsSection,
11
+ useServiceAccounts,
12
+ } from "./ServiceAccountsSection";
9
13
 
10
14
  /**
11
- * List admin-only plugin pages for a Junior admin.
15
+ * Show service accounts and admin-only plugin pages to a Junior admin.
12
16
  *
13
- * The server hides admin pages from other viewers and refuses to serve them,
14
- * so this page only arranges links.
17
+ * The server hides admin pages and service accounts from other viewers and
18
+ * refuses their requests, so this page only arranges them.
15
19
  */
16
20
  export function AdminPage(props: { pages: PluginUserPageLink[] }) {
17
21
  const adminPages = props.pages.filter((page) => page.navigation === "admin");
22
+ const serviceAccounts = useServiceAccounts();
23
+ const nothingToSet =
24
+ adminPages.length === 0 &&
25
+ serviceAccounts.data?.serviceAccounts.length === 0;
18
26
  return (
19
27
  <div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
20
28
  <section className="mx-auto grid w-full max-w-3xl gap-6">
@@ -28,7 +36,8 @@ export function AdminPage(props: { pages: PluginUserPageLink[] }) {
28
36
  }
29
37
  title="Admin"
30
38
  />
31
- {adminPages.length === 0 ? (
39
+ <ServiceAccountsSection accountsQuery={serviceAccounts} />
40
+ {nothingToSet ? (
32
41
  <Card padding="md">
33
42
  <div className="flex items-center gap-4">
34
43
  <div className="grid size-10 shrink-0 place-items-center rounded-md border border-dashboard-border bg-dashboard-fill-soft text-dashboard-text-muted">
@@ -39,7 +48,7 @@ export function AdminPage(props: { pages: PluginUserPageLink[] }) {
39
48
  </p>
40
49
  </div>
41
50
  </Card>
42
- ) : (
51
+ ) : adminPages.length === 0 ? null : (
43
52
  <div className="grid gap-3">
44
53
  {adminPages.map((page) => (
45
54
  <Link
@@ -0,0 +1,231 @@
1
+ import {
2
+ useMutation,
3
+ useQuery,
4
+ useQueryClient,
5
+ type UseQueryResult,
6
+ } from "@tanstack/react-query";
7
+ import { KeyRound } from "lucide-react";
8
+ import type { z } from "zod";
9
+ import {
10
+ disconnectServiceAccountResponseSchema,
11
+ serviceAccountListSchema,
12
+ startServiceAccountSignInResponseSchema,
13
+ type serviceAccountSchema,
14
+ } from "@sentry/junior/api/schema";
15
+ import { getDashboardAgentName } from "../../agentName";
16
+ import { Button } from "../../components/Button";
17
+ import { InlineError } from "../../components/InlineError";
18
+ import { StatusChip } from "../../components/StatusChip";
19
+ import { Card } from "../../components/layout/Card";
20
+ import { formatRelativeTime } from "../../format";
21
+ import { del, fetchDashboardJson, post } from "../../http";
22
+
23
+ type ServiceAccount = z.infer<typeof serviceAccountSchema>;
24
+
25
+ const serviceAccountsQueryKey = ["dashboard", "service-accounts"] as const;
26
+
27
+ function serviceAccountPath(provider: string): string {
28
+ return `/api/service-accounts/${encodeURIComponent(provider)}`;
29
+ }
30
+
31
+ /** Load the plugins that support a service account, with their status. */
32
+ export function useServiceAccounts() {
33
+ return useQuery({
34
+ queryKey: serviceAccountsQueryKey,
35
+ queryFn: ({ signal }) =>
36
+ fetchDashboardJson(
37
+ serviceAccountListSchema,
38
+ "/api/service-accounts",
39
+ signal,
40
+ ),
41
+ // OAuth finishes in another tab. Refresh when the admin returns.
42
+ refetchOnWindowFocus: true,
43
+ retry: false,
44
+ });
45
+ }
46
+
47
+ function statusText(account: ServiceAccount): string {
48
+ const usage = !account.userOAuth
49
+ ? `Everyone uses this account. People cannot connect their own ${account.displayName} account.`
50
+ : `People who have not connected their own ${account.displayName} account use this account.`;
51
+ if (!account.connected) {
52
+ const blocked = !account.userOAuth
53
+ ? ` ${account.displayName} is not available until you connect it.`
54
+ : "";
55
+ return `Not connected.${blocked} ${usage} Sign in as the shared account, not as yourself.`;
56
+ }
57
+ const by = account.connectedBy ?? "the CLI";
58
+ return `Connected by ${by} ${formatRelativeTime(account.connectedAt)}. ${usage}`;
59
+ }
60
+
61
+ /**
62
+ * Connect and disconnect the shared provider accounts that Junior uses for
63
+ * people who have not connected their own account.
64
+ */
65
+ export function ServiceAccountsSection(props: {
66
+ accountsQuery: UseQueryResult<z.infer<typeof serviceAccountListSchema>>;
67
+ }) {
68
+ const { accountsQuery } = props;
69
+ const queryClient = useQueryClient();
70
+ const connectMutation = useMutation({
71
+ mutationFn: (input: { provider: string; popup: Window | null }) =>
72
+ post(
73
+ startServiceAccountSignInResponseSchema,
74
+ serviceAccountPath(input.provider),
75
+ {},
76
+ ),
77
+ onSuccess: ({ authorizationUrl }, { popup }) => {
78
+ // A blocked popup returns null, so use this tab. A tab the admin
79
+ // closed means they cancelled; keep them on the dashboard.
80
+ if (!popup) {
81
+ window.location.assign(authorizationUrl);
82
+ return;
83
+ }
84
+ if (popup.closed) return;
85
+ popup.opener = null;
86
+ popup.location.href = authorizationUrl;
87
+ },
88
+ onError: (_error, { popup }) => {
89
+ popup?.close();
90
+ },
91
+ });
92
+ const disconnectMutation = useMutation({
93
+ mutationFn: (account: ServiceAccount) =>
94
+ del(
95
+ disconnectServiceAccountResponseSchema,
96
+ serviceAccountPath(account.provider),
97
+ ),
98
+ onSuccess: () =>
99
+ queryClient.invalidateQueries({ queryKey: serviceAccountsQueryKey }),
100
+ });
101
+
102
+ const accounts = accountsQuery.data?.serviceAccounts ?? [];
103
+ if (accountsQuery.isSuccess && accounts.length === 0) {
104
+ return null;
105
+ }
106
+ const busy = connectMutation.isPending || disconnectMutation.isPending;
107
+ const pendingProvider = connectMutation.isPending
108
+ ? connectMutation.variables?.provider
109
+ : disconnectMutation.isPending
110
+ ? disconnectMutation.variables?.provider
111
+ : undefined;
112
+ const listFailed = !accountsQuery.data && accountsQuery.isError;
113
+ const displayedError = connectMutation.isError
114
+ ? "Could not start the sign-in. Try again."
115
+ : disconnectMutation.isError
116
+ ? "Could not disconnect the account. Try again."
117
+ : listFailed
118
+ ? "Could not load service accounts. Try again."
119
+ : undefined;
120
+
121
+ return (
122
+ <Card as="section" padding="md">
123
+ <h2 className="m-0 font-display text-base font-medium text-dashboard-text">
124
+ Service accounts
125
+ </h2>
126
+ <p className="mt-1 mb-4 text-sm text-dashboard-text-muted">
127
+ A shared account that Junior uses for a plugin. It can do only what its
128
+ plugin allows, usually read. Everyone who can talk to{" "}
129
+ {getDashboardAgentName()} can use it, so give it only access that all of
130
+ them may have.
131
+ </p>
132
+ {displayedError ? (
133
+ <InlineError className={listFailed ? undefined : "mb-4"}>
134
+ {displayedError}
135
+ </InlineError>
136
+ ) : null}
137
+ {listFailed ? null : accountsQuery.isPending ? (
138
+ <p className="m-0 text-sm text-dashboard-text-muted">
139
+ Loading service accounts…
140
+ </p>
141
+ ) : (
142
+ <div className="space-y-2">
143
+ {accounts.map((account) => (
144
+ <div
145
+ className="flex items-center gap-3 rounded-md border border-dashboard-border-strong p-3"
146
+ key={account.provider}
147
+ >
148
+ <KeyRound
149
+ aria-hidden="true"
150
+ className="shrink-0 text-dashboard-accent"
151
+ size={16}
152
+ />
153
+ <div className="min-w-0 flex-1">
154
+ <div className="flex flex-wrap items-center gap-2">
155
+ <p className="m-0 truncate text-sm font-semibold">
156
+ {account.displayName}
157
+ </p>
158
+ <StatusChip
159
+ size="compact"
160
+ tone={
161
+ account.connected
162
+ ? "success"
163
+ : !account.userOAuth
164
+ ? "warning"
165
+ : "neutral"
166
+ }
167
+ >
168
+ {account.connected ? "connected" : "not connected"}
169
+ </StatusChip>
170
+ {!account.userOAuth ? (
171
+ <StatusChip size="compact" tone="neutral">
172
+ shared only
173
+ </StatusChip>
174
+ ) : null}
175
+ </div>
176
+ <p className="mt-1 mb-0 break-words text-xs text-dashboard-text-muted">
177
+ {statusText(account)}
178
+ </p>
179
+ <p className="mt-1 mb-0 break-words font-mono text-xs text-dashboard-text-faint">
180
+ {account.tools.join(", ")}
181
+ </p>
182
+ </div>
183
+ {account.connected ? (
184
+ <Button
185
+ aria-busy={
186
+ disconnectMutation.isPending &&
187
+ pendingProvider === account.provider
188
+ }
189
+ aria-label={`Disconnect the ${account.displayName} service account`}
190
+ disabled={busy}
191
+ onClick={() => {
192
+ connectMutation.reset();
193
+ disconnectMutation.mutate(account);
194
+ }}
195
+ tone="danger"
196
+ >
197
+ Disconnect
198
+ </Button>
199
+ ) : null}
200
+ <Button
201
+ aria-busy={
202
+ connectMutation.isPending &&
203
+ pendingProvider === account.provider
204
+ }
205
+ aria-label={`${account.connected ? "Reconnect" : "Connect"} the ${account.displayName} service account`}
206
+ disabled={busy}
207
+ onClick={() => {
208
+ disconnectMutation.reset();
209
+ // Open the tab during the click so popup blockers allow
210
+ // it, then point it at the provider.
211
+ const popup = window.open("about:blank", "_blank");
212
+ connectMutation.mutate({
213
+ provider: account.provider,
214
+ popup,
215
+ });
216
+ }}
217
+ >
218
+ {connectMutation.isPending &&
219
+ pendingProvider === account.provider
220
+ ? "Connecting…"
221
+ : account.connected
222
+ ? "Reconnect"
223
+ : "Connect"}
224
+ </Button>
225
+ </div>
226
+ ))}
227
+ </div>
228
+ )}
229
+ </Card>
230
+ );
231
+ }
@@ -141,10 +141,10 @@ export function AutomationOutcomeFields(props: {
141
141
  disabled={index === 0}
142
142
  onClick={() => {
143
143
  const next = [...props.value];
144
- [next[index - 1], next[index]] = [
145
- next[index],
146
- next[index - 1],
147
- ];
144
+ const previous = next[index - 1];
145
+ if (!previous) return;
146
+ next[index - 1] = outcome;
147
+ next[index] = previous;
148
148
  props.onChange(next);
149
149
  }}
150
150
  >
@@ -53,7 +53,7 @@ export function scheduleDraft(
53
53
  ...fields,
54
54
  timezone: value.schedule.timezone,
55
55
  time: `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`,
56
- weekdays: days?.map((day) => weekdays[day]),
56
+ weekdays: days?.flatMap((day) => weekdays[day] ?? []),
57
57
  };
58
58
  }
59
59
  if (value.nextRunAtMs) {
@@ -23,6 +23,12 @@ const toneColors = {
23
23
  warning: "#fbbf24",
24
24
  } as const;
25
25
 
26
+ function seriesColor(series: Widget["series"][number], index: number): string {
27
+ return series.tone
28
+ ? toneColors[series.tone]
29
+ : (colors[index % colors.length] ?? colors[0]);
30
+ }
31
+
26
32
  /** Render a validated plugin-owned categorical bar chart. */
27
33
  export function PluginBarChart(props: {
28
34
  range?: TimeRangeDays;
@@ -69,9 +75,7 @@ export function PluginBarChart(props: {
69
75
  <ChartLegend
70
76
  ariaLabel="Chart legend"
71
77
  items={widget.series.map((series, index) => ({
72
- color: series.tone
73
- ? toneColors[series.tone]
74
- : colors[index % colors.length],
78
+ color: seriesColor(series, index),
75
79
  key: series.key,
76
80
  label: series.label,
77
81
  }))}
@@ -122,11 +126,7 @@ export function PluginBarChart(props: {
122
126
  >
123
127
  <rect
124
128
  aria-label={`${formatActivityCategoryTooltipLabel(category.label)}, ${series.label}: ${formatted}`}
125
- fill={
126
- series.tone
127
- ? toneColors[series.tone]
128
- : colors[seriesIndex % colors.length]
129
- }
129
+ fill={seriesColor(series, seriesIndex)}
130
130
  height={renderedHeight}
131
131
  opacity={value ? 0.82 : 0.1}
132
132
  rx="1.5"
package/src/client.tsx CHANGED
@@ -22,18 +22,18 @@ class DashboardErrorBoundary extends Component<
22
22
  { children: ReactNode },
23
23
  ErrorBoundaryState
24
24
  > {
25
- state: ErrorBoundaryState = { error: null };
25
+ override state: ErrorBoundaryState = { error: null };
26
26
 
27
27
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
28
28
  return { error };
29
29
  }
30
30
 
31
- componentDidCatch(error: Error, errorInfo: ErrorInfo) {
31
+ override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
32
32
  const stack = error.stack ?? errorInfo.componentStack;
33
33
  window.__JUNIOR_DASHBOARD_SHOW_ERROR__?.(stack ? new Error(stack) : error);
34
34
  }
35
35
 
36
- render() {
36
+ override render() {
37
37
  if (this.state.error) {
38
38
  return <DashboardErrorPanel error={this.state.error} />;
39
39
  }