feat/discovery
needs attentionviewing older commit1847ec8 · incrementalPR #305reviewed 2026-07-17 23:28 UTC0H · 2M · 4L · 2I- Purpose
- Feature Discovery v1 — contextual spotlight coachmarks, Descubre hub checklist, and first-run linear tour for Batu for Providers users, flag-gated (feature-discovery-spotlights OFF by default).
- Goal
- Fix a cluster of dogfooding bugs discovered after the initial PR review: ambient coachmark popping over the tour close/RPU drawer, double 'Siguiente paso' toast mid-tour, pulled hub cards dying instantly post-param-strip on the tour-gated page, dead recibos tour anchor, and CFE credentials handoff toast auto-dismissing before the user could read it.
- Sub-goals
- SG-1: Replace seen-based firstRunTourPending with cooldown-based holdAmbientForTour (keeps ambient out of the whole tour session, not just pre-first-engagement)
- SG-2: Add origin:'pull'|'ambient' to SpotlightPick so the tour-cooldown gate exempts user-consent pulls (bills-contratos pull cards were dying post-param-strip)
- SG-3: Extract shouldConsumePullParam as a named function (testable, fixes the keepPreviousData blink regression)
- SG-4: Double-toast guard in DescubreMenu (suppress 'Siguiente paso' when in tour flow)
- SG-5: Rebind recibos tour step anchor from monitoring toggle to recibos_col column header (dead-button-under-scrim dogfooding bug)
- SG-6: duration:Infinity on CFE credentials handoff toast (auto-dismiss was too fast)
- SG-7: Extract tutorialHref + ChecklistItem to discovery-links.ts for testability; add tutorial-href and shouldConsumePullParam unit tests
- What
- Cooldown-based ambient hold (shouldHoldAmbientForTour, 20h post-tour-engagement); SpotlightPick.origin field; shouldConsumePullParam extraction; inTourFlow guard; recibos_col anchor; Infinity toast; discovery-links.ts module; API field rename firstRunTourPending→holdAmbientForTour; listEligibleKeysOnPage drops exposures arg.
- Why
- Dogfooding the first-run tour revealed several visual-layer bugs: the ambient coachmark appeared while the tour was still closing, the Descubre hub fired a second toast mid-tour, user-pulled cards on the main tour page were unmounted instantly after param strip, the recibos tour step pointed at a toggle the scrim blocked, and the CFE handoff toast vanished too fast.
- Areas
- domains/cross-domain/src+969−0domains/core/src/discovery+399−0apps/platform/src/components/discovery+1197−0apps/platform/src/api+525−0apps/platform/src/app+537−0packages/api/src/schemas+99−0e2e/platform+61−0packages/database+13645−12
- Blast
- 60 files +5006/-63 (excluding drizzle meta snapshot); all changes flag-gated — no user impact until feature-discovery-spotlights is flipped.
Findings · 8
correctness2
shouldHoldAmbientForTour resets on every tour re-engagement — rolling, not one-way
domains/cross-domain/src/feature-discovery.decisions.ts:250
Every time the user relaunches the tour via the hub, lastSeenAt updates and the 20h cooldown resets. A user who relaunches the tour daily would never see the ambient coachmark on the contratos page. The old isFirstRunTourPending was one-way (false forever after first engagement); the new function is a rolling window. This may be intentional (don't surface ambient during active tour sessions), but it is undocumented — the comment says 'surfaces on a later visit' without mentioning that repeated relaunch extends the hold indefinitely.
inTourFlow guard may suppress the 'Siguiente paso' toast on bookmarked TOUR_RETURN_PARAM URLs
apps/platform/src/components/discovery/DescubreMenu.tsx:110
The double-toast guard reads searchParams.has(TOUR_RETURN_PARAM). A user who navigates to the CFE credentials page mid-tour and bookmarks that URL would have TOUR_RETURN_PARAM in the URL on a fresh load with no active tour — the toast would be silently suppressed. Very narrow edge case (TOUR_RETURN_PARAM is meant to be consumed by the tour flow), and prevRef logic further limits the scope, but the guard is slightly wider than 'is a tour actually running'.
conventions2
shouldHoldAmbientForTour not named decide* per canonical naming
domains/cross-domain/src/feature-discovery.decisions.ts:250
Pure decision functions in the .decisions.ts layer should follow the decide{Operation} naming convention (canonical-form.md). The new function is named shouldHoldAmbientForTour; per convention it should be decideHoldAmbientForTour. Sibling isFirstRunTourPending has the same deviation and predates this change — but that does not justify extending the pattern. Low-risk (purely naming), but the canonical form should be respected for new additions.
Wire rename firstRunTourPending → holdAmbientForTour — all refs updated but no grep record in PR
packages/api/src/schemas/discovery.schemas.ts:75
canonical-form.md requires a workspace grep before renaming wire-level fields. All references are correctly updated (handler, component, shell, schema) with no orphans. The concern is process: the PR description does not document that a grep was performed. This is a process note, not a live bug — flagging so the convention is explicitly followed next time.
tests2
Shell integration test does not assert holdAmbientForTour output
domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts
The five getDiscoverySpotlightShell integration tests assert spotlightKey but none assert holdAmbientForTour. The shared-now fix (time-consistency between decideSpotlight and shouldHoldAmbientForTour) is validated only by unit tests. A targeted test (profile with product_tour exposure within/outside cooldown) would pin the shell wiring and catch future regressions at the injection boundary.
shouldHoldAmbientForTour exact boundary (== cooldownHours) not covered
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:529
The implementation uses strict less-than (< cooldownHours * 3600 * 1000), so at exactly AMBIENT_AFTER_TOUR_COOLDOWN_HOURS the result is false (released). Tests prove at +1h past but not at the exact boundary. Minor for a 20h window but the strict vs non-strict edge is undocumented by tests.
improvement2
shouldHoldAmbientForTour and isFirstRunTourPending independently re-implement the 'never engaged' check
domains/cross-domain/src/feature-discovery.decisions.ts:253
Both functions search for the product_tour exposure and return true when none is found, but using different logic paths. If the 'never engaged' definition changes (e.g., a new field), only isFirstRunTourPending would be updated. shouldHoldAmbientForTour could call isFirstRunTourPending(exposures) in the no-tour-row branch to make the dependency explicit.
inTourFlow searchParams checks could use a shared TOUR_PARAMS array from tour-catalog
apps/platform/src/components/discovery/DescubreMenu.tsx:110
Three separate searchParams.has() calls on TOUR_PARAM / TOUR_RETURN_PARAM / SUBTOUR_PARAM. These are co-defined in tour-catalog.ts. Extracting TOUR_PARAMS = [TOUR_PARAM, TOUR_RETURN_PARAM, SUBTOUR_PARAM] as const there and using .some(p => searchParams.has(p)) would make exhaustiveness structural — if a fourth tour param is added to the catalog, this guard would be an easy miss otherwise.
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:28current
- 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:27
- 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