@checkstack/notification-frontend 0.4.4 → 0.4.5

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 CHANGED
@@ -1,5 +1,155 @@
1
1
  # @checkstack/notification-frontend
2
2
 
3
+ ## 0.4.5
4
+
5
+ ### Patch Changes
6
+
7
+ - f23f3c9: Add per-channel notification delivery-attempt tracking
8
+ (Phase 8 of the v1 polishing plan). The external dispatch loop now
9
+ persists one row per `strategy.send(...)` call into a new
10
+ `notification_delivery_attempts` table - both successes and
11
+ failures - so silent delivery breakage (misconfigured webhooks, dead
12
+ channels) becomes queryable instead of buried in logs.
13
+
14
+ - `@checkstack/notification-backend` adds the
15
+ `notification_delivery_attempts` table, the matching Drizzle
16
+ migration, and a new `dispatchWithAttempt` helper that wraps every
17
+ external `strategy.send(...)` with duration measurement and
18
+ best-effort row persistence. The insert is intentionally
19
+ fire-and-forget: if writing the attempt row itself errors, the
20
+ dispatch loop logs and continues so visibility tracking can never
21
+ introduce a _new_ silent failure.
22
+ - `@checkstack/notification-common` exports a new
23
+ `DeliveryAttemptSchema` zod schema, the
24
+ `ListDeliveryAttemptsInputSchema =
25
+ PaginationInput.extend({ notificationId })` input, and a new
26
+ `getDeliveryAttempts` procedure on the contract. The procedure is
27
+ gated by the existing `notificationAccess.admin`
28
+ (`notification:manage`) access rule - no new permission was
29
+ introduced.
30
+ - `@checkstack/notification-frontend` adds a minimal admin-only
31
+ `DeliveryAttemptsPage` (route id `notification.deliveryAttempts`,
32
+ path `/notifications/delivery-attempts`) and an "Open inspector"
33
+ link from the Notification Settings page for users with
34
+ `notification:manage`. No client-side `isAdmin` gate - the FORBIDDEN
35
+ case is rendered via the standard error-state branch on the page,
36
+ enforced by the contract.
37
+
38
+ Visibility only: there is no retry mechanism in this phase. A
39
+ `failure` row is a final outcome an admin actions manually
40
+ (re-trigger the source event, fix the misconfigured channel).
41
+ Automated retries are deferred to v1.1.
42
+
43
+ Strategy errors thrown during `send(...)` are persisted via
44
+ `extractErrorMessage(error)` so secrets potentially embedded in raw
45
+ error objects (webhook URLs, OAuth tokens reachable from the strategy
46
+ send context) are not stored verbatim.
47
+
48
+ See the new
49
+ `docs/src/content/docs/backend/notification-delivery.md` page for the
50
+ full surface description.
51
+
52
+ - f23f3c9: Establish the canonical optimistic-UI pattern for oRPC mutations
53
+ (`onMutate` snapshot / patch, `onError` rollback, `onSettled`
54
+ invalidate) and apply it to the two highest-frequency toggles where
55
+ perceived latency was most visible:
56
+
57
+ - `markAsRead` on the Notifications page — clicking the check on a
58
+ notification card now flips the read state immediately instead of
59
+ waiting for the round-trip.
60
+ - `pauseConfiguration` / `resumeConfiguration` on the Health Check
61
+ Config page — pause/resume now flip the row's badge instantly,
62
+ rolling back on server error.
63
+
64
+ The wrapper type for `useMutation` on each plugin client gained an
65
+ optional `TContext` generic so optimistic sites can return a snapshot
66
+ from `onMutate` and consume it in `onError` without `unknown` casts.
67
+ The runtime behaviour and the auto-invalidation on success are
68
+ unchanged; the change is additive on the type surface only.
69
+
70
+ Full pattern and "when NOT to use it" guidance live in
71
+ `docs/frontend/optimistic-updates.md`.
72
+
73
+ - f23f3c9: Sweep every paginated `*-common` contract onto the canonical
74
+ `PaginationInput` / `PaginatedResult` from `@checkstack/common` and
75
+ remove the now-unused legacy exports.
76
+
77
+ **BREAKING CHANGE** - `@checkstack/common` drops the deprecated
78
+ `PaginationInputSchema`, `paginatedOutput`, and `PaginatedResponse`
79
+ symbols. Callers must consume `PaginationInput` (input) and
80
+ `PaginatedResult(itemSchema)` (output) instead. The canonical input is
81
+ `{ limit (1-100, default 20), offset (>= 0, default 0) }`; the
82
+ canonical output envelope is
83
+ `{ items, total, limit, offset }`.
84
+
85
+ **BREAKING CHANGE** - `@checkstack/notification-common` migrates
86
+ `getNotifications` off the legacy `PaginationInputSchema`
87
+ (`{ limit, offset, unreadOnly }` with output `{ notifications, total }`)
88
+ onto `ListNotificationsInputSchema =
89
+ PaginationInput.extend({ unreadOnly })` and
90
+ `PaginatedResult(NotificationSchema)`. The output key changes from
91
+ `notifications` to `items`, and `limit` / `offset` are now echoed on
92
+ the response. The `PaginationInput` type alias previously exported
93
+ from `notification-common` is removed - use `ListNotificationsInput`
94
+ or the canonical `PaginationInput` from `@checkstack/common`.
95
+
96
+ **BREAKING CHANGE** - `@checkstack/integration-common` migrates
97
+ `listSubscriptions` (inline `{ page, pageSize, ... }` -> output
98
+ `{ subscriptions, total }`) and `getDeliveryLogs` (via
99
+ `DeliveryLogQueryInputSchema` `{ subscriptionId?, eventType?, status?,
100
+ page, pageSize }` -> output `{ logs, total }`) onto the canonical
101
+ `PaginationInput.extend({...})` input and
102
+ `PaginatedResult(itemSchema)` output. External callers must switch
103
+ from `{ page, pageSize }` to `{ limit, offset }` and read response
104
+ items from `data.items` (no more `data.subscriptions` / `data.logs`).
105
+
106
+ The matching `*-backend` handlers were updated to consume the new
107
+ input shape (`offset` arithmetic in lieu of `(page - 1) * pageSize`)
108
+ and to echo `limit` / `offset` on the response. The `*-frontend` call
109
+ sites in `NotificationsPage`, `NotificationBell`, `IntegrationsPage`,
110
+ and `DeliveryLogsPage` were updated to send the new input shape and
111
+ read `data.items`.
112
+
113
+ - f23f3c9: Gate decorative motion and blur effects behind
114
+ `usePerformance().isLowPower` on a focused set of high-traffic plugin
115
+ pages (Dashboard, Dependency map, System node, Notification bell,
116
+ Announcement banner / cards, Anomaly field overrides editor, SLO
117
+ attribution chart, Catalog droppable group). Hover scales, backdrop
118
+ blurs, `animate-pulse`/`animate-ping` accents, and entry transitions
119
+ now drop to static states on low-power devices; functional UX
120
+ transitions (Drawer/Dialog open-close, colour transitions) are left
121
+ alone.
122
+
123
+ Standardise the post-mutation error-toast voice on plugin pages by
124
+ migrating multi-clause `toast.error(extractErrorMessage(error, "Failed
125
+ to X"))` call sites onto the `toastError(toast, "Failed to X", error)`
126
+ helper from `@checkstack/ui`. The helper applies the canonical
127
+ `"action: message"` prefix and 100-character truncation in one place,
128
+ and the now-orphaned `extractErrorMessage` imports are dropped from
129
+ the affected files. No business logic or component APIs changed.
130
+
131
+ - f23f3c9: Standardise the empty / loading / error story on key list pages using
132
+ the shared `ListEmptyState`, `QueryErrorState`, and `Skeleton`
133
+ primitives from `@checkstack/ui`. Each affected page now branches
134
+ through the same `isLoading -> isError -> empty -> data` ladder, so
135
+ failed queries surface a retry-able inline error instead of silently
136
+ rendering an empty table, and loading states match the final layout
137
+ rather than flashing a generic spinner. No layout, business logic, or
138
+ query input shapes changed.
139
+ - Updated dependencies [f23f3c9]
140
+ - Updated dependencies [f23f3c9]
141
+ - Updated dependencies [f23f3c9]
142
+ - Updated dependencies [f23f3c9]
143
+ - Updated dependencies [f23f3c9]
144
+ - Updated dependencies [f23f3c9]
145
+ - @checkstack/common@0.11.0
146
+ - @checkstack/auth-frontend@0.6.5
147
+ - @checkstack/notification-common@1.2.0
148
+ - @checkstack/frontend-api@0.5.2
149
+ - @checkstack/ui@1.10.0
150
+ - @checkstack/tips-frontend@0.2.5
151
+ - @checkstack/signal-frontend@0.1.4
152
+
3
153
  ## 0.4.4
