- prefetch route-tab links so tab clicks reuse a cached payload instead
of paying an uncached server round trip each time
- add loading.tsx boundaries (dashboard root and each section) so clicks
paint within a frame; section-level files keep the header and tabs
mounted while only the content area swaps
- link the sidebar Account item and the checkout page straight to
/dashboard/account/billing. The /dashboard/account redirect stub stays
for old links, but no internal link pays the extra hop anymore. The
stub also dropped query params, which silently ate the plan-change
success toast.
- set QueryClient defaults (staleTime 60s, no focus refetch, retry 1);
mutations already invalidate their keys, so the user's own changes
stay instant. Device messages and webhook deliveries get a 15s
staleTime since they change from outside the tab.
- replace the axios getCachedSession TTL cache with a token seeded from
the server session in Providers and kept in sync by a session bridge.
Requests attach the token synchronously; /api/auth/session is only a
deduped fallback, instead of a refetch every 2 minutes with a
thundering herd on expiry.
- swap the billing card's 16px loading spinner for a card-shaped
skeleton so the tab no longer looks blank while loading
Tests: interceptor seeding/dedupe/signed-out behavior, provider
defaults and token seeding, nav active-state matching, tab prefetch,
billing loading state, plus e2e coverage for direct-to-billing
navigation and an at-most-one-session-call budget guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ten components hand-rolled useMutation against httpBrowserClient, each
repeating its own endpoint, error shape and cache invalidations. The four
webhook mutations wrote the same ['webhooks'] key four times, and the two
API-key generators wrote the same three invalidations twice, which is how
one of them ended up with the ['apiKeys', 'stats'] key that matched
nothing.
Invalidations now live once beside each hook, so a component cannot
forget one or spell it differently. Component-specific behaviour (toasts,
dialog closing, form resets) stays at the call site via per-call
mutate(vars, { onSuccess, onError }) callbacks.
For the two hooks that take options, the caller's onSuccess is composed
with the hook's rather than spread over it, so passing a callback cannot
silently drop the invalidation the hook exists to guarantee.
Also normalised useRevokeApiKey, useDeleteApiKey and useRenameApiKey onto
queryKeys.apiKeysAll. They previously invalidated either a bare
['apiKeys'] literal or only the 'active' list, so revoking a key left the
revoked and all lists stale.
Two behaviour details preserved deliberately: the inline register panel's
failure toast, which moved to the call site rather than being dropped,
and webhook-card's toggle, which was a raw try/catch with its own loading
state and is now the shared mutation's isPending.
The typed generate hook surfaced that the copy-key handler could pass
undefined to clipboard.writeText, now guarded.
Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e including the webhook create, edit,
delete and toggle paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
My earlier estimate of 34 errors was wrong, and the reason is worth
recording: tsconfig carried "strictNullChecks": false as a duplicate key
AFTER the strict flag, and an explicit option beats the strict umbrella
even when --strict is passed on the CLI. So the measurement that produced
34 had silently excluded every null-safety error. The real number was 63.
Both duplicate keys are gone; strict: true now stands alone.
Most were implicit-any, but strict caught several genuine type lies:
- SendSmsPayload.deviceId and WebhookData._id were optional while both
are interpolated into request paths, so an absent value would have hit
/gateway/devices/undefined/send-sms or PATCH /webhooks/undefined.
- webhook-table's deviceName was typed string while buildDeviceLabel
returns string | string[] and the Device cell already renders the
array case. The type never described what the code produced.
- webhooks-section read `webhooks?.data?.length > 0`, comparing
undefined against 0 while the query was still in flight.
- app-header declared a non-null Session while its own body guarded with
session?.user throughout. Making the type honest surfaced four
genuinely unguarded accesses.
- api-keys kept a local ApiKeyRow duplicating the shared ApiKey type, so
the list callback annotated rows as one type while the hook returned
the other. ApiKeyRow is now an alias and the two extra fields moved
onto ApiKey.
- The notifications envelope typed its rows as unknown[], so the
deliveries table's row type went entirely unchecked. Now a real
WebhookNotification type.
The react-hook-form cluster (20 of the 63) was one root cause: zod's
.default() makes the input and output types differ, so z.infer (the
output) is not what the resolver takes. Fixed by typing the forms with
z.input and z.output separately, which changes nothing at runtime.
Also bumped target es5 to ES2017, which fixes the Set-iteration error
that made tsc --noEmit fail before any of this. Next compiles browser
output via SWC and its own browserslist, so bundle targeting is
unaffected. Added @types/papaparse and @types/react-syntax-highlighter,
and a typecheck script, since next build does not check test files.
No @ts-expect-error and no new any were used.
Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Message history had no way to find an old message. The endpoint only accepted
page, limit and type, so a client-side search box could only ever filter the
20 rows already loaded, which is more misleading than having no search.
getMessages takes an optional `search` and matches it against the message
body, recipient and sender. It composes with the existing type filter.
The term is escaped before it reaches RegExp. This is not theoretical: an
unescaped "(" throws a SyntaxError and fails the request, "." would match any
character instead of a dot, and a nested quantifier is a ReDoS vector. The
escaping is a separate, directly tested unit.
Note on performance: the existing { device, type, receivedAt } index does not
serve a regex $or, so a search is a scan within one device's messages. That is
fine at current per-device volumes; a text index is the follow-up if it stops
being fine.
Frontend: useDeviceMessages takes `search`, joins it to the query key so each
term caches separately, and builds the query with URLSearchParams so terms
containing & or = are encoded rather than corrupting the URL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Split app/(app)/dashboard/(components)/webhooks-history.tsx into a
webhooks-history/ folder (import path unchanged via index.tsx):
- use-filters.ts: filter state hook (device, webhook, event, status, preset
and custom date ranges) with page reset handled in one place
- filters.tsx: the five filter selects + custom-range popover, deduplicated
via a FilterField wrapper and option tables
- index.tsx: slim container on new useWebhooks / useWebhookNotifications
hooks in lib/api
- promote the numbered pagination (previously duplicated verbatim between
message-history and webhooks-history) to
components/shared/numbered-pagination.tsx
- drop dead code: empty useEffect, never-set isLoading state, ~10 unused
imports; replace the off-palette sky gradient with card tokens
- add a webhooks history e2e guard
Build, 25 unit tests, and 6 e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split app/(app)/dashboard/(components)/message-history.tsx into a
message-history/ folder (import path unchanged via index.tsx):
- sms-composer-dialog: one parameterized compose dialog replacing the
near-duplicate ReplyDialog and FollowUpDialog (~300 lines of duplication)
- sms-details-dialog, message-card (+skeleton), filters-bar, pagination,
utils (timestamp/status badge), types
- index.tsx is a slim container owning filter/pagination state, wired to new
useSendSms and useDeviceMessages hooks in lib/api
- design cleanups: off-palette sky gradient on the filters bar replaced with
card tokens, bg-gray-* -> bg-muted, brand-tinted controls -> accent tokens,
proper empty states with icons, subtle fade-in on cards
Every module is now 26-240 lines. Build, 19 unit tests, and 5 e2e green
(including the messaging history guard added before the refactor).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a typed data layer under lib/api (query-key factory, response
types, feature hooks) plus shared lib/format and lib/status helpers, and
migrate the canonical dashboard consumers (subscription-info, overview,
device-list, api-keys) onto it, deleting their duplicated inline queries,
mutations and formatters.
List hooks keep the raw { data: [] } envelope in the cache and unwrap
per-observer with react-query `select`, so shared keys like ['devices']
stay compatible with the not-yet-migrated components that still read the
raw shape (avoids a cache-shape collision surfaced by the dashboard e2e).
Adds unit tests for the formatters and the hooks (against MSW). Build,
19 unit tests, and 2 e2e all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>