@checkstack/notification-frontend 0.4.4 → 0.4.6

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,176 @@
1
1
  # @checkstack/notification-frontend
2
2
 
3
+ ## 0.4.6
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [e2d6f25]
8
+ - Updated dependencies [41c77f4]
9
+ - Updated dependencies [41c77f4]
10
+ - Updated dependencies [41c77f4]
11
+ - Updated dependencies [41c77f4]
12
+ - Updated dependencies [4832e33]
13
+ - Updated dependencies [6d52276]
14
+ - Updated dependencies [35bc682]
15
+ - Updated dependencies [c39ee69]
16
+ - @checkstack/frontend-api@0.6.0
17
+ - @checkstack/ui@1.11.0
18
+ - @checkstack/common@0.12.0
19
+ - @checkstack/auth-frontend@0.6.6
20
+ - @checkstack/tips-frontend@0.2.6
21
+ - @checkstack/notification-common@1.2.1
22
+ - @checkstack/signal-frontend@0.1.5
23
+
24
+ ## 0.4.5
25
+
26
+ ### Patch Changes
27
+
28
+ - f23f3c9: Add per-channel notification delivery-attempt tracking
29
+ (Phase 8 of the v1 polishing plan). The external dispatch loop now
30
+ persists one row per `strategy.send(...)` call into a new
31
+ `notification_delivery_attempts` table - both successes and
32
+ failures - so silent delivery breakage (misconfigured webhooks, dead
33
+ channels) becomes queryable instead of buried in logs.
34
+
35
+ - `@checkstack/notification-backend` adds the
36
+ `notification_delivery_attempts` table, the matching Drizzle
37
+ migration, and a new `dispatchWithAttempt` helper that wraps every
38
+ external `strategy.send(...)` with duration measurement and
39
+ best-effort row persistence. The insert is intentionally
40
+ fire-and-forget: if writing the attempt row itself errors, the
41
+ dispatch loop logs and continues so visibility tracking can never
42
+ introduce a _new_ silent failure.
43
+ - `@checkstack/notification-common` exports a new
44
+ `DeliveryAttemptSchema` zod schema, the
45
+ `ListDeliveryAttemptsInputSchema =
46
+ PaginationInput.extend({ notificationId })` input, and a new
47
+ `getDeliveryAttempts` procedure on the contract. The procedure is
48
+ gated by the existing `notificationAccess.admin`
49
+ (`notification:manage`) access rule - no new permission was
50
+ introduced.
51
+ - `@checkstack/notification-frontend` adds a minimal admin-only
52
+ `DeliveryAttemptsPage` (route id `notification.deliveryAttempts`,
53
+ path `/notifications/delivery-attempts`) and an "Open inspector"
54
+ link from the Notification Settings page for users with
55
+ `notification:manage`. No client-side `isAdmin` gate - the FORBIDDEN
56
+ case is rendered via the standard error-state branch on the page,
57
+ enforced by the contract.
58
+
59
+ Visibility only: there is no retry mechanism in this phase. A
60
+ `failure` row is a final outcome an admin actions manually
61
+ (re-trigger the source event, fix the misconfigured channel).
62
+ Automated retries are deferred to v1.1.
63
+
64
+ Strategy errors thrown during `send(...)` are persisted via
65
+ `extractErrorMessage(error)` so secrets potentially embedded in raw
66
+ error objects (webhook URLs, OAuth tokens reachable from the strategy
67
+ send context) are not stored verbatim.
68
+
69
+ See the new
70
+ `docs/src/content/docs/backend/notification-delivery.md` page for the
71
+ full surface description.
72
+
73
+ - f23f3c9: Establish the canonical optimistic-UI pattern for oRPC mutations
74
+ (`onMutate` snapshot / patch, `onError` rollback, `onSettled`
75
+ invalidate) and apply it to the two highest-frequency toggles where
76
+ perceived latency was most visible:
77
+
78
+ - `markAsRead` on the Notifications page — clicking the check on a
79
+ notification card now flips the read state immediately instead of
80
+ waiting for the round-trip.
81
+ - `pauseConfiguration` / `resumeConfiguration` on the Health Check
82
+ Config page — pause/resume now flip the row's badge instantly,
83
+ rolling back on server error.
84
+
85
+ The wrapper type for `useMutation` on each plugin client gained an
86
+ optional `TContext` generic so optimistic sites can return a snapshot
87
+ from `onMutate` and consume it in `onError` without `unknown` casts.
88
+ The runtime behaviour and the auto-invalidation on success are
89
+ unchanged; the change is additive on the type surface only.
90
+
91
+ Full pattern and "when NOT to use it" guidance live in
92
+ `docs/frontend/optimistic-updates.md`.
93
+
94
+ - f23f3c9: Sweep every paginated `*-common` contract onto the canonical
95
+ `PaginationInput` / `PaginatedResult` from `@checkstack/common` and
96
+ remove the now-unused legacy exports.
97
+
98
+ **BREAKING CHANGE** - `@checkstack/common` drops the deprecated
99
+ `PaginationInputSchema`, `paginatedOutput`, and `PaginatedResponse`
100
+ symbols. Callers must consume `PaginationInput` (input) and
101
+ `PaginatedResult(itemSchema)` (output) instead. The canonical input is
102
+ `{ limit (1-100, default 20), offset (>= 0, default 0) }`; the
103
+ canonical output envelope is
104
+ `{ items, total, limit, offset }`.
105
+
106
+ **BREAKING CHANGE** - `@checkstack/notification-common` migrates
107
+ `getNotifications` off the legacy `PaginationInputSchema`
108
+ (`{ limit, offset, unreadOnly }` with output `{ notifications, total }`)
109
+ onto `ListNotificationsInputSchema =
110
+ PaginationInput.extend({ unreadOnly })` and
111
+ `PaginatedResult(NotificationSchema)`. The output key changes from
112
+ `notifications` to `items`, and `limit` / `offset` are now echoed on
113
+ the response. The `PaginationInput` type alias previously exported
114
+ from `notification-common` is removed - use `ListNotificationsInput`
115
+ or the canonical `PaginationInput` from `@checkstack/common`.
116
+
117
+ **BREAKING CHANGE** - `@checkstack/integration-common` migrates
118
+ `listSubscriptions` (inline `{ page, pageSize, ... }` -> output
119
+ `{ subscriptions, total }`) and `getDeliveryLogs` (via
120
+ `DeliveryLogQueryInputSchema` `{ subscriptionId?, eventType?, status?,
121
+ page, pageSize }` -> output `{ logs, total }`) onto the canonical
122
+ `PaginationInput.extend({...})` input and
123
+ `PaginatedResult(itemSchema)` output. External callers must switch
124
+ from `{ page, pageSize }` to `{ limit, offset }` and read response
125
+ items from `data.items` (no more `data.subscriptions` / `data.logs`).
126
+
127
+ The matching `*-backend` handlers were updated to consume the new
128
+ input shape (`offset` arithmetic in lieu of `(page - 1) * pageSize`)
129
+ and to echo `limit` / `offset` on the response. The `*-frontend` call
130
+ sites in `NotificationsPage`, `NotificationBell`, `IntegrationsPage`,
131
+ and `DeliveryLogsPage` were updated to send the new input shape and
132
+ read `data.items`.
133
+
134
+ - f23f3c9: Gate decorative motion and blur effects behind
135
+ `usePerformance().isLowPower` on a focused set of high-traffic plugin
136
+ pages (Dashboard, Dependency map, System node, Notification bell,
137
+ Announcement banner / cards, Anomaly field overrides editor, SLO
138
+ attribution chart, Catalog droppable group). Hover scales, backdrop
139
+ blurs, `animate-pulse`/`animate-ping` accents, and entry transitions
140
+ now drop to static states on low-power devices; functional UX
141
+ transitions (Drawer/Dialog open-close, colour transitions) are left
142
+ alone.
143
+
144
+ Standardise the post-mutation error-toast voice on plugin pages by
145
+ migrating multi-clause `toast.error(extractErrorMessage(error, "Failed
146
+ to X"))` call sites onto the `toastError(toast, "Failed to X", error)`
147
+ helper from `@checkstack/ui`. The helper applies the canonical
148
+ `"action: message"` prefix and 100-character truncation in one place,
149
+ and the now-orphaned `extractErrorMessage` imports are dropped from
150
+ the affected files. No business logic or component APIs changed.
151
+
152
+ - f23f3c9: Standardise the empty / loading / error story on key list pages using
153
+ the shared `ListEmptyState`, `QueryErrorState`, and `Skeleton`
154
+ primitives from `@checkstack/ui`. Each affected page now branches
155
+ through the same `isLoading -> isError -> empty -> data` ladder, so
156
+ failed queries surface a retry-able inline error instead of silently
157
+ rendering an empty table, and loading states match the final layout
158
+ rather than flashing a generic spinner. No layout, business logic, or
159
+ query input shapes changed.
160
+ - Updated dependencies [f23f3c9]
161
+ - Updated dependencies [f23f3c9]
162
+ - Updated dependencies [f23f3c9]
163
+ - Updated dependencies [f23f3c9]
164
+ - Updated dependencies [f23f3c9]
165
+ - Updated dependencies [f23f3c9]
166
+ - @checkstack/common@0.11.0
167
+ - @checkstack/auth-frontend@0.6.5
168
+ - @checkstack/notification-common@1.2.0
169
+ - @checkstack/frontend-api@0.5.2
170
+ - @checkstack/ui@1.10.0
171
+ - @checkstack/tips-frontend@0.2.5
172
+ - @checkstack/signal-frontend@0.1.4
173
+
3
174
  ## 0.4.4
