Skip to main content

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 by attention-section.tsx.
  • src/app/routes.tsx — /dashboard is a child of RequireAuth → RequireOrg → AppLayout; /getting-started is a back-compat loader redirect to it (FLOW-005 D1). AuthRedirect sends 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-section QueryErrorBanner + Retry. The previous-window query (useAnalyticsOverview(7, 7)) failing only hides delta chips (showDeltas).
  • UsageStrip returns null on 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.ts convention 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 (allDone requires 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 callKeyConsumers
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_KEYUsageStrip + 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:

  1. 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 from currentPeriodEnd (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.
  2. Any quota exceeded or used/limit ≥ 1 → "At or over plan limits".
  3. used/limit ≥ 0.8 (NEAR_LIMIT_RATIO) → "Approaching plan limits". Both list affected labels ("clicks (monthly), domains"); action → /subscribe.
  4. One info item per domain with status === "REQUESTED" → action /domains.
  5. Pending invitations count, admin+ only, enforced by enabled: role ≥ admin on 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​

CallRolesNotes
GET /portal/links?limit=5any org roleportal-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=25any org roleportal-domain.controller.ts:82; envelope {default, custom: {data, total, page, limit}}
GET /portal/analytics/overview?days=7 / &offsetDays=7 / ?days=365any org roleportal-analytics.controller.ts:131; offsetDays @Min(0) @Max(365)
GET /portal/entitlementsany org roleportal-entitlements.controller.ts:56-89
GET /portal/billing/subscriptionany org roleportal-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-Prisma Promise.all, no Redis/cache. installs.total = DeviceProfile rows with firstSeenAt in window (first-seen device = install; migration 20260908071324_add_device_profile_first_seen_index, FLOW-005 Round 7). mau = activeEndUserId groupBy on lastSeenAt (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.ts overview(), ~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_INTEGER sentinel in plans.config.ts:195-202 (ENTERPRISE tier). The controller DTO types limit as number \| null ("null = unlimited") but no current code path emits literal null; the portal's quota.limit === null checks 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: null payload (HTTP 200, never 404) = no subscription row = STARTER; planLabel(null) → "Starter" (src/lib/plans.ts planOf). gatewayCustomerId is deliberately stripped from the DTO (portal-billing.controller.ts:301-334).
  • Roles: useAuth().role = active Clerk membership publicMetadata.role validated by isOrgRole (use-auth.ts:48-50, src/lib/roles.ts); hasMinimumRole is inclusive. Client gates (headerActions, "Fix payment", setup hints) only hide UI — the server @Roles decorators are the enforcement (POST /portal/links and POST /portal/domains are @Roles('developer')).
  • Motion: one-shot staggered entrance (fade-in-up 6px, 50 ms/section, under 400 ms total) with motion-reduce:animate-none everywhere (page.tsx enter()).

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_id drifts. Restore with status='ACTIVE'. Shows the Paymob-defensive copy.
  • Invitation item: POST /portal/team/invite {"emailAddress":"[email protected]","role":"developer"}, then DELETE /portal/team/invitations/<id> to free the seat. REQUESTED-domain item: POST /portal/domains {"domain":"go.flow015.test"} (needs a plan with custom_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 of analytics/events/page.tsx); workaround was a local :5199 reverse proxy percent-encoding that segment, plus VITE_API_BASE_URL="" for same-origin /portal paths through Vite's dev proxy. Recorded in docs/FLOW-004.md Round 4 only — despite that round's claim, it is not in docs/TESTS-NOTES.md (record-vs-record flag, §11).

Known gaps​

  • Entitlements limit type drift (unresolved): DTO says number | null, code emits MAX_SAFE_INTEGER. Needs a maintainer answer or a plans.config change 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.md PAGE-004 is stale in details: claims useLinks({limit:1}) / useDomains(1,1) (actual: limit:5 / (1, 25), chosen for key sharing) and omits AttentionSection, UsageStrip, RecentLinks (added by FLOW-015 R1, which postdates the map entry).