feat: add composable to determine if user is eligible for nightly survey(s)#8189
feat: add composable to determine if user is eligible for nightly survey(s)#8189christian-byrne merged 16 commits intomainfrom
Conversation
📝 WalkthroughWalkthroughAdds a new composable Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Hook as useSurveyEligibility
participant Tracker as useFeatureUsageTracker
participant Storage as LocalStorage
Client->>Hook: initialize(config)
Hook->>Storage: read SurveyState(key)
Hook->>Tracker: subscribe(featureId)
Tracker-->>Hook: usage updates
Hook->>Hook: evaluate env, threshold, cooldown, opt-out
Hook-->>Client: expose reactive flags & actions
Client->>Hook: markSurveyShown()
Hook->>Storage: persist seenAt / lastShown
Client->>Hook: optOut()
Hook->>Storage: persist optedOut
Client->>Hook: resetState()
Hook->>Storage: clear state
Possibly related PRs
Suggested reviewers
✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🎭 Playwright Tests: ✅ PassedResults: 507 passed, 0 failed, 0 flaky, 8 skipped (Total: 515) 📊 Browser Reports
|
🎨 Storybook Build Status✅ Build completed successfully! ⏰ Completed at: 01/28/2026, 04:14:08 AM UTC 🔗 Links🎉 Your Storybook is ready for review! |
Bundle Size ReportSummary
Category Glance Per-category breakdownApp Entry Points — 22.9 kB (baseline 22.9 kB) • ⚪ 0 BMain entry bundles and manifests
Status: 1 added / 1 removed Graph Workspace — 961 kB (baseline 961 kB) • ⚪ 0 BGraph editor runtime, canvas, workflow orchestration
Status: 1 added / 1 removed Views & Navigation — 80.7 kB (baseline 80.7 kB) • ⚪ 0 BTop-level views, pages, and routed surfaces
Status: 9 added / 9 removed Panels & Settings — 470 kB (baseline 470 kB) • 🟢 -8 BConfiguration panels, inspectors, and settings screens
Status: 12 added / 12 removed User & Accounts — 3.94 kB (baseline 3.94 kB) • ⚪ 0 BAuthentication, profile, and account management bundles
Status: 3 added / 3 removed Editors & Dialogs — 2.86 kB (baseline 2.86 kB) • ⚪ 0 BModals, dialogs, drawers, and in-app editors
Status: 2 added / 2 removed UI Components — 33.7 kB (baseline 33.7 kB) • ⚪ 0 BReusable component library chunks
Status: 4 added / 4 removed Data & Services — 2.7 MB (baseline 2.7 MB) • 🔴 +1 BStores, services, APIs, and repositories
Status: 9 added / 9 removed Utilities & Hooks — 25.2 kB (baseline 25.2 kB) • ⚪ 0 BHelpers, composables, and utility bundles
Status: 7 added / 7 removed Vendor & Third-Party — 10.7 MB (baseline 10.7 MB) • ⚪ 0 BExternal libraries and shared vendor chunks
Other — 7.04 MB (baseline 7.04 MB) • 🟢 -195 BBundles that do not match a named category
Status: 34 added / 34 removed |
🔧 Auto-fixes AppliedThis PR has been automatically updated to fix linting and formatting issues.
Changes made:
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@src/platform/surveys/useSurveyEligibility.test.ts`:
- Around line 1-5: Add a unit test in useSurveyEligibility.test.ts that seeds
USER_SAMPLING_ID_KEY with a user id known to produce the problematic hash (the
value that yields Math.abs(hash) === 2147483648), then call isUserInSample with
the same divisor used in production and assert the function returns the
expected, stable boolean (call it twice to ensure deterministic behavior); this
ensures the edge case where Math.abs(-2147483648) overflows is covered and
prevents regressions if the hash implementation changes.
In `@src/platform/surveys/useSurveyEligibility.ts`:
- Around line 131-139: The isUserInSample function can produce normalized > 1
when hash equals -2147483648; change the final hashing/normalization to produce
a uniform [0,1] by converting the 32-bit value to an unsigned integer (use >>> 0
on the computed hash) and divide by 0xffffffff (4294967295) instead of
0x7fffffff; alternatively you can short-circuit when sampleRate === 1 to always
return true. Update the logic in isUserInSample to use the unsigned conversion
and the correct divisor so normalized is always within [0,1].
- Around line 43-45: The current call to
useFeatureUsageTracker(resolvedConfig.value.featureId) captures featureId at
setup so changes to a reactive config won't recreate the tracker; update the
composable to watch resolvedConfig (or resolvedConfig.value.featureId) with
watchEffect or watch and recreate the feature tracker when featureId changes,
disposing/cleanup the previous tracker if applicable and reassigning useCount
(or the returned tracker object) so downstream logic uses the new tracker;
alternatively document that featureId must be static or accept featureId as a
separate non-reactive parameter (referencing resolvedConfig,
useFeatureUsageTracker, featureId, and useCount).
- Remove sampleRate config option, getUserSamplingId, isUserInSample - Simplifies eligibility to ~2k nightly users without random sampling - Addresses coderabbitai review: hash edge case bug, YAGNI for cohort size - Document featureId must remain static after initialization
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/platform/surveys/useSurveyEligibility.ts`:
- Around line 62-65: The computed isInGlobalCooldown uses Date.now() which isn’t
reactive over time; to make the cooldown expire automatically without state
changes, replace the Date.now() call with a reactive now from useNow() (import
useNow from `@vueuse/core`) and use now.value in the computed that references
state.value.lastSurveyShown and GLOBAL_COOLDOWN_MS so the computed updates as
time progresses.
|
|
||
| import { useFeatureUsageTracker } from './useFeatureUsageTracker' | ||
|
|
||
| /** @public */ |
There was a problem hiding this comment.
Remove this /** @public */ annotation.
| } | ||
|
|
||
| const STORAGE_KEY = 'Comfy.SurveyState' | ||
| const GLOBAL_COOLDOWN_MS = 14 * 24 * 60 * 60 * 1000 // 14 days |
There was a problem hiding this comment.
Change global cooldown to 4 days. The release cycle is short and sample size too low to be 14 days.
| function getStorageState() { | ||
| return useStorage<SurveyState>(STORAGE_KEY, { | ||
| seenSurveys: {}, | ||
| lastSurveyShown: null, | ||
| optedOut: false | ||
| }) | ||
| } | ||
|
|
There was a problem hiding this comment.
Is there a better way to make it testable without requiring this clunky initialization function? Would prefer to just inline useStorageState
There was a problem hiding this comment.
Usually through module mocking.
There was a problem hiding this comment.
| isEligible, | ||
| hasReachedThreshold, | ||
| hasSeenSurvey, | ||
| isInGlobalCooldown, | ||
| hasOptedOut, | ||
| delayMs, | ||
| markSurveyShown, | ||
| optOut, | ||
| resetState |
There was a problem hiding this comment.
A quick check: Do we need to expose all/each of these, or can we get away with just the aggregate isEligble and the actions?
| const delayMs = computed( | ||
| () => resolvedConfig.value.delayMs ?? DEFAULT_DELAY_MS | ||
| ) | ||
| const enabled = computed(() => resolvedConfig.value.enabled ?? true) |
There was a problem hiding this comment.
enabled is a little generic. What's enabled?
|
|
||
| interface SurveyState { | ||
| seenSurveys: Record<string, number> | ||
| lastSurveyShown: number | null |
There was a problem hiding this comment.
Nit: Can this be optional instead of nullable?
(I wouldn't mind sorting the fields either)
| function markSurveyShown() { | ||
| const now = Date.now() | ||
| state.value.seenSurveys[resolvedConfig.value.featureId] = now | ||
| state.value.lastSurveyShown = now |
There was a problem hiding this comment.
Very nit: Maintaining this as a separate value instead of deriving it from the collection can lead to skew.
| const mockIsNightly = vi.hoisted(() => ({ value: true })) | ||
| const mockIsCloud = vi.hoisted(() => ({ value: false })) | ||
| const mockIsDesktop = vi.hoisted(() => ({ value: false })) |
There was a problem hiding this comment.
Optional, but then you could just return this object instead of having to assign and read from .value for each of them.
| const mockIsNightly = vi.hoisted(() => ({ value: true })) | |
| const mockIsCloud = vi.hoisted(() => ({ value: false })) | |
| const mockIsDesktop = vi.hoisted(() => ({ value: false })) | |
| const mockData = vi.hoisted(() => ({ | |
| isNightly: true, | |
| isCloud: false, | |
| isDesktop: false | |
| })) |
| mockIsNightly.value = false | ||
| setFeatureUsage('test-feature', 5) | ||
|
|
||
| const { useSurveyEligibility } = await import('./useSurveyEligibility') |
There was a problem hiding this comment.
Could you import this at the top instead of doing a dynamic import?
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/platform/surveys/useSurveyEligibility.ts`:
- Around line 9-73: The config lacks a sampling gate: add an optional
sampleRate:number (0-1) to FeatureSurveyConfig and compute a deterministic
bucket (e.g., hash(stableUserId + resolvedConfig.value.featureId) / MAX ->
[0,1)) and expose it as a computed (e.g., sampleBucket) then include a check in
isEligible that returns false when sampleBucket >=
resolvedConfig.value.sampleRate; reference FeatureSurveyConfig to add
sampleRate, use resolvedConfig and resolvedConfig.value.featureId to build the
bucket, use a stable identity source (e.g., installation/user id) and add the
new bucket check in the isEligible computed alongside the existing gates.
- Around line 32-79: state read from useStorage is untrusted and can be
malformed; ensure normalization and immutable writes: when initializing/reading,
validate that state.value is an object with seenSurveys as a plain object,
lastSurveyShown as number|null and optedOut as boolean (falling back to {
seenSurveys: {}, lastSurveyShown: null, optedOut: false } if not), update
hasSeenSurvey to safely check (e.g., const seen = state.value?.seenSurveys ?? {}
and use seen[resolvedConfig.value.featureId]), and change markSurveyShown to
perform an immutable update (e.g., const now = Date.now(); state.value = {
...state.value, seenSurveys: { ...(state.value?.seenSurveys ?? {}),
[resolvedConfig.value.featureId]: now }, lastSurveyShown: now };) so mutations
are safe and reactive; reference useStorage, state, hasSeenSurvey,
isInGlobalCooldown, markSurveyShown and resolvedConfig/featureId.
| interface FeatureSurveyConfig { | ||
| /** Feature identifier. Must remain static after initialization. */ | ||
| featureId: string | ||
| typeformId: string | ||
| triggerThreshold?: number | ||
| delayMs?: number | ||
| enabled?: boolean | ||
| } | ||
|
|
||
| interface SurveyState { | ||
| seenSurveys: Record<string, number> | ||
| lastSurveyShown: number | null | ||
| optedOut: boolean | ||
| } | ||
|
|
||
| const STORAGE_KEY = 'Comfy.SurveyState' | ||
| const GLOBAL_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000 // 4 days | ||
| const DEFAULT_THRESHOLD = 3 | ||
| const DEFAULT_DELAY_MS = 5000 | ||
|
|
||
| export function useSurveyEligibility( | ||
| config: MaybeRefOrGetter<FeatureSurveyConfig> | ||
| ) { | ||
| const state = useStorage<SurveyState>(STORAGE_KEY, { | ||
| seenSurveys: {}, | ||
| lastSurveyShown: null, | ||
| optedOut: false | ||
| }) | ||
| const resolvedConfig = computed(() => toValue(config)) | ||
|
|
||
| const { useCount } = useFeatureUsageTracker(resolvedConfig.value.featureId) | ||
|
|
||
| const threshold = computed( | ||
| () => resolvedConfig.value.triggerThreshold ?? DEFAULT_THRESHOLD | ||
| ) | ||
| const delayMs = computed( | ||
| () => resolvedConfig.value.delayMs ?? DEFAULT_DELAY_MS | ||
| ) | ||
| const enabled = computed(() => resolvedConfig.value.enabled ?? true) | ||
|
|
||
| const isNightlyLocalhost = computed(() => isNightly && !isCloud && !isDesktop) | ||
|
|
||
| const hasReachedThreshold = computed(() => useCount.value >= threshold.value) | ||
|
|
||
| const hasSeenSurvey = computed( | ||
| () => !!state.value.seenSurveys[resolvedConfig.value.featureId] | ||
| ) | ||
|
|
||
| const isInGlobalCooldown = computed(() => { | ||
| if (!state.value.lastSurveyShown) return false | ||
| return Date.now() - state.value.lastSurveyShown < GLOBAL_COOLDOWN_MS | ||
| }) | ||
|
|
||
| const hasOptedOut = computed(() => state.value.optedOut) | ||
|
|
||
| const isEligible = computed(() => { | ||
| if (!enabled.value) return false | ||
| if (!isNightlyLocalhost.value) return false | ||
| if (!hasReachedThreshold.value) return false | ||
| if (hasSeenSurvey.value) return false | ||
| if (isInGlobalCooldown.value) return false | ||
| if (hasOptedOut.value) return false | ||
|
|
||
| return true | ||
| }) |
There was a problem hiding this comment.
Missing sampling gate described in PR objectives.
I don’t see a sample-rate field or any deterministic sampling check in isEligible, so every eligible user will be shown the survey. If sampling is still required, add a sampleRate (and a stable user bucketing key) to the config and gate eligibility on it; otherwise, please update the PR objective/test expectations to reflect its removal.
🤖 Prompt for AI Agents
In `@src/platform/surveys/useSurveyEligibility.ts` around lines 9 - 73, The config
lacks a sampling gate: add an optional sampleRate:number (0-1) to
FeatureSurveyConfig and compute a deterministic bucket (e.g., hash(stableUserId
+ resolvedConfig.value.featureId) / MAX -> [0,1)) and expose it as a computed
(e.g., sampleBucket) then include a check in isEligible that returns false when
sampleBucket >= resolvedConfig.value.sampleRate; reference FeatureSurveyConfig
to add sampleRate, use resolvedConfig and resolvedConfig.value.featureId to
build the bucket, use a stable identity source (e.g., installation/user id) and
add the new bucket check in the isEligible computed alongside the existing
gates.
| const state = useStorage<SurveyState>(STORAGE_KEY, { | ||
| seenSurveys: {}, | ||
| lastSurveyShown: null, | ||
| optedOut: false | ||
| }) | ||
| const resolvedConfig = computed(() => toValue(config)) | ||
|
|
||
| const { useCount } = useFeatureUsageTracker(resolvedConfig.value.featureId) | ||
|
|
||
| const threshold = computed( | ||
| () => resolvedConfig.value.triggerThreshold ?? DEFAULT_THRESHOLD | ||
| ) | ||
| const delayMs = computed( | ||
| () => resolvedConfig.value.delayMs ?? DEFAULT_DELAY_MS | ||
| ) | ||
| const enabled = computed(() => resolvedConfig.value.enabled ?? true) | ||
|
|
||
| const isNightlyLocalhost = computed(() => isNightly && !isCloud && !isDesktop) | ||
|
|
||
| const hasReachedThreshold = computed(() => useCount.value >= threshold.value) | ||
|
|
||
| const hasSeenSurvey = computed( | ||
| () => !!state.value.seenSurveys[resolvedConfig.value.featureId] | ||
| ) | ||
|
|
||
| const isInGlobalCooldown = computed(() => { | ||
| if (!state.value.lastSurveyShown) return false | ||
| return Date.now() - state.value.lastSurveyShown < GLOBAL_COOLDOWN_MS | ||
| }) | ||
|
|
||
| const hasOptedOut = computed(() => state.value.optedOut) | ||
|
|
||
| const isEligible = computed(() => { | ||
| if (!enabled.value) return false | ||
| if (!isNightlyLocalhost.value) return false | ||
| if (!hasReachedThreshold.value) return false | ||
| if (hasSeenSurvey.value) return false | ||
| if (isInGlobalCooldown.value) return false | ||
| if (hasOptedOut.value) return false | ||
|
|
||
| return true | ||
| }) | ||
|
|
||
| function markSurveyShown() { | ||
| const now = Date.now() | ||
| state.value.seenSurveys[resolvedConfig.value.featureId] = now | ||
| state.value.lastSurveyShown = now | ||
| } |
There was a problem hiding this comment.
Normalize localStorage state before access to avoid crashes.
state is sourced from localStorage (untrusted) and is accessed/mutated directly; if stored data is malformed (e.g., seenSurveys is null), hasSeenSurvey/markSurveyShown can throw. Normalize the shape before use and prefer immutable writes to keep reactivity reliable.
✅ Proposed fix (normalize + immutable updates)
const state = useStorage<SurveyState>(STORAGE_KEY, {
seenSurveys: {},
lastSurveyShown: null,
optedOut: false
})
const resolvedConfig = computed(() => toValue(config))
+ const safeSeenSurveys = computed<Record<string, number>>(() => {
+ const seen = state.value.seenSurveys
+ return seen && typeof seen === 'object' ? seen : {}
+ })
+
+ const safeLastSurveyShown = computed(() =>
+ typeof state.value.lastSurveyShown === 'number'
+ ? state.value.lastSurveyShown
+ : null
+ )
+
const { useCount } = useFeatureUsageTracker(resolvedConfig.value.featureId)
@@
const hasSeenSurvey = computed(
- () => !!state.value.seenSurveys[resolvedConfig.value.featureId]
+ () => !!safeSeenSurveys.value[resolvedConfig.value.featureId]
)
const isInGlobalCooldown = computed(() => {
- if (!state.value.lastSurveyShown) return false
- return Date.now() - state.value.lastSurveyShown < GLOBAL_COOLDOWN_MS
+ const lastShown = safeLastSurveyShown.value
+ if (lastShown === null) return false
+ return Date.now() - lastShown < GLOBAL_COOLDOWN_MS
})
@@
function markSurveyShown() {
const now = Date.now()
- state.value.seenSurveys[resolvedConfig.value.featureId] = now
- state.value.lastSurveyShown = now
+ state.value = {
+ ...state.value,
+ seenSurveys: {
+ ...safeSeenSurveys.value,
+ [resolvedConfig.value.featureId]: now
+ },
+ lastSurveyShown: now
+ }
}As per coding guidelines, validate trusted sources before processing data and prefer immutability.
🤖 Prompt for AI Agents
In `@src/platform/surveys/useSurveyEligibility.ts` around lines 32 - 79, state
read from useStorage is untrusted and can be malformed; ensure normalization and
immutable writes: when initializing/reading, validate that state.value is an
object with seenSurveys as a plain object, lastSurveyShown as number|null and
optedOut as boolean (falling back to { seenSurveys: {}, lastSurveyShown: null,
optedOut: false } if not), update hasSeenSurvey to safely check (e.g., const
seen = state.value?.seenSurveys ?? {} and use
seen[resolvedConfig.value.featureId]), and change markSurveyShown to perform an
immutable update (e.g., const now = Date.now(); state.value = { ...state.value,
seenSurveys: { ...(state.value?.seenSurveys ?? {}),
[resolvedConfig.value.featureId]: now }, lastSurveyShown: now };) so mutations
are safe and reactive; reference useStorage, state, hasSeenSurvey,
isInGlobalCooldown, markSurveyShown and resolvedConfig/featureId.
- Rename 'enabled' to 'isSurveyEnabled' for clarity - Remove lastSurveyShown field, derive from seenSurveys collection - Make SurveyState interface fields optional and alphabetically sorted - Reduce exposed return values to just isEligible, delayMs, and actions - Simplify mock object in tests, use top-level import - Update tests to verify behavior through isEligible only
🔧 Auto-fixes AppliedThis PR has been automatically updated to fix linting and formatting issues.
Changes made:
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/platform/surveys/useSurveyEligibility.test.ts`:
- Around line 241-245: Remove the "uses default delayMs when not specified" unit
test (or fold its expectation into an existing behavior-driven test) because it
merely asserts a constant value; locate the test named 'uses default delayMs
when not specified' in the useSurveyEligibility tests, remove the direct
default-value assertion on delayMs.value (and instead, if needed, verify that
omitting delayMs in the defaultConfig leads to the expected observable behavior
of useSurveyEligibility—e.g., eligibility timing or debounce behavior—within an
existing test that exercises useSurveyEligibility).
In `@src/platform/surveys/useSurveyEligibility.ts`:
- Around line 23-25: The GLOBAL_COOLDOWN_MS constant in useSurveyEligibility is
incorrectly set to 4 days; update GLOBAL_COOLDOWN_MS to represent 14 days (14 *
24 * 60 * 60 * 1000) so the global cooldown matches the PR objective, and then
update any tests that assert cooldown durations or eligibility timing to expect
14 days instead of 4; locate the constant by name (GLOBAL_COOLDOWN_MS) in
src/platform/surveys/useSurveyEligibility.ts and update corresponding unit tests
that reference this constant or hard-coded millisecond values.
| const STORAGE_KEY = 'Comfy.SurveyState' | ||
| const GLOBAL_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000 // 4 days | ||
| const DEFAULT_THRESHOLD = 3 |
There was a problem hiding this comment.
Global cooldown is 4 days, not 14.
PR objective calls for a 14‑day global cooldown, but GLOBAL_COOLDOWN_MS is set to 4 days. This changes eligibility timing and will make surveys appear much more frequently than intended. Update the constant (and adjust related tests) to 14 days.
🛠️ Proposed fix
-const GLOBAL_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000 // 4 days
+const GLOBAL_COOLDOWN_MS = 14 * 24 * 60 * 60 * 1000 // 14 days🤖 Prompt for AI Agents
In `@src/platform/surveys/useSurveyEligibility.ts` around lines 23 - 25, The
GLOBAL_COOLDOWN_MS constant in useSurveyEligibility is incorrectly set to 4
days; update GLOBAL_COOLDOWN_MS to represent 14 days (14 * 24 * 60 * 60 * 1000)
so the global cooldown matches the PR objective, and then update any tests that
assert cooldown durations or eligibility timing to expect 14 days instead of 4;
locate the constant by name (GLOBAL_COOLDOWN_MS) in
src/platform/surveys/useSurveyEligibility.ts and update corresponding unit tests
that reference this constant or hard-coded millisecond values.
## Summary Adds a centralized registry for feature survey configurations. ## Changes - Add `surveyRegistry.ts` with `FEATURE_SURVEYS` record for survey configs - Add helper functions `getSurveyConfig()` and `getEnabledSurveys()` - Export `FeatureId` type for type-safe feature references ## Part of Nightly Survey System This is part 3 of a stacked PR chain: 1. ✅ feat/feature-usage-tracker - useFeatureUsageTracker (merged in #8189) 2. ✅ feat/survey-eligibility - useSurveyEligibility (#8189, merged) 3. **feat/survey-config** - surveyRegistry.ts (this PR) 4. feat/survey-popover - NightlySurveyPopover.vue 5. feat/survey-integration - NightlySurveyController.vue ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8355-feat-add-survey-registry-for-feature-survey-configurations-2f66d73d365081faae6bda0c14c069d9) by [Unito](https://www.unito.io)
## Summary Adds NightlySurveyPopover component that displays a Typeform survey to eligible nightly users after a configurable delay. ## Changes - **What**: Vue component that uses `useSurveyEligibility` to show/hide a survey popover with accept, dismiss, and opt-out actions. Loads Typeform embed script dynamically with HTTPS and deduplication. ## Review Focus - Typeform script injection security (HTTPS-only, load-once guard, typeformId alphanumeric validation) - Timeout lifecycle (clears pending timeout when eligibility changes) ## Part of Nightly Survey System This is part 4 of a stacked PR chain: 1. ✅ feat/feature-usage-tracker - useFeatureUsageTracker (merged in #8189) 2. ✅ feat/survey-eligibility - useSurveyEligibility (#8189, merged) 3. ✅ feat/survey-config - surveyRegistry.ts (#8355, merged) 4. **feat/survey-popover** - NightlySurveyPopover.vue (this PR) 5. feat/survey-integration - NightlySurveyController.vue (#8480) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9083-feat-add-NightlySurveyPopover-component-for-feature-surveys-30f6d73d365081d1beb2f92555a4b2f4) by [Unito](https://www.unito.io) Co-authored-by: Amp <amp@ampcode.com>
Summary
Adds
useSurveyEligibilitycomposable that determines whether a user should see a survey based on multiple criteria: nightly localhost build only (isNightly && !isCloud && !isDesktop), configurable usage threshold (default 3), 14-day global cooldown between any surveys, once-per-feature-ever display, optional percentage-based sampling, and user opt-out support. All state persists to localStorage. Includes extensive unit tests covering all eligibility conditions.See:
┆Issue is synchronized with this Notion page by Unito