diff --git a/src/__tests__/services/featureFlagService.test.ts b/src/__tests__/services/featureFlagService.test.ts new file mode 100644 index 00000000..9f22d047 --- /dev/null +++ b/src/__tests__/services/featureFlagService.test.ts @@ -0,0 +1,152 @@ +import { + evaluateFlag, + EvaluationContext, + FlagDefinition, +} from '../../services/featureFlagService'; + +const mockContext: EvaluationContext = { + userId: 'user-123', + deviceType: 'ios', + appVersion: '1.15.0', +}; + +describe('evaluateFlag', () => { + it('returns false for undefined definition', () => { + expect(evaluateFlag('testFlag', undefined, mockContext)).toBe(false); + }); + + describe('enabled boolean', () => { + it('returns true when enabled is true', () => { + const def: FlagDefinition = { enabled: true }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(true); + }); + + it('returns false when enabled is false', () => { + const def: FlagDefinition = { enabled: false }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(false); + }); + }); + + describe('user targeting', () => { + it('returns true when user is in includedUserIds', () => { + const def: FlagDefinition = { includedUserIds: ['user-123'] }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(true); + }); + + it('returns false when user is in excludedUserIds', () => { + const def: FlagDefinition = { + enabled: true, + excludedUserIds: ['user-123'], + }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(false); + }); + + it('exclusion takes priority over inclusion', () => { + const def: FlagDefinition = { + enabled: true, + includedUserIds: ['user-123'], + excludedUserIds: ['user-123'], + }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(false); + }); + + it('matches user IDs case-insensitively', () => { + const def: FlagDefinition = { includedUserIds: ['USER-123'] }; + expect(evaluateFlag('testFlag', def, { ...mockContext, userId: 'User-123' })).toBe(true); + }); + }); + + describe('device type targeting', () => { + it('returns true when device type is included', () => { + const def: FlagDefinition = { includedDeviceTypes: ['ios'] }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(true); + }); + + it('returns false when device type is excluded', () => { + const def: FlagDefinition = { + enabled: true, + excludedDeviceTypes: ['ios'], + }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(false); + }); + + it('ignores device type when set to unknown', () => { + const def: FlagDefinition = { includedDeviceTypes: ['ios'] }; + const ctx = { ...mockContext, deviceType: 'unknown' as const }; + expect(evaluateFlag('testFlag', def, ctx)).toBe(false); + }); + }); + + describe('app version targeting', () => { + it('returns false when app version is below minimum', () => { + const def: FlagDefinition = { enabled: true, minAppVersion: '2.0.0' }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(false); + }); + + it('returns true when app version meets minimum', () => { + const def: FlagDefinition = { enabled: true, minAppVersion: '1.0.0' }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(true); + }); + + it('returns false when app version is above maximum', () => { + const def: FlagDefinition = { enabled: true, maxAppVersion: '1.0.0' }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(false); + }); + + it('returns true when app version is in range', () => { + const def: FlagDefinition = { + enabled: true, + minAppVersion: '1.0.0', + maxAppVersion: '2.0.0', + }; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(true); + }); + }); + + describe('percentage rollout', () => { + it('consistently assigns the same user to the same bucket', () => { + const def: FlagDefinition = { percentage: 50 }; + const result1 = evaluateFlag('testFlag', def, { userId: 'user-A' }); + const result2 = evaluateFlag('testFlag', def, { userId: 'user-A' }); + expect(result1).toBe(result2); + }); + + it('returns false for 0% rollout', () => { + const def: FlagDefinition = { percentage: 0 }; + expect(evaluateFlag('testFlag', def, { userId: 'user-123' })).toBe(false); + }); + + it('returns true for 100% rollout', () => { + const def: FlagDefinition = { percentage: 100 }; + expect(evaluateFlag('testFlag', def, { userId: 'user-123' })).toBe(true); + }); + + it('uses anonymous identifier when no userId', () => { + const def: FlagDefinition = { percentage: 100 }; + expect(evaluateFlag('testFlag', def, {})).toBe(true); + }); + + it('different keys produce different distributions', () => { + const def: FlagDefinition = { percentage: 50 }; + const user = { userId: 'user-consistent' }; + const flagA = evaluateFlag('flagA', def, user); + const flagB = evaluateFlag('flagB', def, user); + // With 50% rollout each, there's a ~75% chance they differ. + // Test that the function doesn't crash and returns boolean. + expect(typeof flagA).toBe('boolean'); + expect(typeof flagB).toBe('boolean'); + }); + }); + + describe('fallback behavior', () => { + it('returns false for empty definition with no targeting rules', () => { + const def: FlagDefinition = {}; + expect(evaluateFlag('testFlag', def, mockContext)).toBe(false); + }); + + it('returns false when no context provided', () => { + const def: FlagDefinition = { enabled: true, includedUserIds: ['admin'] }; + expect(evaluateFlag('testFlag', def, {})).toBe(false); + }); + }); +}); diff --git a/src/__tests__/store/degradationStore.test.ts b/src/__tests__/store/degradationStore.test.ts index 30910e75..1b4c5331 100644 --- a/src/__tests__/store/degradationStore.test.ts +++ b/src/__tests__/store/degradationStore.test.ts @@ -38,6 +38,7 @@ beforeEach(() => { autoDismissDegradationAlerts: true, remindPermissionRetry: true, enableFallbackUX: true, + respectRemoteFlags: true, }, }); }); diff --git a/src/__tests__/store/featureFlagStore.test.ts b/src/__tests__/store/featureFlagStore.test.ts new file mode 100644 index 00000000..93fa972d --- /dev/null +++ b/src/__tests__/store/featureFlagStore.test.ts @@ -0,0 +1,246 @@ +import { + evaluateFlag, + EvaluationContext, + FlagDefinition, + FlagsResponse, +} from '../../services/featureFlagService'; +import { + initializeFeatureFlags, + useFeatureFlagStore, +} from '../../store/featureFlagStore'; + +jest.mock('../../services/featureFlagService', () => { + const actual = jest.requireActual('../../services/featureFlagService'); + return { + ...actual, + fetchRemoteFlags: jest.fn(), + }; +}); + +const { fetchRemoteFlags } = require('../../services/featureFlagService'); + +const getStore = () => useFeatureFlagStore.getState(); + +const mockRemoteResponse: FlagsResponse = { + version: '1.0.0', + updatedAt: '2025-01-15T00:00:00Z', + flags: { + newCheckout: { enabled: true }, + darkMode: { percentage: 50 }, + betaFeature: { enabled: false }, + iosOnly: { includedDeviceTypes: ['ios'] }, + androidOnly: { includedDeviceTypes: ['android'] }, + vipUsers: { includedUserIds: ['vip-001', 'vip-002'] }, + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + (fetchRemoteFlags as jest.Mock).mockResolvedValue(null); + + useFeatureFlagStore.setState({ + flags: { version: '0.0.0', updatedAt: '', flags: {} }, + lastFetchedAt: null, + fetchError: null, + isPolling: false, + context: { + userId: 'user-123', + deviceType: 'ios', + appVersion: '1.15.0', + }, + }); +}); + +describe('featureFlagStore', () => { + describe('isEnabled', () => { + it('returns defaultValue when flag does not exist', () => { + expect(getStore().isEnabled('nonexistent', true)).toBe(true); + expect(getStore().isEnabled('nonexistent', false)).toBe(false); + expect(getStore().isEnabled('nonexistent')).toBe(false); + }); + + it('respects enabled boolean from flags', async () => { + (fetchRemoteFlags as jest.Mock).mockResolvedValue(mockRemoteResponse); + await getStore().refresh(); + + expect(getStore().isEnabled('newCheckout')).toBe(true); + expect(getStore().isEnabled('betaFeature')).toBe(false); + }); + + it('falls back to defaultValue when flag key is missing', () => { + useFeatureFlagStore.setState({ + flags: { version: '1.0', updatedAt: '', flags: { onlyThis: { enabled: true } } }, + }); + + expect(getStore().isEnabled('missingFlag', true)).toBe(true); + expect(getStore().isEnabled('missingFlag', false)).toBe(false); + }); + + it('evaluates device type targeting', () => { + useFeatureFlagStore.setState({ + flags: { ...mockRemoteResponse, flags: { ...mockRemoteResponse.flags } }, + context: { deviceType: 'ios', appVersion: '1.15.0' }, + }); + + expect(getStore().isEnabled('iosOnly')).toBe(true); + expect(getStore().isEnabled('androidOnly')).toBe(false); + }); + + it('evaluates user ID targeting', () => { + useFeatureFlagStore.setState({ + flags: { ...mockRemoteResponse, flags: { ...mockRemoteResponse.flags } }, + context: { userId: 'vip-001', deviceType: 'ios', appVersion: '1.15.0' }, + }); + + expect(getStore().isEnabled('vipUsers')).toBe(true); + + useFeatureFlagStore.setState({ + context: { userId: 'regular-user', deviceType: 'ios', appVersion: '1.15.0' }, + }); + + expect(getStore().isEnabled('vipUsers')).toBe(false); + }); + }); + + describe('refresh', () => { + it('updates flags from remote', async () => { + (fetchRemoteFlags as jest.Mock).mockResolvedValue(mockRemoteResponse); + + await getStore().refresh(); + + expect(getStore().flags.flags.newCheckout).toEqual({ enabled: true }); + expect(getStore().lastFetchedAt).not.toBeNull(); + expect(getStore().fetchError).toBeNull(); + }); + + it('sets fetchError when remote fetch fails', async () => { + (fetchRemoteFlags as jest.Mock).mockRejectedValue(new Error('Network error')); + + await getStore().refresh(); + + expect(getStore().fetchError).toBe('Network error'); + }); + + it('merges remote flags over existing flags', async () => { + useFeatureFlagStore.setState({ + flags: { + version: '0.0.0', + updatedAt: '', + flags: { existingFlag: { enabled: false } }, + }, + }); + + (fetchRemoteFlags as jest.Mock).mockResolvedValue({ + version: '1.0.0', + updatedAt: '', + flags: { existingFlag: { enabled: true } }, + }); + + await getStore().refresh(); + + expect(getStore().isEnabled('existingFlag')).toBe(true); + }); + + it('preserves flags not present in remote response', async () => { + useFeatureFlagStore.setState({ + flags: { + version: '0.0.0', + updatedAt: '', + flags: { onlyLocal: { enabled: true } }, + }, + }); + + (fetchRemoteFlags as jest.Mock).mockResolvedValue({ + version: '1.0.0', + updatedAt: '', + flags: { remoteOnly: { enabled: true } }, + }); + + await getStore().refresh(); + + expect(getStore().isEnabled('onlyLocal')).toBe(true); + expect(getStore().isEnabled('remoteOnly')).toBe(true); + }); + }); + + describe('setContext', () => { + it('merges partial context updates', () => { + getStore().setContext({ userId: 'new-user-456' }); + + expect(getStore().context.userId).toBe('new-user-456'); + expect(getStore().context.deviceType).toBe('ios'); + expect(getStore().context.appVersion).toBe('1.15.0'); + }); + }); + + describe('getDefinition', () => { + it('returns definition for existing flag', () => { + useFeatureFlagStore.setState({ + flags: { ...mockRemoteResponse, flags: { ...mockRemoteResponse.flags } }, + }); + + expect(getStore().getDefinition('newCheckout')).toEqual({ enabled: true }); + }); + + it('returns undefined for missing flag', () => { + expect(getStore().getDefinition('nonexistent')).toBeUndefined(); + }); + }); + + describe('polling', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + getStore().stopPolling(); + }); + + it('sets isPolling to true when started', () => { + getStore().startPolling(); + expect(getStore().isPolling).toBe(true); + }); + + it('does not create duplicate timers', () => { + getStore().startPolling(); + getStore().startPolling(); + expect(getStore().isPolling).toBe(true); + }); + + it('sets isPolling to false when stopped', () => { + getStore().startPolling(); + getStore().stopPolling(); + expect(getStore().isPolling).toBe(false); + }); + + it('calls refresh on each interval tick', async () => { + (fetchRemoteFlags as jest.Mock).mockResolvedValue(mockRemoteResponse); + + getStore().startPolling(); + + jest.advanceTimersByTime(15 * 60 * 1000); + await Promise.resolve(); + + expect(fetchRemoteFlags).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(15 * 60 * 1000); + await Promise.resolve(); + + expect(fetchRemoteFlags).toHaveBeenCalledTimes(2); + }); + }); + + describe('initializeFeatureFlags', () => { + it('fetches flags and starts polling', async () => { + (fetchRemoteFlags as jest.Mock).mockResolvedValue(mockRemoteResponse); + + await initializeFeatureFlags(); + + expect(getStore().flags.flags.newCheckout).toEqual({ enabled: true }); + expect(getStore().isPolling).toBe(true); + + getStore().stopPolling(); + }); + }); +}); diff --git a/src/services/featureFlagService.ts b/src/services/featureFlagService.ts new file mode 100644 index 00000000..1039b2f7 --- /dev/null +++ b/src/services/featureFlagService.ts @@ -0,0 +1,172 @@ +import { Platform } from 'react-native'; + +import { apiService } from './api'; +import { appLogger } from '../utils/logger'; + +const FLAGS_ENDPOINT = '/api/config/flags'; + +export type DeviceType = 'ios' | 'android' | 'unknown'; + +export interface FlagDefinition { + enabled?: boolean; + percentage?: number; + includedUserIds?: string[]; + excludedUserIds?: string[]; + includedDeviceTypes?: DeviceType[]; + excludedDeviceTypes?: DeviceType[]; + minAppVersion?: string; + maxAppVersion?: string; + description?: string; +} + +export interface FlagsResponse { + version: string; + updatedAt: string; + flags: Record; +} + +export interface EvaluationContext { + userId?: string; + deviceType?: DeviceType; + appVersion?: string; +} + +function normalizeDeviceType(raw?: string): DeviceType { + if (raw === 'ios' || raw === 'android') return raw; + return 'unknown'; +} + +function getPlatformDeviceType(): DeviceType { + return normalizeDeviceType(Platform.OS); +} + +function semverCompare(a: string, b: string): number { + const aParts = a.split('.').map(Number); + const bParts = b.split('.').map(Number); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i += 1) { + const aVal = aParts[i] || 0; + const bVal = bParts[i] || 0; + if (aVal > bVal) return 1; + if (aVal < bVal) return -1; + } + return 0; +} + +function hashPercentage(key: string, identifier: string): number { + let hash = 0; + const input = `${key}:${identifier}`; + for (let i = 0; i < input.length; i += 1) { + hash = (hash << 5) - hash + input.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash) % 100; +} + +/** + * Evaluates a single flag definition against the given context. + * Pure function — does not depend on any internal state. + * + * Evaluation order (first match wins): + * 1. User exclusion list -> false + * 2. User inclusion list -> true + * 3. Device type exclusion -> false + * 4. Device type inclusion -> true + * 5. App version range check -> false if out of range + * 6. `enabled` boolean + * 7. Percentage rollout (consistent hash-based) + * 8. false (default) + */ +export function evaluateFlag( + key: string, + definition: FlagDefinition | undefined, + context: EvaluationContext +): boolean { + if (!definition) return false; + + const { userId, deviceType, appVersion } = context; + + if (userId) { + const normalizedId = userId.trim().toLowerCase(); + if (definition.excludedUserIds?.map(id => id.trim().toLowerCase()).includes(normalizedId)) { + return false; + } + if (definition.includedUserIds?.map(id => id.trim().toLowerCase()).includes(normalizedId)) { + return true; + } + } + + if (deviceType && deviceType !== 'unknown') { + if (definition.excludedDeviceTypes?.includes(deviceType)) { + return false; + } + if (definition.includedDeviceTypes?.includes(deviceType)) { + return true; + } + } + + if (appVersion) { + if (definition.minAppVersion && semverCompare(appVersion, definition.minAppVersion) < 0) { + return false; + } + if (definition.maxAppVersion && semverCompare(appVersion, definition.maxAppVersion) > 0) { + return false; + } + } + + if (typeof definition.enabled === 'boolean') { + return definition.enabled; + } + + if (typeof definition.percentage === 'number') { + const identifier = userId || 'anonymous'; + return hashPercentage(key, identifier) < definition.percentage; + } + + return false; +} + +export async function fetchRemoteFlags(): Promise { + try { + const response = await apiService.get(FLAGS_ENDPOINT); + if (!response || !response.flags || typeof response.flags !== 'object') { + appLogger.warnSync('Remote flags response missing .flags map'); + return null; + } + + return { + version: response.version || '0.0.0', + updatedAt: response.updatedAt || '', + flags: response.flags, + }; + } catch (error) { + appLogger.warnSync( + 'Failed to fetch remote feature flags', + error instanceof Error ? error : new Error(String(error)), + { endpoint: FLAGS_ENDPOINT } + ); + return null; + } +} + +let platformAppVersion: string | null = null; + +export function getPlatformContext(): Pick { + if (platformAppVersion === null) { + try { + platformAppVersion = require('expo-constants').default?.expoConfig?.version || '0.0.0'; + } catch { + platformAppVersion = '0.0.0'; + } + } + + return { + deviceType: getPlatformDeviceType(), + appVersion: platformAppVersion, + }; +} + +/** + * Baked-in fallback flags shipped with the app binary. + * Used when no remote config is available (first launch, offline). + */ +export const DEFAULT_FLAGS: Record = {}; diff --git a/src/store/degradationStore.ts b/src/store/degradationStore.ts index f64bad80..e8d392f4 100644 --- a/src/store/degradationStore.ts +++ b/src/store/degradationStore.ts @@ -26,6 +26,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { FeatureStatus, FeatureType } from '../services/featureCapabilities'; +import { useFeatureFlagStore } from './featureFlagStore'; export interface DegradationNotification { id: string; @@ -42,6 +43,7 @@ export interface DegradationPreferences { autoDismissDegradationAlerts: boolean; // Auto-dismiss alerts after 5 seconds remindPermissionRetry: boolean; // Remind user to grant permissions after 1 hour enableFallbackUX: boolean; // Use fallback UX when features unavailable (always true) + respectRemoteFlags: boolean; // Check remote feature flags before marking feature available } interface DegradationState { @@ -72,6 +74,7 @@ interface DegradationState { setShowDegradationBanners: (show: boolean) => void; setAutoDismissAlerts: (autoDismiss: boolean) => void; setRemindPermissionRetry: (remind: boolean) => void; + setRespectRemoteFlags: (respect: boolean) => void; } const DEFAULT_PREFERENCES: DegradationPreferences = { @@ -79,6 +82,7 @@ const DEFAULT_PREFERENCES: DegradationPreferences = { autoDismissDegradationAlerts: true, remindPermissionRetry: true, enableFallbackUX: true, + respectRemoteFlags: true, }; let notificationIdCounter = 0; @@ -135,11 +139,21 @@ export const useDegradationStore = create()( isFeatureDegraded: (feature: FeatureType): boolean => { const status = get().featureStatuses[feature]; - return ( + const hardwareDegraded = status === FeatureStatus.PERMISSION_DENIED || status === FeatureStatus.HARDWARE_UNAVAILABLE || - status === FeatureStatus.UNAVAILABLE - ); + status === FeatureStatus.UNAVAILABLE; + + if (hardwareDegraded) return true; + + if (get().preferences.respectRemoteFlags) { + const featureFlags = useFeatureFlagStore.getState(); + if (featureFlags.isEnabled(feature, true) === false) { + return true; + } + } + + return false; }, getDegradedFeatures: (): FeatureType[] => { @@ -204,6 +218,12 @@ export const useDegradationStore = create()( preferences: { ...state.preferences, remindPermissionRetry: remind }, })); }, + + setRespectRemoteFlags: (respect: boolean) => { + set(state => ({ + preferences: { ...state.preferences, respectRemoteFlags: respect }, + })); + }, }), { name: 'degradation-store', diff --git a/src/store/featureFlagStore.ts b/src/store/featureFlagStore.ts new file mode 100644 index 00000000..59333d33 --- /dev/null +++ b/src/store/featureFlagStore.ts @@ -0,0 +1,164 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { + DEFAULT_FLAGS, + evaluateFlag, + EvaluationContext, + fetchRemoteFlags, + FlagDefinition, + FlagsResponse, + getPlatformContext, +} from '../services/featureFlagService'; +import { appLogger } from '../utils/logger'; + +const POLL_INTERVAL_MS = 15 * 60 * 1000; + +interface FeatureFlagState { + flags: FlagsResponse; + lastFetchedAt: number | null; + fetchError: string | null; + isPolling: boolean; + context: EvaluationContext; + + setContext: (context: Partial) => void; + isEnabled: (flagKey: string, defaultValue?: boolean) => boolean; + getDefinition: (flagKey: string) => FlagDefinition | undefined; + refresh: () => Promise; + startPolling: () => void; + stopPolling: () => void; +} + +function mergeFlags(remote: FlagsResponse | null, persisted: FlagsResponse | null): FlagsResponse { + const merged: Record = {}; + + Object.entries(DEFAULT_FLAGS).forEach(([key, def]) => { + merged[key] = def; + }); + + if (persisted?.flags) { + Object.entries(persisted.flags).forEach(([key, def]) => { + merged[key] = def; + }); + } + + if (remote?.flags) { + Object.entries(remote.flags).forEach(([key, def]) => { + merged[key] = def; + }); + } + + return { + version: remote?.version || persisted?.version || '0.0.0', + updatedAt: remote?.updatedAt || persisted?.updatedAt || '', + flags: merged, + }; +} + +let pollTimerId: ReturnType | null = null; + +const initialState: FlagsResponse = { + version: '0.0.0', + updatedAt: '', + flags: { ...DEFAULT_FLAGS }, +}; + +export const useFeatureFlagStore = create()( + persist( + (set, get) => { + const platformContext = getPlatformContext(); + + return { + flags: initialState, + lastFetchedAt: null, + fetchError: null, + isPolling: false, + context: platformContext, + + setContext: (partial: Partial) => { + set(state => ({ + context: { ...state.context, ...partial }, + })); + }, + + isEnabled: (flagKey: string, defaultValue = false): boolean => { + const { flags, context } = get(); + const definition = flags.flags[flagKey]; + if (!definition) return defaultValue; + return evaluateFlag(flagKey, definition, context); + }, + + getDefinition: (flagKey: string): FlagDefinition | undefined => { + return get().flags.flags[flagKey]; + }, + + refresh: async () => { + try { + const remote = await fetchRemoteFlags(); + if (remote) { + set(state => ({ + flags: mergeFlags(remote, state.flags), + lastFetchedAt: Date.now(), + fetchError: null, + })); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + set({ fetchError: message }); + appLogger.warnSync( + 'Feature flag refresh failed', + error instanceof Error ? error : new Error(message) + ); + } + }, + + startPolling: () => { + if (pollTimerId !== null) return; + + set({ isPolling: true }); + + pollTimerId = setInterval(() => { + void get().refresh(); + }, POLL_INTERVAL_MS); + }, + + stopPolling: () => { + if (pollTimerId !== null) { + clearInterval(pollTimerId); + pollTimerId = null; + } + set({ isPolling: false }); + }, + }; + }, + { + name: 'feature-flag-store', + storage: createJSONStorage(() => AsyncStorage), + version: 1, + partialize: state => ({ + flags: state.flags, + lastFetchedAt: state.lastFetchedAt, + }), + merge: (persisted, current) => { + const p = (persisted ?? {}) as Partial; + return { + ...current, + flags: mergeFlags(null, p.flags ?? null), + lastFetchedAt: p.lastFetchedAt ?? null, + }; + }, + } + ) +); + +/** + * Bootstrap feature flags on app launch. + * Fetches remote flags, then starts periodic polling every 15 minutes. + * Call once from the root component or splash screen. + */ +export async function initializeFeatureFlags(): Promise { + const store = useFeatureFlagStore.getState(); + await store.refresh(); + store.startPolling(); +}