← all branches

feat/discovery

needs attentionviewing older commit
440d838 · incrementalPR #305reviewed 2026-07-17 00:27 UTC4H · 9M · 9L · 5I
The branch
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)
The changes (whole branch)
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+900300apps/platform/src/app/[locale]/(dashboard)+20080domains/cross-domain/src+500100packages/api/src/schemas+605packages/database+320020e2e/platform+2015packages/analytics/src+200
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.
flag-gated: feature-discovery-spotlights (OFF by default) providers-only in v1 deploy preconditions: PostHog flag + segment reclassify script + staged rollout
typecheck· PR description: all touched packages greenrls-static-check· PR description: RLS static check greenunit-tests· PR description: core + cross-domain decision suites, spotlight-latch, secrets-client branches, upsert greene2e· PR description: Playwright anchor smoke greenci· CI check API not accessiblecoderabbit· No .coderabbit.yaml

Findings · 25

correctness5

medium

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.

medium

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.

medium

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.

low

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.

info

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

low

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.

info

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.

info

?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

medium

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).

medium

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.

low

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

high

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.

high

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).

high

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.

high

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.

medium

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.

medium

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.

medium

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.

medium

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

medium

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.

medium

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.

low

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.

low

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.

info

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.

info

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

  1. c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
  2. 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
  3. ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
  4. 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
  5. 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
  6. cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
  7. 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
  8. ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
  9. 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
  10. 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
  11. 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
  12. 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
  13. 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
  14. d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
  15. 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
  16. 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27current
  17. 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
  18. b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
  19. 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
  20. 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
  21. 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
  22. 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
  23. a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
  24. 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
  25. 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
  26. 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
  27. 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
  28. fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
  29. d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
  30. c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
  31. 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
  32. 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
  33. 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
  34. 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
  35. fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
  36. 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08