4
175
 
5
176
  ### 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.6",
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",
17
- "@checkstack/common": "0.10.0",
18
- "@checkstack/frontend-api": "0.5.1",
19
- "@checkstack/notification-common": "1.1.0",
20
- "@checkstack/signal-frontend": "0.1.3",
21
- "@checkstack/tips-frontend": "0.2.3",
22
- "@checkstack/ui": "1.8.3",
16
+ "@checkstack/auth-frontend": "0.6.5",
17
+ "@checkstack/common": "0.11.0",
18
+ "@checkstack/frontend-api": "0.5.2",
19
+ "@checkstack/notification-common": "1.2.0",
20
+ "@checkstack/signal-frontend": "0.1.4",
21
+ "@checkstack/tips-frontend": "0.2.5",
22
+ "@checkstack/ui": "1.10.0",
23
23
  "lucide-react": "^0.344.0",
24
24
  "react": "^18.2.0",
25
25
  "react-router-dom": "^6.22.0"
@@ -28,6 +28,6 @@
28
28
  "typescript": "^5.0.0",
29
29
  "@types/react": "^18.2.0",
30
30
  "@checkstack/tsconfig": "0.0.7",
31
- "@checkstack/scripts": "0.3.2"
31
+ "@checkstack/scripts": "0.3.3"
32
32
  }
33
33
  }
@@ -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) => {