4
154
 
5
155
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-frontend",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.tsx",
@@ -13,13 +13,13 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/auth-frontend": "0.6.3",
16
+ "@checkstack/auth-frontend": "0.6.4",
17
17
  "@checkstack/common": "0.10.0",
18
18
  "@checkstack/frontend-api": "0.5.1",
19
- "@checkstack/notification-common": "1.1.0",
19
+ "@checkstack/notification-common": "1.1.1",
20
20
  "@checkstack/signal-frontend": "0.1.3",
21
- "@checkstack/tips-frontend": "0.2.3",
22
- "@checkstack/ui": "1.8.3",
21
+ "@checkstack/tips-frontend": "0.2.4",
22
+ "@checkstack/ui": "1.9.0",
23
23
  "lucide-react": "^0.344.0",
24
24
  "react": "^18.2.0",
25
25
  "react-router-dom": "^6.22.0"
@@ -15,6 +15,8 @@ import {
15
15
  stripMarkdown,
16
16
  useToast,
17
17
  useIsMobile,
18
+ usePerformance,
19
+ cn,
18
20
  } from "@checkstack/ui";
19
21
  import { useApi, usePluginClient } from "@checkstack/frontend-api";
20
22
  import { resolveRoute } from "@checkstack/common";
@@ -29,6 +31,7 @@ import { ChevronDown, ChevronUp } from "lucide-react";
29
31
  import { authApiRef } from "@checkstack/auth-frontend/api";
