Home dashboard architecture
This page explains the mechanics behind the org landing page (route /dashboard, sidebar label "Home"): how its sections fail independently, which queries they share, why the pulse is gated behind setup, and how attention items are derived. The guard stack and FB-2 error-screen behavior are covered in Authentication architecture; the route tree and shell reads in Architecture overview; the write paths its CTAs lead to (428/402 gates) in Link creation pipeline. Analytics endpoint internals (aggregation math, Redis) are FLOW-005's harvest and are only touched here where Home depends on them.
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (FLOW-004 harvest; B-017 re-verification pass). Behavior is code-derived plus the 2026-09-06 and 2026-09 live records; no fresh live re-audit (
docs/FLOW-004.md§11).
Composition
src/app/(app)/dashboard/page.tsx—HomePage, plus inline components:useSetupItems,SetupCard/SetupRow,PulseSection/PulseTile,UsageStrip,RecentLinks,SectionHeader,headerActions(HeaderLink). Page description swaps with setup state ("Set up in three steps: …" while incomplete, "A 7-day pulse of your deep linking activity." once done).src/app/(app)/dashboard/attention.ts—deriveAttentionItems(pure, unit-tested) +useAttentionItems(query plumbing); rendered byattention-section.tsx.src/app/routes.tsx—/dashboardis a child ofRequireAuth → RequireOrg → AppLayout;/getting-startedis a back-compat loader redirect to it (FLOW-005 D1).AuthRedirectsends signed-in ADMIN sessions (when not impersonating) to/admin, so platform admins never see this page.- Hooks:
src/hooks/{use-links,use-domains,use-analytics,use-entitlements,use-subscription,use-team}.ts.
The page owns no writes. All reads; every endpoint runs ClerkAuthGuard → RolesGuard → RateLimitGuard with the portal preset (30 req/s, key prefix rl:portal, src/auth/rate-limiting/rate-limit.decorator.ts:23).
Section-independence model
Each section is a separate set of TanStack queries and fails alone (page.tsx; FB-1/FB-2 lineage from the pre-rebuild audit):
- Pulse keys its error branch to
isError && !data: a warm failure keeps stale tiles on screen; only a cold failure swaps in the per-sectionQueryErrorBanner+ Retry. The previous-window query (useAnalyticsOverview(7, 7)) failing only hides delta chips (showDeltas). - UsageStrip returns
nullon entitlements error (no strip, no banner); a pending entitlements read renders a skeleton. - Attention's selector drops an errored source's items only; the
attention.tsconvention is "errored sources are silently omitted… never a broken section". This is also why the Clerk boot-401 noise right after login can land here: the strip paints with those sources omitted, and a refetch shows the items. Adding loaders for this would be wrong; empty-on-error is the spec (docs/TESTS-NOTES.md§ Home verification). - Setup is the exception: any of its three signal queries erroring blocks the completion render (
allDonerequires no error — "a false 'Setup complete' is worse than a banner").
Query dedupe
Sections intentionally share query keys so a section rendered twice fetches once:
| Hook call | Key | Consumers |
|---|---|---|
useLinks({limit: 5}) | ["links", {limit: 5}] | useSetupItems (first-link signal) + RecentLinks → one GET /portal/links?limit=5 |
useDomains(1, 25) | ["domains", 1, 25] | useSetupItems (first-domain signal) + attention (REQUESTED items) → one GET /portal/domains?page=1&limit=25 |
useEntitlements() | ENTITLEMENTS_KEY | UsageStrip + attention quota items + setup hints |
useSubscription() | ["subscription"] | UsageStrip + attention PAST_DUE + the Header's shell read (so Home adds no extra call) |
Consequence: GET /portal/domains polls every 30 s (refetchInterval in use-domains.ts); the shared key means Home's poll runs once, but any open Home tab keeps that 30 s poll alive.
An admin+ full render issues up to 8 distinct GETs (links, domains, overview ×3, entitlements, subscription, invitations, the last only admin+), all under the 30 req/s portal limit.
Setup gating and the first-match ceiling
PulseSection mounts only when allDone: setup queries settled, no error, all 3 items done. An org still in setup never fetches the 7-day overview pair at all (page.tsx).
The first-match signal reads GET /portal/analytics/overview?days=365. 365 is the backend days cap (@Max(365), src/portal/portal-analytics.controller.ts:28), which makes the window exactly one year, deliberate per the // ponytail: comment in useSetupItems; a first match older than one year never completes the item.
Attention: pure derived state
deriveAttentionItems composes items from queries the page already fetched — no new endpoints, no persistence, in fixed priority order:
- Subscription
status === "PAST_DUE"(warning). Copy is gateway-factual: Stripe → "Stripe retries the charge automatically"; Paymob → no retry claim; both name the Starter fallback and a deadline fromcurrentPeriodEnd(degrades to "by the end of the current billing period" when null). The "Fix payment" action (/settings?tab=billing) renders for owners only; members get the item without it. - Any quota
exceededor used/limit ≥ 1 → "At or over plan limits". - used/limit ≥ 0.8 (
NEAR_LIMIT_RATIO) → "Approaching plan limits". Both list affected labels ("clicks (monthly), domains"); action →/subscribe. - One info item per domain with
status === "REQUESTED"→ action/domains. - Pending invitations count, admin+ only, enforced by
enabled: role ≥ adminon the source query, not post-filtering, so the@Roles('admin')endpoint (src/team/team.controller.ts:120) is never called by lower roles. Action →/settings?tab=team.
Edge rules: unknown quota keys in the entitlements payload fall back to the raw key and sort last (quotaLabel/quotaRank — FLOW-008 R4: never hard-code the quota list). The domains source reads at most 25 rows (limit 25 covers every plan's domain quota except ENTERPRISE's unlimited sentinel), so 25+ simultaneous REQUESTED domains is out of Home's derived scope. No items → the section unmounts entirely.
The persistent notification center is the recorded follow-up (docs/FLOW-015.md D1 hybrid: derived state now, center later).
Reads and backend notes
| Call | Roles | Notes |
|---|---|---|
GET /portal/links?limit=5 | any org role | portal-link.controller.ts:303; limit ≤ 100 (pagination-query.dto.ts); shape {data, total, page, limit} with domain: {domain, status} + all-time clickCount |
GET /portal/domains?page=1&limit=25 | any org role | portal-domain.controller.ts:82; envelope {default, custom: {data, total, page, limit}} |
GET /portal/analytics/overview?days=7 / &offsetDays=7 / ?days=365 | any org role | portal-analytics.controller.ts:131; offsetDays @Min(0) @Max(365) |
GET /portal/entitlements | any org role | portal-entitlements.controller.ts:56-89 |
GET /portal/billing/subscription | any org role | portal-billing.controller.ts:544; envelope {subscription | null} |
GET /portal/team/invitations | @Roles('admin') | team.controller.ts:120-121 |
overview()(analytics.service.ts:567-606) is a pure-PrismaPromise.all, no Redis/cache.installs.total=DeviceProfilerows withfirstSeenAtin window (first-seen device = install; migration20260908071324_add_device_profile_first_seen_index, FLOW-005 Round 7).mau=activeEndUserIdgroupBy onlastSeenAt(analytics.service.ts:302-313).- Delta windows: the hook appends
&offsetDays=only when > 0, keeping the main request byte-identical to the pre-delta call (use-analytics.ts). Backend window =now − (days+offsetDays)·24h→now − offsetDays·24h(portal-analytics.controller.tsoverview(), ~146-149). - The entitlements read iterates all keys per request: 5×
checkQuota+ 4×canUseFeature(entitlements.service.ts:73,85). - Unlimited is the
Number.MAX_SAFE_INTEGERsentinel inplans.config.ts:195-202(ENTERPRISE tier). The controller DTO typeslimitasnumber \| null("null = unlimited") but no current code path emits literal null; the portal'squota.limit === nullchecks are defensive, and an ENTERPRISE org would render a MAX_SAFE_INTEGER fraction on the usage strip if the sentinel ever reached the wire. Which side is the contract is unresolved; see Known gaps. - Subscription read:
nullpayload (HTTP 200, never 404) = no subscription row = STARTER;planLabel(null)→ "Starter" (src/lib/plans.tsplanOf).gatewayCustomerIdis deliberately stripped from the DTO (portal-billing.controller.ts:301-334). - Roles:
useAuth().role= active Clerk membershippublicMetadata.rolevalidated byisOrgRole(use-auth.ts:48-50,src/lib/roles.ts);hasMinimumRoleis inclusive. Client gates (headerActions, "Fix payment", setup hints) only hide UI — the server@Rolesdecorators are the enforcement (POST /portal/linksandPOST /portal/domainsare@Roles('developer')). - Motion: one-shot staggered entrance (fade-in-up 6px, 50 ms/section, under 400 ms total) with
motion-reduce:animate-noneeverywhere (page.tsxenter()).
Legacy GET /portal/dashboard
src/portal/portal-dashboard.controller.ts is live and called by nothing: ClerkAuthGuard + RateLimitGuard only, no RolesGuard. Shape: {totalLinks, totalDomains, clicksToday, clicksLast7Days, topLinks[5]}, where the click totals come from Redis UTC day buckets click:org:{orgId}:{YYYY-MM-DD} (TTL 90 days, click-aggregation.service.ts:5,24,59-95) and topLinks from a 30-day ClickEvent groupBy. The frontend stopped calling it in FLOW-005 D1 (b01acc7); the endpoint was left untouched. Disposition (delete vs deprecate) is open — docs/FLOW-004.md §11. docs/v2.1.0/PORTAL-MAP.md's query-key table still lists ["dashboard"] for it; stale on the frontend side.
Testing gotchas
Full recipes: docs/TESTS-NOTES.md § "Home 'Needs attention' verification (FLOW-015 R1)" + § Deployed Test Stack. The traps:
- Seed clicks age out of the pulse. The seed's ~200 clicks land on the seed date; days later the Demo Org's 7-day pulse legitimately shows the all-zero card. Not a bug.
- Demo Org legitimately derives "At or over plan limits" (domains 1/1, seats 1/1 on Starter). Same ≥0.8/≥1.0 rule as the billing tab; don't "fix" it.
- Boot-401 noise: the token-settling 401s after login can hit
entitlements/invitations; the strip paints with those sources omitted. A reload shows the items. Empty-on-error is the spec — adding loaders would be wrong. - PAST_DUE fixture:
UPDATE subscriptions SET status='PAST_DUE' WHERE id='qa_sub_flow012'— key on the stable id;gateway_subscription_iddrifts. Restore withstatus='ACTIVE'. Shows the Paymob-defensive copy. - Invitation item:
POST /portal/team/invite {"emailAddress":"[email protected]","role":"developer"}, thenDELETE /portal/team/invitations/<id>to free the seat. REQUESTED-domain item:POST /portal/domains {"domain":"go.flow015.test"}(needs a plan withcustom_domains), deletable while REQUESTED. - Audit-harness trap: the audit browser's guard proxy blocks any subresource URL containing an
/events/segment → the SPA blank-mounts (static import ofanalytics/events/page.tsx); workaround was a local :5199 reverse proxy percent-encoding that segment, plusVITE_API_BASE_URL=""for same-origin/portalpaths through Vite's dev proxy. Recorded indocs/FLOW-004.mdRound 4 only — despite that round's claim, it is not indocs/TESTS-NOTES.md(record-vs-record flag, §11).
Known gaps
- Entitlements
limittype drift (unresolved): DTO saysnumber | null, code emitsMAX_SAFE_INTEGER. Needs a maintainer answer or aplans.configchange before internal docs can state either as the contract (docs/FLOW-004.md§11). - Legacy endpoint disposition open (above): live, role-unguarded, zero callers.
PORTAL-MAP.mdPAGE-004 is stale in details: claimsuseLinks({limit:1})/useDomains(1,1)(actual:limit:5/(1, 25), chosen for key sharing) and omitsAttentionSection,UsageStrip,RecentLinks(added by FLOW-015 R1, which postdates the map entry).