feat/discovery
needs attentionviewing older commit69473b7 · incrementalPR #305reviewed 2026-07-14 01:34 UTC2H · 5M · 3L · 2I- Purpose
- Feature Discovery v3 — contextual in-product nudges (spotlights, guided tour, hub) to help providers discover key platform capabilities and reach their first value moments faster.
- Goal
- Descubre hub v3.1: persistent for providers even at checklist=0, shows done capabilities as struck achievements, gates on discoveryActive from the server.
- Sub-goals
- SG-1: Add adopted predicates to every DiscoverySignal — done state distinct from eligible
- SG-2: listDiscoveryChecklist pure function — done+available (locked hidden, retirement ignored)
- SG-3: Shell emits discoveryActive bool; non-providers get false
- SG-4: API schema extended with status on items and discoveryActive bool
- SG-5: DescubreMenu refactored to persistent, renders done/available with check/sparkle icons
- SG-6: ProductTourMenuItem removed — tour re-entry consolidated inside DescubreMenu
- What
- 7 adopted predicates added to DISCOVERY_SIGNAL_CATALOG; new listDiscoveryChecklist pure function; shell switched to emit checklist + discoveryActive; API schema extended; DescubreMenu refactored to always-mounted for providers; ProductTourMenuItem removed from DashboardHeader.
- Why
- The previous hub vanished at gap=0 (all steps done), leaving no stable home for the guided tour. The persistent design gives the tour a stable entry point and turns the hub into an achievement tracker.
- Areas
- domains/cross-domain/src/feature-discovery.*+168−8apps/platform/src/components/discovery/DescubreMenu.tsx+72−48packages/api/src/schemas/discovery.schemas.ts+11−0apps/platform/src/api/handlers/discovery.handler.ts+1−0apps/platform/src/messages/*.json+4−2domains/cross-domain/src/index.ts+3−0apps/platform/src/components/DashboardHeader.tsx+0−5
- Blast
- 10 files, +245/-63 in this incremental window. listDiscoveryChecklist is side-effect-free; shell change is additive; UI breaking for gap-only API consumers.
Findings · 12
correctness1
Toast fires spuriously when query cycles through loading state mid-session
apps/platform/src/components/discovery/DescubreMenu.tsx:100
When ok flips false (loading/error) then true (data arrived), the useEffect early-returns on `if (!ok) return` without updating prevRef.current. On the next render when data arrives, prev.keys holds the stale snapshot and current holds new data, so a spurious 'completed step' toast fires if items differ. prevRef should be updated even on the !ok early-return path, or depend on a stable isSuccess flag.
security1
status: done discloses org setup booleans — intentional, bounded by auth+RLS
packages/api/src/schemas/discovery.schemas.ts
Each item's status field reveals whether the org completed a setup step. Intentional UX; bounded by withAuth + RLS (no cross-org leakage). Worth re-evaluating if the signal set ever expands to sensitive commercial state (payment status, contract financials).
conventions3
ChecklistItem local type in DescubreMenu is an unsafe cast — no structural alignment
apps/platform/src/components/discovery/DescubreMenu.tsx:47
A local ChecklistItem type is hand-written then cast with `as ReadonlyArray<ChecklistItem>`. If DiscoveryChecklistItem gains or renames a field, the cast silently succeeds while the component uses stale types. Better: derive from DiscoveryGapResponse['items'][number] (already client-bundle-safe from @batu/api schemas), giving compile-time proof the alias matches the wire schema.
DiscoveryChecklistStatusSchema missing satisfies z.ZodType<DiscoveryChecklistStatus>
packages/api/src/schemas/discovery.schemas.ts:46
Per the covariance-gotcha in api-patterns.md: z.enum(['done','available']) needs `satisfies z.ZodType<DiscoveryChecklistStatus>` to catch a future widening of the domain type (e.g. adding 'locked') that leaves the runtime enum stale with no TypeScript error. Add the annotation and import DiscoveryChecklistStatus from @batu/cross-domain.
discoveryActive: true hardcoded in shell — derive from orgSegment instead
domains/cross-domain/src/feature-discovery.shells.ts:197
The providers success path emits `discoveryActive: true` as a constant. If a second segment ('enterprise') is ever enabled, this constant stays true silently. Prefer `discoveryActive: input.orgSegment === 'providers'` — stays correct as segments expand and is self-documenting.
tests5
export_columns partial-done state untested in listDiscoveryChecklist
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:384
export_columns uses asymmetric predicates: eligible uses OR (missing either config = eligible), adopted uses AND (both configs needed = done). The partial state — one view configured, not both — should yield 'available'. This is explicitly called out in decisions.ts comments but has no test in the listDiscoveryChecklist suite. The decideSpotlight suite covers it but the checklist function is a separate code path. A future refactor swapping || for && in adopted would regress silently.
payment_monitoring available-but-not-done state never asserted in checklist suite
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:384
The checklist test verifies payment_monitoring is UNDEFINED when hasMonitored=false (locked). But the unlock transition — hasMonitored=true, hasPaymentMonitored=false → 'available' — is never asserted in the listDiscoveryChecklist suite. Also no test for the adopted direction: hasPaymentMonitored=true should yield 'done' regardless of hasMonitored. Both are exercised in decideSpotlight tests but not the dedicated listDiscoveryChecklist describe block.
internal_site_ids checklist status never directly asserted in any test
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:384
The base-signals test checks cfe_setup, add_contract, monitoring, export_columns, api_keys, and payment_monitoring — but not internal_site_ids. baseSignals has contractCount=10 >= SCALE_SIGNAL_MIN_CONTRACTS and hasInternalSiteIds=false, so expected status is 'available'. The all-adopted test sets hasInternalSiteIds=true but only asserts items.every(done). A bug that dropped internal_site_ids from output would not be caught.
getDiscoveryGapShell integration test absent — discoveryActive unwired at DB/RLS level
domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts
The existing integration tests exercise getDiscoverySpotlightShell and recordSpotlightOutcomeShell with real DB + RLS, but getDiscoveryGapShell (which calls listDiscoveryChecklist) has no integration test. discoveryActive: true/false and tourSeen from the exposures query are never asserted at the DB layer.
Retirement-ignore invariant has no test — architecturally safe but undocumented
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:372
listDiscoveryChecklist doesn't accept exposures (making retirement impossible to introduce), but no test documents the intent. A test asserting that a user with 2 dismissals on monitoring still sees 'available' in the checklist would document the deliberate divergence from listEligibleSpotlights.
improvement2
`ok` is too terse for a variable gating three derived values
apps/platform/src/components/discovery/DescubreMenu.tsx:88
`ok` guards discoveryActive, items, and a useEffect dependency. The name carries no domain meaning. `checklistReady` or `checklistLoaded` would make intent obvious without adding length.
Tour re-entry reachability: verify fully-onboarded providers still see Descubre button
apps/platform/src/components/DashboardHeader.tsx:178
ProductTourMenuItem removed; tour re-entry now lives inside DescubreMenu. DescubreMenu is persistent for providers (discoveryActive=true regardless of checklist fullness), so the button should always be reachable. Verify manually: a provider with all items 'done' still sees the Descubre button and can re-launch the tour — this is the core UX promise of v3.1.
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: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:34current
- 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