30
32
 
31
33
  export const NotificationBell = () => {
34
+ const { isLowPower } = usePerformance();
32
35
  const authApi = useApi(authApiRef);
33
36
  const { data: session, isPending: isAuthLoading } = authApi.useSession();
34
37
  const notificationClient = usePluginClient(NotificationApi);
@@ -72,7 +75,7 @@ export const NotificationBell = () => {
72
75
  const markAsReadMutation = notificationClient.markAsRead.useMutation();
73
76
 
74
77
  const unreadCount = unreadData?.count ?? 0;
75
- const recentNotifications = notificationsData?.notifications ?? [];
78
+ const recentNotifications = notificationsData?.items ?? [];
76
79
 
77
80
  const handleMarkAllAsRead = async () => {
78
81
  try {
@@ -102,10 +105,17 @@ export const NotificationBell = () => {
102
105
 
103
106
  const trigger = (
104
107
  <Button variant="ghost" size="icon" className="relative group">
105
- <Bell className="h-5 w-5 transition-transform group-hover:scale-110" />
108
+ <Bell
109
+ className={cn(
110
+ "h-5 w-5",
111
+ !isLowPower && "transition-transform group-hover:scale-110",
112
+ )}
113
+ />
106
114
  {unreadCount > 0 && (
107
115
  <span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center">
108
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
116
+ {!isLowPower && (
117
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
118
+ )}
109
119
  <Badge
110
120
  variant="destructive"
111
121
  className="relative h-5 min-w-[20px] flex items-center justify-center p-0 text-xs font-bold"
package/src/index.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { NotificationBell } from "./components/NotificationBell";
12
12
  import { NotificationsPage } from "./pages/NotificationsPage";
13
13
  import { NotificationSettingsPage } from "./pages/NotificationSettingsPage";
14
+ import { DeliveryAttemptsPage } from "./pages/DeliveryAttemptsPage";
14
15
  import { NotificationUserMenuItems } from "./components/UserMenuItems";
15
16
 
16
17
  // Plugin-extensible kind registry — domain frontends call `registerSubjectKind`
@@ -47,6 +48,10 @@ export const notificationPlugin = createFrontendPlugin({
47
48
  route: notificationRoutes.routes.settings,
48
49
  element: <NotificationSettingsPage />,
49
50
  },
51
+ {
52
+ route: notificationRoutes.routes.deliveryAttempts,
53
+ element: <DeliveryAttemptsPage />,
54
+ },
50
55
  ],
51
56
  extensions: [
52
57
  {
@@ -0,0 +1,197 @@
1
+ import { useState } from "react";
2
+ import { Send } from "lucide-react";
3
+ import {
4
+ PageLayout,
5
+ Badge,
6
+ Button,
7
+ Card,
8
+ Table,
9
+ TableHeader,
10
+ TableBody,
11
+ TableRow,
12
+ TableHead,
13
+ TableCell,
14
+ Alert,
15
+ LoadingSpinner,
16
+ EmptyState,
17
+ } from "@checkstack/ui";
18
+ import { usePluginClient } from "@checkstack/frontend-api";
19
+ import { NotificationApi } from "@checkstack/notification-common";
20
+ import type { DeliveryAttempt } from "@checkstack/notification-common";
21
+ import { extractErrorMessage } from "@checkstack/common";
22
+
23
+ const PAGE_SIZE = 25;
24
+ const ERROR_MESSAGE_MAX = 120;
25
+
26
+ /**
27
+ * Truncate a sanitised error message to keep the table column tidy.
28
+ * The full message is still available on hover via `title`.
29
+ */
30
+ const truncate = (value: string, max: number): string =>
31
+ value.length > max ? `${value.slice(0, max - 1)}…` : value;
32
+
33
+ /**
34
+ * Format an attempt timestamp as local-time with a relative hint.
35
+ * Mirrors `NotificationsPage`'s `formatDate` voice without depending
36
+ * on it (a tiny helper, not worth a shared util yet).
37
+ */
38
+ const formatAttemptedAt = (raw: Date | string): string => {
39
+ const d = new Date(raw);
40
+ return d.toLocaleString();
41
+ };
42
+
43
+ const StatusBadge = ({ status }: { status: DeliveryAttempt["status"] }) => {
44
+ if (status === "success") {
45
+ return <Badge variant="success">Success</Badge>;
46
+ }
47
+ return <Badge variant="destructive">Failure</Badge>;
48
+ };
49
+
50
+ /**
51
+ * Admin-only visibility surface for per-channel notification delivery
52
+ * attempts. Shows the most recent attempts (newest first); failures
53
+ * expose the silent-failure mode the dispatch loop swallowed pre-v1.
54
+ *
55
+ * Scope is intentionally minimal — no filter chips, charts, or
56
+ * exports. Retries are deferred to v1.1; this is visibility only.
57
+ */
58
+ export const DeliveryAttemptsPage = () => {
59
+ const notificationClient = usePluginClient(NotificationApi);
60
+ const [page, setPage] = useState(0);
61
+
62
+ const {
63
+ data,
64
+ isLoading,
65
+ isError,
66
+ error,
67
+ refetch,
68
+ } = notificationClient.getDeliveryAttempts.useQuery({
69
+ limit: PAGE_SIZE,
70
+ offset: page * PAGE_SIZE,
71
+ });
72
+
73
+ const attempts = data?.items ?? [];
74
+ const total = data?.total ?? 0;
75
+ const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
76
+
77
+ // No client-side `isAdmin` gate: the `getDeliveryAttempts` procedure
78
+ // is locked behind `notificationAccess.admin` at the contract layer
79
+ // (see `core/notification-common/src/rpc-contract.ts`). A caller
80
+ // without the `notification:manage` access rule receives FORBIDDEN
81
+ // from the server, which we render below via the standard
82
+ // error-state branch. The nav entry is hidden cosmetically by the
83
+ // existing access check on `NotificationSettingsPage` — security is
84
+ // enforced by the contract, not the UI.
85
+ return (
86
+ <PageLayout title="Delivery Attempts" icon={Send}>
87
+ <div className="space-y-4">
88
+ <p className="text-sm text-muted-foreground">
89
+ Per-channel outcomes from the external notification dispatch
90
+ loop. Failures are surfaced here so silent delivery breakage
91
+ (misconfigured webhooks, dead channels) becomes actionable.
92
+ Retries are not implemented yet - this is visibility only.
93
+ </p>
94
+
95
+ {isLoading ? (
96
+ <Card className="p-8">
97
+ <div className="flex justify-center">
98
+ <LoadingSpinner size="md" />
99
+ </div>
100
+ </Card>
101
+ ) : isError ? (
102
+ <Alert variant="error">
103
+ <div className="flex items-center justify-between gap-4">
104
+ <span>
105
+ Failed to load delivery attempts:{" "}
106
+ {extractErrorMessage(error, "Unknown error")}
107
+ </span>
108
+ <Button
109
+ variant="outline"
110
+ size="sm"
111
+ onClick={() => {
112
+ void refetch();
113
+ }}
114
+ >
115
+ Retry
116
+ </Button>
117
+ </div>
118
+ </Alert>
119
+ ) : attempts.length === 0 ? (
120
+ <EmptyState
121
+ icon={<Send className="h-12 w-12" />}
122
+ title="No delivery attempts yet"
123
+ description="Once notifications are dispatched via an external channel, each attempt will appear here."
124
+ />
125
+ ) : (
126
+ <Card className="p-0 overflow-hidden">
127
+ <Table>
128
+ <TableHeader>
129
+ <TableRow>
130
+ <TableHead>Attempted at</TableHead>
131
+ <TableHead>Strategy</TableHead>
132
+ <TableHead>Status</TableHead>
133
+ <TableHead>Duration</TableHead>
134
+ <TableHead>Error</TableHead>
135
+ </TableRow>
136
+ </TableHeader>
137
+ <TableBody>
138
+ {attempts.map((attempt) => (
139
+ <TableRow key={attempt.id}>
140
+ <TableCell className="whitespace-nowrap">
141
+ {formatAttemptedAt(attempt.attemptedAt)}
142
+ </TableCell>
143
+ <TableCell className="font-mono text-xs">
144
+ {attempt.strategyQualifiedId}
145
+ </TableCell>
146
+ <TableCell>
147
+ <StatusBadge status={attempt.status} />
148
+ </TableCell>
149
+ <TableCell className="whitespace-nowrap text-muted-foreground">
150
+ {attempt.durationMs}ms
151
+ </TableCell>
152
+ <TableCell
153
+ className="max-w-md text-xs text-muted-foreground"
154
+ title={attempt.errorMessage ?? undefined}
155
+ >
156
+ {attempt.errorMessage
157
+ ? truncate(attempt.errorMessage, ERROR_MESSAGE_MAX)
158
+ : "-"}
159
+ </TableCell>
160
+ </TableRow>
161
+ ))}
162
+ </TableBody>
163
+ </Table>
164
+ </Card>
165
+ )}
166
+
167
+ {total > PAGE_SIZE && (
168
+ <div className="flex items-center justify-center gap-2">
169
+ <Button
170
+ variant="outline"
171
+ size="sm"
172
+ disabled={page === 0}
173
+ onClick={() => {
174
+ setPage((p) => Math.max(0, p - 1));
175
+ }}
176
+ >
177
+ Previous
178
+ </Button>
179
+ <span className="text-sm text-muted-foreground">
180
+ Page {page + 1} of {pageCount}
181
+ </span>
182
+ <Button
183
+ variant="outline"
184
+ size="sm"
185
+ disabled={(page + 1) * PAGE_SIZE >= total}
186
+ onClick={() => {
187
+ setPage((p) => p + 1);
188
+ }}
189
+ >
190
+ Next
191
+ </Button>
192
+ </div>
193
+ )}
194
+ </div>
195
+ </PageLayout>
196
+ );
197
+ };
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect } from "react";
2
- import { Bell, Clock, Zap, Send } from "lucide-react";
2
+ import { Link } from "react-router-dom";
3
+ import { Bell, Clock, Zap, Send, Activity } from "lucide-react";
3
4
  import {
4
5
  PageLayout,
5
6
  Card,
@@ -17,8 +18,10 @@ import type { EnrichedSubscription } from "@checkstack/notification-common";
17
18
  import {
18
19
  NotificationApi,
19
20
  notificationAccess,
21
+ notificationRoutes,
20
22
  pluginMetadata as notificationPluginMetadata,
21
23
  } from "@checkstack/notification-common";
24
+ import { resolveRoute } from "@checkstack/common";
22
25
  import { TipBanner } from "@checkstack/tips-frontend";
23
26
  import {
24
27
  StrategyCard,
@@ -389,6 +392,34 @@ export const NotificationSettingsPage = () => {
389
392
  </section>
390
393
  )}
391
394
 
395
+ {/* Delivery Attempts inspector link - Admin only */}
396
+ {isAdmin && (
397
+ <section>
398
+ <SectionHeader
399
+ title="Delivery Attempts"
400
+ description="Inspect per-channel delivery outcomes for recent notifications (admin only)."
401
+ icon={<Activity className="h-5 w-5" />}
402
+ />
403
+ <Card className="p-4">
404
+ <div className="flex items-center justify-between gap-4">
405
+ <p className="text-sm text-muted-foreground">
406
+ See every external `strategy.send(...)` outcome - useful
407
+ for debugging silent failures on misconfigured channels.
408
+ </p>
409
+ <Button asChild variant="outline" size="sm">
410
+ <Link
411
+ to={resolveRoute(
412
+ notificationRoutes.routes.deliveryAttempts,
413
+ )}
414
+ >
415
+ Open inspector
416
+ </Link>
417
+ </Button>
418
+ </div>
419
+ </Card>
420
+ </section>
421
+ )}
422
+
392
423
  {/* Retention Policy - Admin only */}
393
424
  {isAdmin && retentionSchema && (
394
425
  <section>
@@ -1,4 +1,4 @@
1
- import { useState, useCallback } from "react";
1
+ import { useState, useCallback, useMemo } from "react";
2
2
  import { Link } from "react-router-dom";
3
3
  import { Bell, Check, Trash2, ChevronDown, ChevronUp } from "lucide-react";
4
4
  import {
@@ -6,7 +6,11 @@ import {
6
6
  Badge,
7
7
  Button,
8
8
  Card,
9
+ ListEmptyState,
10
+ QueryErrorState,
11
+ Skeleton,
9
12
  useToast,
13
+ toastError,
10
14
  Popover,
11
15
  PopoverContent,
12
16
  PopoverTrigger,
@@ -14,16 +18,27 @@ import {
14
18
  MenuCloseContext,
15
19
  Markdown,
16
20
  } from "@checkstack/ui";
17
- import { usePluginClient } from "@checkstack/frontend-api";
21
+ import { usePluginClient, useQueryClient } from "@checkstack/frontend-api";
18
22
  import type { Notification } from "@checkstack/notification-common";
19
23
  import { NotificationApi } from "@checkstack/notification-common";
20
- import { extractErrorMessage } from "@checkstack/common";
24
+ import { extractErrorMessage, type InferClient } from "@checkstack/common";
21
25
  import { NotificationSubjects } from "../components/NotificationSubjects";
22
26
  import { groupByCollapseKey } from "../components/collapse";
23
27
  import { CollapsedGroupTimeline } from "../components/CollapsedGroupTimeline";
24
28
 
29
+ /**
30
+ * Cached output of the `notification.getNotifications` query, derived
31
+ * directly from the contract so a future change to the procedure's
32
+ * output shape surfaces as a typecheck error in this file rather than
33
+ * a runtime mismatch between the cache and the optimistic patch.
34
+ */
35
+ type NotificationsQueryData = Awaited<
36
+ ReturnType<InferClient<typeof NotificationApi>["getNotifications"]>
37
+ >;
38
+
25
39
  export const NotificationsPage = () => {
26
40
  const notificationClient = usePluginClient(NotificationApi);
41
+ const queryClient = useQueryClient();
27
42
  const toast = useToast();
28
43
 
29
44
  const [filter, setFilter] = useState<"all" | "unread">("all");
@@ -46,30 +61,79 @@ export const NotificationsPage = () => {
46
61
  const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
47
62
  const pageSize = 20;
48
63
 
64
+ // Query input — captured once so the loader and the optimistic
65
+ // `markAsRead` patch agree on the exact query-key oRPC builds. Changes
66
+ // to filter/page rebuild the memo, which rebuilds the key, which keeps
67
+ // the optimistic write aimed at the cache entry the user is looking at.
68
+ const notificationsQueryInput = useMemo(
69
+ () => ({
70
+ limit: pageSize,
71
+ offset: page * pageSize,
72
+ unreadOnly: filter === "unread",
73
+ }),
74
+ [page, pageSize, filter],
75
+ );
76
+
77
+ // Mirrors oRPC's `generateOperationKey([path], { type, input })` shape;
78
+ // see `docs/frontend/optimistic-updates.md` for the contract.
79
+ const notificationsQueryKey = useMemo(
80
+ () =>
81
+ [
82
+ ["notification", "getNotifications"],
83
+ { input: notificationsQueryInput, type: "query" },
84
+ ] as const,
85
+ [notificationsQueryInput],
86
+ );
87
+
49
88
  // Query: Fetch notifications
89
+ const notificationsQuery =
90
+ notificationClient.getNotifications.useQuery(notificationsQueryInput);
50
91
  const {
51
92
  data: notificationsData,
52
93
  isLoading: loading,
53
94
  refetch,
54
- } = notificationClient.getNotifications.useQuery({
55
- limit: pageSize,
56
- offset: page * pageSize,
57
- unreadOnly: filter === "unread",
58
- });
95
+ } = notificationsQuery;
59
96
 
60
- const notifications = notificationsData?.notifications ?? [];
97
+ const notifications = notificationsData?.items ?? [];
61
98
  const total = notificationsData?.total ?? 0;
62
99
 
63
- // Mutation: Mark as read
100
+ // Mutation: Mark as read — optimistic.
101
+ //
102
+ // High-frequency click; the perceived latency win matters. Four-step
103
+ // pattern per `docs/frontend/optimistic-updates.md`:
104
+ // 1. onMutate: cancel in-flight refetches, snapshot, patch.
105
+ // 2. onError: roll back from the snapshot, surface a toast.
106
+ // 3. onSettled: invalidate the exact key so the cache reconciles
107
+ // with server truth on both branches.
108
+ // 4. No success toast — the row fades; that IS the feedback.
64
109
  const markAsReadMutation = notificationClient.markAsRead.useMutation({
65
- onSuccess: () => {
66
- void refetch();
67
- toast.success("Notification marked as read");
110
+ onMutate: async ({ notificationId }) => {
111
+ await queryClient.cancelQueries({ queryKey: notificationsQueryKey });
112
+ const previous =
113
+ queryClient.getQueryData<NotificationsQueryData>(notificationsQueryKey);
114
+ if (previous) {
115
+ queryClient.setQueryData<NotificationsQueryData>(
116
+ notificationsQueryKey,
117
+ {
118
+ ...previous,
119
+ items: previous.items.map((n) =>
120
+ notificationId === undefined || n.id === notificationId
121
+ ? { ...n, isRead: true }
122
+ : n,
123
+ ),
124
+ },
125
+ );
126
+ }
127
+ return { previous };
68
128
  },
69
- onError: (error) => {
70
- toast.error(
71
- extractErrorMessage(error, "Failed to mark as read"),
72
- );
129
+ onError: (error, _vars, ctx) => {
130
+ if (ctx?.previous) {
131
+ queryClient.setQueryData(notificationsQueryKey, ctx.previous);
132
+ }
133
+ toastError(toast, "Failed to mark as read", error);
134
+ },
135
+ onSettled: () => {
136
+ void queryClient.invalidateQueries({ queryKey: notificationsQueryKey });
73
137
  },
74
138
  });
75
139
 
@@ -149,7 +213,7 @@ export const NotificationsPage = () => {
149
213
  };
150
214
 
151
215
  return (
152
- <PageLayout title="Notifications" icon={Bell} loading={loading}>
216
+ <PageLayout title="Notifications" icon={Bell}>
153
217
  <div className="space-y-4">
154
218
  {/* Header with filters */}
155
219
  <div className="flex items-center justify-between">
@@ -204,11 +268,40 @@ export const NotificationsPage = () => {
204
268
  </div>
205
269
 
206
270
  {/* Notifications list */}
207
- {notifications.length === 0 ? (
208
- <Card className="p-8 text-center text-muted-foreground">
209
- <Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
210
- <p>No notifications</p>
211
- </Card>
271
+ {loading ? (
272
+ <div className="space-y-2">
273
+ {Array.from({ length: 3 }, (_, index) => (
274
+ <Card key={index} className="p-4">
275
+ <div className="flex items-start justify-between gap-4">
276
+ <div className="flex-1 min-w-0 space-y-2">
277
+ <div className="flex items-center gap-2">
278
+ <Skeleton className="h-5 w-16 rounded-full" />
279
+ <Skeleton className="h-3 w-20" />
280
+ </div>
281
+ <Skeleton className="h-5 w-2/3" />
282
+ <Skeleton className="h-4 w-full" />
283
+ <Skeleton className="h-4 w-1/2" />
284
+ </div>
285
+ <div className="flex items-center gap-1">
286
+ <Skeleton className="h-8 w-8" />
287
+ <Skeleton className="h-8 w-8" />
288
+ </div>
289
+ </div>
290
+ </Card>
291
+ ))}
292
+ </div>
293
+ ) : notificationsQuery.isError ? (
294
+ <QueryErrorState
295
+ error={notificationsQuery.error}
296
+ onRetry={() => void notificationsQuery.refetch()}
297
+ resource="notifications"
298
+ />
299
+ ) : notifications.length === 0 ? (
300
+ <ListEmptyState
301
+ resource="notifications"
302
+ description="You're all caught up. New notifications about systems you're subscribed to will show up here."
303
+ icon={<Bell className="h-10 w-10" />}
304
+ />
212
305
  ) : (
213
306
  <div className="space-y-2">
214
307
  {groupByCollapseKey(notifications).map((group) => {