- 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>
/checkout/pro and /checkout/scale failed with "Plan cannot be purchased"
for every user since the Next 16 upgrade (ea1b00c).
The checkout page still typed `params` as a plain object and read
`params.planName` synchronously. Next 16 removed the sync compat shim, so
the value was undefined, axios dropped the key, and the API ran
findOne({ name: undefined }). Mongoose 9 returns null for that where
older versions stripped the key, so the guard threw a message about a
misconfigured plan for a request that never named one. Plan data and
Polar product ids were correct throughout.
- unwrap params with React's use() and drop the `as string` cast that
suppressed the type error
- split the conflated guard into PLAN_NAME_REQUIRED, PLAN_NOT_FOUND and
the existing unpurchasable case, each logged server side
- stop retrying a rejected checkout twice: a 400 is not transient
- mark the Polar product id indexes sparse, they have been failing to
build with E11000 on every boot because most plans leave them unset
Checkout now defaults to yearly. A URL that names an interval still
redirects straight through, so the marketing funnel is unchanged; the
in-app CTAs that name none get an interval chooser with yearly
preselected instead of a silent monthly default.
Also redesigns the checkout states, which were the only place in the
dashboard hardcoding gray-100/white and so rendering a light slab in dark
mode, and the plan picker, which painted the accent on every tier.
Tests: a checkout e2e spec asserting the request body carries planName,
which is what would have caught this, plus API cases pinning the three
guard failures apart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The (components) directory held 28 loose files alongside six feature
folders, so the convention existed but was only half applied. Every new
feature added to the pile rather than to a place.
Grouped into account, billing, devices, api-keys, alerts, search and
community. Only nav-items and overview stay at the root, since both
compose across features rather than belonging to one.
Also merged the duplicate webhooks component trees. Components lived in
both dashboard/(components)/webhooks and dashboard/webhooks/(components),
and the second tree was reached by a cross-tree import from
webhooks-history. All three of its files are delivery-history specific
and had no other consumer, so they moved into webhooks-history and that
tree is gone. webhook-table also became deliveries-table, matching what
it renders.
Moves only, no logic changes: git records all 25 as renames, and no test
needed editing, which is the check that behaviour did not move with them.
Two files turned out to be dead code and are deliberately left in place
rather than deleted: black-friday-modal (seasonal, clearly parked) and
community-alert. Nothing imports either.
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>
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>
Nine components each hand-rolled a useQuery against endpoints the typed
hooks in lib/api already cover: the current user in seven places, the
subscription in four, the device list in two, and stats in one. Each
copy repeated the endpoint, the envelope unwrapping and the cache key,
which is where both of the invalidation bugs fixed in c1942a4 came from.
They now call useCurrentUser, useSubscription, useDevices and
useGatewayStats. The polling behaviour in use-onboarding is preserved by
passing refetchInterval through the hooks' options, with the difference
that its polling now refreshes the same cache entries the rest of the
dashboard reads rather than maintaining a parallel copy of them.
update-app-modal and update-app-notification-bar previously destructured
the raw { data } envelope; useDevices unwraps it, so those consumers
read the array directly now.
Added createdAt and onboarding to the User type. Both are real fields
that components were already reading, and adding them lets the shared
hook feed the onboarding checklist, which had been the reason for a
separate query in the first place.
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>
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>
Both bugs were hand-written query keys. lib/api/query-keys.ts exists to
prevent exactly this and neither site used it.
1. /auth/who-am-i was cached under two keys. Four call sites used
['whoAmI'] and seven used ['currentUser'], including the typed
useCurrentUser hook. So the endpoint was fetched twice on any page
using both, and invalidating one never invalidated the other:
edit-profile-form refreshed ['currentUser'] and left the email
verification banner reading stale data under ['whoAmI'], while
use-onboarding had the mirror-image bug.
verify-email/page.tsx additionally cached the whole axios response
where the others cached the unwrapped user, so unifying the key meant
normalising that shape too. One key demands one shape.
2. generate-api-key invalidated ['apiKeys', 'stats'], which no query
uses. react-query matches by prefix, so it matched neither the key
list (['apiKeys', status]) nor the dashboard stats (['stats']).
Generating an API key refreshed nothing at all. It is now three
invalidations against queryKeys, using a new apiKeysAll prefix so
every status filter refreshes rather than just 'active'.
Also added accountDeletionRequestedAt to the User type. It is a real
field on the user schema and the deletion banner already reads it; only
the type did not know about it.
Both bugs are invisible on inspection, which is how they shipped, so the
regression tests were each confirmed to fail against the pre-change code
before being trusted.
Verified: build clean, 0 lint errors (21 warnings, unchanged), 155 unit
tests (from 153), 78 e2e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/billing/current-subscription answers in two shapes. A subscriber gets the
Subscription document, which always carries a status because the schema
requires one. A user with no subscription gets a synthesised
{ plan, isActive, usage } with no status, no amount and no dates.
The page read that second shape as a subscription with missing fields, so
every free user was told their subscription status was "Unknown" and shown
two "N/A" billing dates. Absence of a status is not an unknown status.
lib/billing.ts interprets the payload instead of guessing at it. A paid plan
arriving without a status is still genuinely unknown and is still reported
that way, so the earlier fix for fabricated "Active" is preserved.
The redesign splits one flat card into plan identity and usage. The old
inner panels were bg-card with shadow-sm sitting on a bg-card parent, so the
nesting was invisible; they are bordered muted panels now. Free accounts
drop the status pill, the always-N/A dates and the portal link they have
nothing to manage with.
CTAs are Buttons rather than hand-rolled Links with background utilities,
and the upgrade target comes from position on the plan ladder, so Scale
subscribers are no longer sold a tier above Scale and bespoke plans are not
pushed onto the self-serve ladder at all. Added a "Compare all plans" link
to textbee.dev/pricing, matching the label the onboarding plan picker
already uses for the same destination.
Also corrected the Pro fixture from 1900 to 999 cents, a price we do not
charge, and gave the portal link noopener noreferrer.
Verified: build clean, 0 lint errors (21 warnings, unchanged), 153 unit
tests (from 121), 78 e2e (from 75). The three free-account guards and the
pricing link guard were each confirmed to fail against the pre-change
component before being trusted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plan step hardcoded Free and Pro inline, so Scale never appeared no
matter what the pricing page offered. Tiers now come from lib/plans,
which mirrors the marketing pricing section, with a test pinning the
values so the two cannot drift silently.
Kept static rather than fetched from /billing/plans. That endpoint
returns Plan documents (limits and cents), not customer-facing copy, and
an environment with no plans rows left the step showing "plans could not
be loaded" with nothing to choose.
Reopening a completed step showed an empty row. Two things caused it:
the body was gated on the step not being done, and an effect cleared the
selection whenever the selected step was done, so clicking a finished
step deselected it on the next render. That effect exists to advance you
when the step you are sitting on completes underneath you, so it now
fires only on that transition. Both paths are covered: reopening the API
key step offers "Generate another API key", and a step completing while
selected still moves the selection on.
Skip is hidden on an already-finished step, where it would mean nothing.
Also corrects the Plan type, which declared amount, currency and
recurringInterval. The plans endpoint has never sent those; they belong
to Subscription. Anything reading plan.amount saw undefined and rendered
a paid plan as free. The fixture encoded the same wrong shape, so tests
would have passed while production broke.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Device type declared a `status` field that the schema does not have
and the API never sends, so `device.status === 'online'` was always
false. The badge took its colour from that phantom field and its text
from `enabled`, so an enabled, working device was styled exactly like a
disabled one. Both now come from `enabled`.
Devices, API keys and webhooks each rendered a bare unstyled
"Error: {error.message}" with no way to recover, putting raw transport
strings like "Request failed with status code 500" in front of users.
formatError already handles axios rejections and rate limits and was
used by none of them.
Adds components/shared/error-state.tsx as the counterpart to the
existing EmptyState, routing all three through formatError and offering
a retry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The status pill hardcoded a check mark and varied only its colour, so a
past_due or canceled subscriber was shown a tick beside the bad news.
It also fell back to the label "Active" when the payload carried no
status at all, inventing a healthy state on a billing screen. The icon
now comes from the status, and an absent status reads "Unknown".
formatPrice returned "Free" whenever the currency was missing, even
with a real amount. Since the badge only renders when amount > 0, the
one case it could produce was a paying customer being shown
"Free / monthly". Default the currency instead.
The page also derived its usage numbers inline instead of calling
deriveUsage, which exists precisely so billing and the dashboard cannot
disagree. The inline copy skipped the helper's 0-100 clamp, so the
invariant was already broken.
Monthly is a rolling 30-day window server-side, not a calendar month,
which the dashboard already labels correctly. Billing said "this
month", so a user capping out late in the month would wait for a reset
that never arrives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the send screen up to the standard of the rebuilt bulk send flow.
Fixes real defects:
- The single-device auto-select never worked. It was computed in
defaultValues from devices?.data?.length === 1, but react-hook-form reads
defaultValues once on mount, while the devices query is still pending, so
the condition was always false. It now preselects once devices resolve.
- Submitting before devices loaded showed "Required" on the device field,
which then auto-filled a moment later, leaving a stale error beside a
populated field. Submit is disabled until devices load.
- Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared
cache key with a different unwrapped shape that caused the devices?.filter
crash previously.
- Every field used a placeholder as its label. Placeholders vanish on focus
and are not reliably announced, so all three now have real labels.
Recipients are chips instead of a growing stack of inputs. They commit on
Enter, comma or blur, so a number typed and left uncommitted is not silently
dropped at send time. Pasting a list adds several at once.
Splitting a pasted list needed care, and an e2e caught it: splitting on
whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous
separators split first; whitespace splitting is a fallback for tokens that
cannot be a single number.
Also adds a segment counter, inline alerts for success and failure instead of
bare text, and a form reset after sending that keeps the device selected.
Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send
page and bulk send share one tested implementation.
One test correction: the desktop footer assertion required links to sit on
exactly one row, but the row layout wraps by design, so it failed whenever
font metrics pushed a link to a second line. It now asserts the property the
design actually guarantees, that links are not one-per-row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The screen opened with a tall filter card, so messages started below the fold,
and each message was a bordered card capped at max-w-sm, fitting three or four
per mobile screen.
List:
- Messages are now rows grouped under day headings (Today, Yesterday, then the
date). Roughly triples what fits on a phone, and scanning by time is how
people actually look for a message.
- Rows are buttons, so the list is keyboard navigable, with relative times
that reveal the exact timestamp on hover.
- Direction now comes from the `type` the API returns. It was inferred from
whether `sender` was present, which is only a proxy; the old check survives
as a fallback for rows written before `type` existed.
- Pagination renders only when there is more than one page.
- A search that matches nothing is a different state from a device with no
messages, and offers a way back.
Filter bar collapsed into one compact row: search, device, refresh, and auto
refresh moved into a dropdown instead of four inline buttons occupying a whole
row for a rarely-changed setting.
Details dialog reordered around why it is opened: the message body first, then
metadata, then errors, then actions. The exact timestamp is text here because
the list tooltip is hover-only and unreachable on touch.
Composer dialog gains real field labels (it used placeholders as labels, which
vanish on focus and are not reliably announced) and the same segment counter
bulk send has.
Two fixes found along the way:
- Reply passed message.device?._id with no fallback. The endpoint does
populate device, but if it were ever absent the composer would open with no
device and could not send. It now falls back to the device being viewed.
- The device-defaulting effect became derived state, so the list no longer
renders once with no device selected before correcting itself.
Segment helpers moved to lib/sms.ts now that the send page, bulk send and the
composer all use them. Fixtures now carry the populated `device` and relative
dates, so the mocked path matches production and exercises day grouping.
Co-Authored-By: Claude Fable 5 <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>
Dashboard:
- Removed the recent activity section.
- Corrected the usage card wording. The number was labelled "sent", but
getCurrentSubscription counts SMS documents with no type filter, and the
same model stores both SMSType.SENT and SMSType.RECEIVED. Inbound messages
consume the quota too, so the cards now say "messages" and carry an explicit
note that sends and receipts both count against the limit.
- "This month" is now "Last 30 days". The backend counts from setMonth(-1), a
rolling window, not the calendar month.
Devices:
- Removed the battery and signal indicators. Neither the device schema nor the
Android app reports either value, so they only ever rendered "unknown" and
"-" beside an icon that implied real telemetry. Also dropped the phantom
batteryLevel field from the Device type so nothing is built on it later.
API guide, rebuilt:
- It rendered a Collapsible that defaulted to closed. Once it moved to its own
route the page looked empty until you found the chevron. It is a page now,
so nothing is collapsed.
- Samples are generated from the user's real device id, with a device picker,
so they run after pasting one key. Every sample previously said
YOUR_DEVICE_ID, so nothing on the page was runnable as-is.
- Code blocks follow the theme. The old ones hardcoded a slate-800 background,
so they were the only dark element on a light page.
- Languages went from 3 to 5 (cURL, Node, Python, PHP, Go), picked once and
applied to every endpoint, replacing a hand-rolled inline Python SVG icon.
- Endpoints now cover bulk send and received messages, not just send and two
lookups. Response examples use the real lowercase status values from the SMS
schema instead of invented uppercase ones, and the batch example is no
longer invalid JSON (it had a trailing comma).
- Keys the copy state per block rather than by index arithmetic over
Object.entries order.
The mount guard usually needed for next-themes is not used in CodeBlock:
resolvedTheme is undefined on the server and first client render, so both
produce the light style. Verified with a console sweep across nine pages in
both colour schemes, zero messages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The home page was four all-time counters that could not answer either
question a user actually opens the dashboard for: how much quota is left, and
did my recent sends work.
Removed fabricated data:
- Every stat card rendered a green TrendingUp arrow whenever a value existed.
Nothing computed a trend. The stats endpoint returns running totals with no
time window, so there was nothing to compare against.
- "Since last year" on the sent and received counts. getStatsForUser sums
device counters with no date filter, so these are all-time totals.
- "Active Devices" with a "Connected now" caption, over a number that counts
every device including disabled ones. Enabled count is now derived from the
device list we already fetch.
- "Active keys" over a count that included revoked keys; now uses the active
API key list.
Added, all from fields the API already returns:
- Usage cards leading the page: today and this month against their limits,
with progress bars, an amber near-limit state past 80% and an upgrade link.
Unlimited plans (-1) show usage with no meter, since there is no ceiling to
measure against.
- Recent activity: the last 5 messages for the connected device. Messages are
only exposed per device, so the card names the device rather than implying
account-wide coverage.
- All-time totals compacted from four large cards into one honest strip.
lib/usage.ts extracts the daily/monthly derivation that subscription-info
already did inline, so the dashboard and billing page cannot disagree about
remaining quota. Unit tested including the -1 sentinel, custom limit
overrides, and an over-100 percentage (possible when a limit is lowered
mid-period).
No backend changes.
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>
- extract the provider tree into app/(app)/providers.tsx and create the
QueryClient once via useState (it was rebuilt on every render, discarding
the cache); remove the old layout-wrapper
- replace the per-navigation whoAmI session check with a global 401 response
interceptor in httpBrowserClient that routes expired sessions to /logout
- rename middleware.ts -> proxy.ts (Next 16 convention), clearing the
deprecation warning; auth guard verified by e2e
- fix .env.example: NEXTAUTH_SECRET (was mislabeled AUTH_SECRET) + NEXTAUTH_URL
- add an e2e assertion for the authenticated login -> dashboard redirect
Build, 19 unit tests, and 3 e2e all green.
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>