feat/discovery
needs attentionviewing older commit440d838 · incrementalPR #305reviewed 2026-07-17 00:27 UTC4H · 9M · 9L · 5I- Purpose
- Flag-gated Feature Discovery v1 for existing providers users: contextual coachmarks + persistent hub checklist + first-run product tour
- Goal
- Ship ambient spotlights, Descubre hub, and first-run tour behind PostHog flag feature-discovery-spotlights; nothing reaches users until flag is flipped
- Sub-goals
- SG-1: Ambient coachmarks (one per page, paced, retired on 2 dismissals or 3 unclicked shows)
- SG-2: Descubre hub — persistent checklist (done/available), deep-links, tour relaunch
- SG-3: First-run linear product tour (5 steps, scrim, progress dots, Hazlo ahora shortcut)
- SG-4: RPU sub-tour — interactive walkthrough of the Nuevo RPU drawer, parked until valid RPU
- SG-5: Tour/ambient coordination — tour-active-store prevents double-overlay
- SG-6: Tour hand-offs — CFE credential page + RPU sub-tour → resume toast
- SG-7: FCIS throughout — DiscoveryFCIS namespace, pure decisions, JSend API, RLS
- SG-8: discovery_state table migration (user-scoped, RLS, profile_id FK)
- What
- This incremental commit (440d838e) adds: TourOverlay (shared scrim+card), RpuSubTour (interactive drawer walkthrough with parking logic), tour-active-store (cross-component surface tracker), DescubreMenu refactor to persistent checklist (done/available items, scrim portal), FeatureSpotlight integrates firstRunTourPending suppression + eligibleKeys latch-drop, guidedOpen prop on AddContractDrawer, CFE page tour hand-off toast, export_columns anchor moved from nav tab to per-row export cluster, and expanded decisions+tests for listDiscoveryChecklist/listEligibleKeysOnPage.
- Why
- The previous commit applied adversarial review must-fixes; this commit completes the tour/ambient coordination (prevents double-overlay), adds the RPU interactive sub-tour, and makes the Descubre hub persistent so it can host the tour link even at an empty checklist.
- Areas
- apps/platform/src/components/discovery+900−300apps/platform/src/app/[locale]/(dashboard)+200−80domains/cross-domain/src+500−100packages/api/src/schemas+60−5packages/database+3200−20e2e/platform+20−15packages/analytics/src+20−0
- Blast
- 60 files, +4935/−62 (bulk is the 13k-line Drizzle snapshot). Discovery is fully flag-gated; all provider paths go through the PostHog flag or isPreviewDeployment() guard. No changes to existing billing, CFE pipeline, or core domain logic.
Findings · 25
correctness5
TourOverlay: getBoundingClientRect called before scroll animation completes
apps/platform/src/components/discovery/TourOverlay.tsx:96
After `el.scrollIntoView({ behavior: 'smooth' })`, `getBoundingClientRect()` is called immediately. The smooth scroll animation has not completed, so the rect reflects the pre-scroll position. The card and cutout snap into correct position only when the scroll event fires the update listener — causing a visible jump on every step requiring a scroll.
RpuSubTour: skip-to-next+1 bypasses FORM_STEPS parking guard at the new index
apps/platform/src/components/discovery/RpuSubTour.tsx:119
When the rpu step is skipped (syncMounted already true), setStepIdx(next + 1) is called without running the FORM_STEPS guard on the target index. With the current catalog (next+1=sync), syncMounted being true makes this safe, but the invariant is brittle: a catalog reorder silently bypasses the parking logic. Fix: replace setStepIdx(next + 1) with a recursive handleStep(next + 1) call so the full guard runs.
GuidedTour: report('seen') can fire twice before ?tour=1 param is stripped
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx
The 'forced' branch calls report('seen') then router.replace to strip ?tour=1. Since router.replace is async, searchParams stays truthy for at least one more render. If any other effect dep changes before the URL update lands (e.g. gap?.status resolving), the effect can fire twice — emitting duplicate analytics and a duplicate seen report.
Drawer never closes when subtourActive becomes false after tour completion
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx:177
The effect that opens the drawer on subtourActive=true has no close branch for when it returns false (after RpuSubTour strips ?subtour=rpu). The drawer stays open indefinitely post-tour; the user must close it manually. If this is intentional (keep form visible), document it.
FeatureSpotlight latch mutation during render is fragile under React Strict Mode
apps/platform/src/components/discovery/FeatureSpotlight.tsx:130
Mutating latchRef.current synchronously inside render is safe in production (single invoke) but under React 18 Strict Mode double-invoke the eligibility-drop path reads a ref already mutated by the first pass. Not a production bug but architecturally fragile; consider moving to a useLayoutEffect.
security3
VERCEL_ENV preview budget-bypass could be triggered on non-Vercel deployments
apps/platform/src/api/handlers/discovery.handler.ts
The handler reads VERCEL_ENV === 'preview' to set relaxDailyBudget = true. On Vercel this is set by infra. On a self-hosted Docker run, an operator setting VERCEL_ENV=preview in the environment would permanently disable the 20h budget cap for all users. Low risk (requires infra misconfiguration), but the intent is clearer as an explicit flag.
No DELETE RLS policy on discovery_state
packages/database/drizzle/0061_discovery_state.sql
No current handler exposes a delete path. Confirm this is intentional for the privacy/data-retention model (GDPR right-to-erasure would require service_role deletion, not user-initiated). No current attack vector.
?descubre param enum vocabulary disclosed via ts-rest ZodError
apps/platform/src/components/discovery/FeatureSpotlight.tsx:65
Invalid ?descubre values trigger a ZodError from ts-rest that lists accepted feature key names. These are non-sensitive (no format spec, no secret shape). api-patterns.md explicitly classifies this as acceptable for non-sensitive enums.
conventions3
isFirstRunTourPending is a pure predicate in the shells file
domains/cross-domain/src/feature-discovery.shells.ts:92
The function is side-effect-free, takes plain values, has no I/O — it belongs in feature-discovery.decisions.ts alongside isRetired and isBudgetSpent. Its current location in the shells file means it cannot be directly unit-tested and its presence creates an apparent FCIS violation (pure logic in a shell).
listEligibleKeysOnPage not re-exported from cross-domain barrel
domains/cross-domain/src/index.ts
listEligibleKeysOnPage is a public export function but is absent from the package barrel (index.ts), unlike its predecessor listEligibleSpotlights and all other public decision functions. Either add it to the barrel or remove the export keyword if it is cross-domain-internal only.
offerTourResume not wrapped in useCallback despite closing over stable refs
apps/platform/src/app/[locale]/(dashboard)/credentials/cfe/page.tsx:87
Not a stale-closure risk (called inside async handlers), but inconsistent with the surrounding code style. Minor.
tests8
isFirstRunTourPending has no unit test
domains/cross-domain/src/feature-discovery.shells.ts:92
This helper is the shared gate that makes GuidedTour and FeatureSpotlight agree on whether the tour is resolved. A bug here causes tour + ambient coachmark to double-stack on a fresh account. Pure function — trivially testable.
tour-active-store.ts has zero test coverage
apps/platform/src/components/discovery/tour-active-store.ts
The store's critical invariant is Set-semantics (two surfaces active → clearing one must not clear the flag). No test covers: two overlapping surfaces, one deactivated; subscribe/notify transitions; unsubscribe cleanup. A bug here causes double-overlay (the P0 that this fix prevents).
getDiscoveryGapShell has zero integration test coverage
domains/cross-domain/src/feature-discovery.shells.ts
The gap shell powers DescubreMenu's checklist, discoveryActive, and tourSeen. The integration test suite covers getDiscoverySpotlightShell (5 cases) but not getDiscoveryGapShell. Consumer-org → discoveryActive: false is only verified by unit tests, not a real DB round-trip.
listEligibleKeysOnPage retirement test vacuously passes after signature refactor
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:454
The function no longer accepts exposures, but the test helper still passes them. In esbuild/vitest (no type checking), the test passes trivially without verifying the retirement-exemption contract. The test must be rewritten to call the actual function and assert a retired key still appears.
firstRunTourPending and eligibleKeys not asserted in spotlight integration tests
domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts
The 5 existing integration test cases for getDiscoverySpotlightShell check only spotlightKey. Neither firstRunTourPending (the suppression gate) nor eligibleKeys (the latch-drop list) is asserted at the DB round-trip level. The latch-drop semantics require that an adopted key is absent from eligibleKeys while a budget-spent-but-not-adopted key remains — this is not tested.
RpuSubTour parking logic (handleStep guard) has no unit test
apps/platform/src/components/discovery/RpuSubTour.tsx:107
The park-on-missing-form and skip-rpu-if-form-mounted branches are DOM-dependent but extractable. A test with a mock querySelector would verify: parking fires when advancing to a FORM_STEPS member with no sync anchor; skip fires when advancing to rpu with sync mounted.
tutorialHref routing logic in DescubreMenu untested
apps/platform/src/components/discovery/DescubreMenu.tsx:59
tutorialHref is pure (no side effects). Untested routing: add_contract → ?subtour=rpu; done item → ?revisit=key; available item → ?descubre=key. A bug here silently misdirects the checklist's highest-value CTA.
data-subtour anchors have no E2E smoke spec
e2e/platform/discovery-anchors.spec.ts
The existing E2E spec covers data-tour anchors but not the new data-subtour anchors (upload, rpu in AddContractDrawer; sync, submit in InlineContractForm). A missing anchor silently degrades RpuSubTour to a centered modal. Extend the spec to navigate to /bills/contratos?subtour=rpu and assert [data-subtour='upload'] and [data-subtour='rpu'] are visible.
improvement6
Progression toast routes add_contract to ambient spotlight instead of RPU sub-tour
apps/platform/src/components/discovery/DescubreMenu.tsx:127
When a step completes and the next is add_contract, the toast builds href as `?descubre=add_contract` (line 127) — the ambient coachmark. But tutorialHref() (line 59) correctly routes add_contract to `?subtour=rpu`. Fix: replace the inline href construction with `tutorialHref(next)`. This is a real UX bug: the highest-intent moment (just completed a prerequisite) routes to a passive coachmark instead of the interactive walkthrough.
TourOverlay anchor retry leaks timers when effect re-runs mid-retry
apps/platform/src/components/discovery/TourOverlay.tsx:91
The retry loop reassigns `timer` on each attempt. Cleanup only clears the LAST value. If the effect re-runs (step changes) while a retry timer is in flight, the previous timer is orphaned and fires after unmount/re-mount, potentially setting stale rect state. Fix: accumulate timer IDs in an array and clearTimeout all of them on cleanup.
Redundant typeof document guard in 'use client' component
apps/platform/src/components/discovery/DescubreMenu.tsx:152
typeof document !== 'undefined' is always true in a 'use client' component. Remove the guard; keep the `open &&` short-circuit. The dead check misleads readers into thinking SSR execution is possible.
DESKTOP_QUERY string duplicated across three discovery components
apps/platform/src/components/discovery/GuidedTour.tsx:31
const DESKTOP_QUERY = '(min-width: 1024px)' is declared identically in GuidedTour.tsx, RpuSubTour.tsx, and FeatureSpotlight.tsx. Extract to a shared constant in spotlight-catalog.ts or a discovery-constants.ts module.
Anchor retry parameters differ between TourOverlay (8×250ms) and FeatureSpotlight (10×300ms)
apps/platform/src/components/discovery/TourOverlay.tsx:100
No documented reason for the difference. Both wait for DOM elements rendered async by tables/drawers. Standardize to a shared constant or add a comment explaining the different wait budgets.
tour-active-store module-level Set may stale during HMR in development
apps/platform/src/components/discovery/tour-active-store.ts:16
If a component unmounts during hot reload without cleanup, a stale surface ID keeps getAnyTourActive() returning true for the dev session. Add a dev-only reset or note this in the module comment.
History · 36 commits
- c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
- 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
- ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
- 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
- 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
- cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
- 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
- ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
- 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
- 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
- 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
- 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
- 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
- d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
- 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
- 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27current
- 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
- b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
- 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
- 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
- 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
- 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
- a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
- 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
- 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
- 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
- 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
- fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
- d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
- c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08