diff --git a/docs/PERFORMANCE_MONITORING.md b/docs/PERFORMANCE_MONITORING.md index 87561083..ffd1c7ee 100644 --- a/docs/PERFORMANCE_MONITORING.md +++ b/docs/PERFORMANCE_MONITORING.md @@ -153,6 +153,60 @@ ALERT` warning. In a real deployment this would fan out to a Slack channel or Pa --- +## Analytics Sampling Policy + +Analytics events are sampled at the source to control volume and cost. The policy +is defined in `src/services/analytics/samplingPolicy.ts` and enforced in +`MobileAnalyticsService.trackEvent()` and `AnalyticsBatchQueue.enqueue()`. + +### Event Frequency Classification + +| Frequency | Sampling Rate | Events | +|---|---|---| +| `critical` | 100% | Session lifecycle, auth, course/quiz start/end, API errors, crashes | +| `high` | 20% | Screen views, content views/shares, search, form submits | +| `medium` | 10% | UI clicks, button clicks, content likes, review prompts | +| `low` | 5% | Performance metrics, React profiler, A/B tests, web vitals, app lifecycle | + +Critical events bypass sampling entirely — they are always sent. + +### High-Frequency Throttle + +Events tagged with `event_category: 'high_frequency'` in their properties are +throttled to a maximum of **10 events per second** per event name, applied before +sampling. This prevents burst spam from cache stats or render profilers. + +### Session Event Budget + +The `AnalyticsBatchQueue` enforces a per-session budget of **500 events** +(`SESSION_EVENT_BUDGET`). Once exhausted, all subsequent events are silently +dropped. The drop count is tracked for observability: + +```ts +import { mobileAnalyticsService } from '@/services/mobileAnalytics'; + +// After a session, check how many events were dropped +const dropped = mobileAnalyticsService.getDroppedCount(); +``` + +Dropped events are logged at WARN level (throttled to every 50th drop) to avoid +log spam while remaining visible in production monitoring. + +### Observability + +- **Sampling drops**: logged at DEBUG level with `[${frequency}] dropped by sampling policy` +- **Throttle drops**: counted in `droppedCount` (no log — by design, these are expected) +- **Budget drops**: logged at WARN level every 50 drops +- **Total drops**: accessible via `mobileAnalyticsService.getDroppedCount()` + +### Adjusting the Policy + +To change sampling rates, edit `SAMPLING_RATES` in `samplingPolicy.ts`. To adjust +the session budget, change `SESSION_EVENT_BUDGET`. Both are module-level constants +that take effect without restart when the module is re-evaluated. + +--- + ## Related Documents - [PERFORMANCE_TESTING.md](./PERFORMANCE_TESTING.md) — component-level perf test guide diff --git a/docs/conflict-resolution-strategy.md b/docs/conflict-resolution-strategy.md index 6ec31424..8d1faecf 100644 --- a/docs/conflict-resolution-strategy.md +++ b/docs/conflict-resolution-strategy.md @@ -8,6 +8,36 @@ Each tracked entity carries: - `checksum`: a stable checksum of the entity payload for quick equality checks. - `baseEntity`: the last server version the client saw before local edits. +## Architecture — Single Detection, Single Resolution + +Conflict handling is consolidated into two modules: + +``` +HTTP path (axios.config.ts 409 handler) + → sync/httpConflictDetection.ts — detection & ConflictData construction + → store/conflictStore.ts — UI resolution queue + +WebSocket path (socket/index.ts) + → sync/syncEntityManager.ts — detection & resolution in one pass + → sync/conflictResolver.ts — pure resolution functions + → sync/versionStore.ts — persistent version state +``` + +`syncService.ts` is orchestration only — it delegates conflict detection to +`httpConflictDetection.isConflictError()` and resolution to +`syncEntityManager.resolveRawConflict()` / `handleServerEntity()`. It no +longer contains its own detection or resolution logic. + +### Key invariants + +1. **One detection path per transport.** HTTP conflicts are detected in the + axios interceptor (status 409). WebSocket conflicts are detected inside + `syncEntityManager.handleServerEntity()`. +2. **One resolution path.** All resolution goes through `conflictResolver.ts` + functions (`resolveConflict`, `processServerUpdate`). +3. **UI conflicts go through conflictStore.** The 409 handler writes a + `ConflictData` record to `conflictStore` for user-mediated resolution. + ## Conflict Detection An incoming server update is not a conflict when its payload matches the local @@ -17,6 +47,9 @@ equal or newer. An incoming server update is a conflict when the local payload differs from the server payload while the client still has pending local edits. +For HTTP requests, a 409 status code indicates the client's `lastKnownVersion` +is behind the server's current version. + ## Resolution Modes `server-wins` accepts the server entity and clears `clientSeq`. Use this for @@ -58,3 +91,16 @@ Versioned real-time messages use this shape: The client stores accepted versions in `versionStore` and keeps a base copy while local edits are pending. After any successful server update or conflict resolution, the resolved entity becomes the new base. + +## File Reference + +| File | Responsibility | +|---|---| +| `sync/conflictResolver.ts` | Pure conflict detection + resolution functions | +| `sync/syncEntityManager.ts` | Versioned entity lifecycle, delegates to conflictResolver | +| `sync/httpConflictDetection.ts` | HTTP 409 detection, ConflictData construction | +| `sync/versionStore.ts` | In-memory version state persistence | +| `sync/types.ts` | Shared type definitions | +| `store/conflictStore.ts` | UI conflict queue (Zustand) | +| `syncService.ts` | Sync orchestration (delegates conflict handling) | +| `api/axios.config.ts` | HTTP interceptor (delegates to httpConflictDetection) | diff --git a/docs/queue-priority-strategy.md b/docs/queue-priority-strategy.md index 1a9d0c0c..f704ca5a 100644 --- a/docs/queue-priority-strategy.md +++ b/docs/queue-priority-strategy.md @@ -2,7 +2,7 @@ ## Overview -The `RequestQueue` service (`src/services/api/requestQueue.ts`) manages offline requests that fail due to network errors. It persists requests to AsyncStorage, supports priority levels, and batches similar requests during sync. +The `RequestQueue` service (`src/services/api/requestQueue.ts`) manages offline requests that fail due to network errors. It persists requests to AsyncStorage, supports priority levels, deduplicates identical requests, enforces a maximum queue size, and batches similar requests during sync. ## Priority Levels @@ -15,6 +15,35 @@ The `RequestQueue` service (`src/services/api/requestQueue.ts`) manages offline The queue is sorted by priority then FIFO within each priority level. +## Deduplication + +Duplicate requests are suppressed at enqueue time using a deterministic fingerprint derived from `method + URL + serialized body`. Two requests sharing the same fingerprint produce only one queue entry. + +### GET collapsing + +GET requests are treated specially: when a duplicate GET is queued, the existing entry is **replaced** with the newest version rather than being suppressed. This ensures that reconnection replays only the most recent read for each endpoint, avoiding stale-data replays. + +### Mutation deduplication + +For POST/PUT/DELETE, duplicates are suppressed entirely (the existing entry is kept). Combined with the Idempotency-Key header generated by the axios interceptor, this prevents duplicate writes on reconnection. + +## Queue Size Bound + +The queue is capped at **100 entries** (`MAX_QUEUE_SIZE`). When the cap is exceeded: + +1. The oldest **low-priority** entry is evicted first. +2. If no low-priority entries remain, **normal** entries are evicted. +3. If no normal entries remain, **high** entries are evicted. +4. **Critical** entries are **never** evicted. + +Evicted entries are logged with a warning. The `getDroppedCount()` method exposes the total number of evictions since app start for observability. + +### Eviction policy rationale + +- GET requests are collapsed (replaced), so evicting an old GET loses nothing — the newer one remains. +- Mutation dedup means at most one entry per mutation fingerprint, so the queue stays small even during extended offline periods. +- Critical requests (payments, auth) are protected to ensure financial and security operations are never silently dropped. + ## Persistence - All queued requests are stored in AsyncStorage under `@teachlink_request_queue` @@ -37,6 +66,12 @@ Queue events are tracked via `mobileAnalyticsService.trackEvent()`: - `queue_batch_synced` — when a batch merge succeeds - `queue_resumed` — on app restart with pending requests +## Observability + +- **Dropped counter**: `requestQueue.getDroppedCount()` returns the number of evictions since app start. +- **Eviction logs**: Each eviction produces a `logger.warn` with the evicted request details. +- **Dedup logs**: Each suppressed duplicate or collapsed GET produces a `logger.info`. + ## Usage ```ts @@ -48,6 +83,9 @@ await requestQueue.addToQueue(config, 'high'); // Check status const status = await requestQueue.getQueueStatus(); +// Check evictions +const dropped = requestQueue.getDroppedCount(); + // Monitor from hook import { usePendingRequests } from '../hooks/usePendingRequests'; const count = usePendingRequests(); diff --git a/src/components/mobile/AccountActionsSection.tsx b/src/components/mobile/AccountActionsSection.tsx new file mode 100644 index 00000000..d7706aa9 --- /dev/null +++ b/src/components/mobile/AccountActionsSection.tsx @@ -0,0 +1,102 @@ +import React, { memo, useCallback } from 'react'; +import { Alert, Platform } from 'react-native'; + +import { SettingRow } from './SettingRow'; +import { ICON_LOGOUT_RED, ICON_ALERT } from './settingsIcons'; +import { SettingsSection } from './SettingsSection'; +import { useRequireReauth } from '../../hooks'; + +interface AccountActionsSectionProps { + onSignOut: () => void; +} + +/** + * Memoised Account Actions section — sign out and delete account. + * Both require confirmation dialogs and delete also needs reauth. + */ +export const AccountActionsSection = memo(function AccountActionsSection({ + onSignOut, +}: AccountActionsSectionProps) { + const { performReauthCheck } = useRequireReauth(); + + const handleSignOut = useCallback(() => { + Alert.alert('Sign Out', 'Are you sure?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Sign Out', style: 'destructive', onPress: onSignOut }, + ]); + }, [onSignOut]); + + const handleDeleteAccount = useCallback(async () => { + const authorized = await performReauthCheck(); + if (!authorized) { + Alert.alert('Re-authentication Failed', 'Verification required to delete your account.'); + return; + } + + Alert.alert( + 'Delete Account', + 'This action is irreversible. All your data, progress, and purchases will be permanently deleted.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Continue', + style: 'destructive', + onPress: () => { + if (Platform.OS === 'ios') { + Alert.alert( + 'Are you absolutely sure?', + 'Type DELETE in the next prompt to confirm account deletion.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + Alert.alert('Account Deleted', 'Your account has been deleted.'); + }, + }, + ] + ); + } else { + Alert.alert( + 'Confirm Deletion', + 'Please type DELETE to confirm', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + Alert.alert('Account Deleted', 'Your account has been deleted.'); + }, + }, + ], + { cancelable: true } + ); + } + }, + }, + ] + ); + }, [performReauthCheck]); + + return ( + + + + + ); +}); diff --git a/src/components/mobile/AccountSection.tsx b/src/components/mobile/AccountSection.tsx new file mode 100644 index 00000000..b3161f9e --- /dev/null +++ b/src/components/mobile/AccountSection.tsx @@ -0,0 +1,155 @@ +import React, { memo, useCallback, useMemo } from 'react'; +import { ActivityIndicator, Alert } from 'react-native'; + +import { NativeToggle } from './NativeToggle'; +import { SettingRow } from './SettingRow'; +import { SettingsPicker } from './SettingsPicker'; +import { ICON_EYE, ICON_LOCK, ICON_FINGERPRINT, ICON_USER, ICON_CREDIT_CARD_YELLOW, ICON_CREDIT_CARD_GREEN } from './settingsIcons'; +import { VISIBILITY_OPTIONS } from './settingsOptions'; +import { SettingsSection } from './SettingsSection'; +import { useRequireReauth } from '../../hooks'; +import { useBiometricAuth } from '../../hooks/useBiometricAuth'; +import { ProfileVisibility, useSettingsStore } from '../../store/settingsStore'; + +interface AccountSectionProps { + onChangePassword: () => void; +} + +/** + * Memoised Account section — profile visibility, 2FA, biometric login, + * password and payment. Owns all hooks it needs so toggling another + * section never repaints this one. + */ +export const AccountSection = memo(function AccountSection({ + onChangePassword, +}: AccountSectionProps) { + const { + profileVisibility, + setProfileVisibility, + twoFactorEnabled, + setTwoFactorEnabled, + } = useSettingsStore(); + + const { + isAvailable: biometricAvailable, + isEnabled: biometricEnabled, + enable: enableBiometric, + disable: disableBiometric, + isLoading: biometricLoading, + } = useBiometricAuth(); + + const { performReauthCheck } = useRequireReauth(); + + const handleBiometricToggle = useCallback( + async (value: boolean) => { + if (value) { + const ok = await enableBiometric(); + if (!ok) { + Alert.alert('Biometric Login', 'Enable failed. Check device settings.'); + } + } else { + await disableBiometric(); + } + }, + [enableBiometric, disableBiometric] + ); + + const handleChangePaymentMethod = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + Alert.alert('Payment Method', 'Payment method updated successfully.'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to change payment method.'); + } + }, [performReauthCheck]); + + const handleViewFullCardNumber = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + Alert.alert('Card Details', 'Card Number: **** **** **** 4242'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to view card details.'); + } + }, [performReauthCheck]); + + // Memoised right elements (stable references) + const profileVisibilityRight = useMemo( + () => ( + + ), + [profileVisibility, setProfileVisibility] + ); + + const twoFactorRight = useMemo( + () => , + [twoFactorEnabled, setTwoFactorEnabled] + ); + + const biometricIcon = useMemo( + () => (biometricLoading ? : ICON_FINGERPRINT), + [biometricLoading] + ); + + const biometricRight = useMemo( + () => ( + + ), + [biometricEnabled, handleBiometricToggle, biometricLoading] + ); + + return ( + + + + + + {biometricAvailable && ( + + )} + + + + + + ); +}); diff --git a/src/components/mobile/AppSection.tsx b/src/components/mobile/AppSection.tsx new file mode 100644 index 00000000..07140707 --- /dev/null +++ b/src/components/mobile/AppSection.tsx @@ -0,0 +1,56 @@ +import React, { memo, useMemo } from 'react'; + +import { NativeToggle } from './NativeToggle'; +import { SettingRow } from './SettingRow'; +import { SettingsPicker } from './SettingsPicker'; +import { ICON_SUN, ICON_DATABASE } from './settingsIcons'; +import { THEME_OPTIONS } from './settingsOptions'; +import { SettingsSection } from './SettingsSection'; +import { useAppStore, useTheme } from '../../store'; +import { useSettingsStore } from '../../store/settingsStore'; + +/** + * Memoised App section — theme picker and data saver toggle. + * Owns the useTheme / setTheme hooks directly. + */ +export const AppSection = memo(function AppSection() { + const theme = useTheme(); + const setTheme = useAppStore(state => state.setTheme); + const { dataSaverEnabled, setDataSaverEnabled } = useSettingsStore(); + + const themeRight = useMemo( + () => ( + + ), + [theme, setTheme] + ); + + const dataSaverRight = useMemo( + () => , + [dataSaverEnabled, setDataSaverEnabled] + ); + + return ( + + + + + + ); +}); diff --git a/src/components/mobile/DownloadsSection.tsx b/src/components/mobile/DownloadsSection.tsx new file mode 100644 index 00000000..fb85b89a --- /dev/null +++ b/src/components/mobile/DownloadsSection.tsx @@ -0,0 +1,73 @@ +import React, { memo, useCallback, useMemo } from 'react'; +import { Alert } from 'react-native'; + +import { NativeToggle } from './NativeToggle'; +import { SettingRow } from './SettingRow'; +import { SettingsPicker } from './SettingsPicker'; +import { ICON_WIFI, ICON_DOWNLOAD, ICON_TRASH_RED } from './settingsIcons'; +import { QUALITY_OPTIONS } from './settingsOptions'; +import { SettingsSection } from './SettingsSection'; +import { useSettingsStore } from '../../store/settingsStore'; + +/** + * Memoised Downloads section — WiFi-only toggle, download quality picker, + * and clear downloads action. + */ +export const DownloadsSection = memo(function DownloadsSection() { + const { + downloadOverWifiOnly, + setDownloadOverWifiOnly, + downloadQuality, + setDownloadQuality, + } = useSettingsStore(); + + const handleClearDownloads = useCallback(() => { + Alert.alert('Clear Downloads', 'Remove all downloads?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Clear', style: 'destructive' }, + ]); + }, []); + + const wifiOnlyRight = useMemo( + () => , + [downloadOverWifiOnly, setDownloadOverWifiOnly] + ); + + const qualityRight = useMemo( + () => ( + + ), + [downloadQuality, setDownloadQuality] + ); + + return ( + + + + + + + + ); +}); diff --git a/src/components/mobile/MobileSettings.tsx b/src/components/mobile/MobileSettings.tsx index a4399f0f..b3b7df53 100644 --- a/src/components/mobile/MobileSettings.tsx +++ b/src/components/mobile/MobileSettings.tsx @@ -1,3 +1,15 @@ +import React, { useCallback, useState } from 'react'; +import { ScrollView, TouchableOpacity, View } from 'react-native'; +import { ChevronDown, ChevronUp } from 'lucide-react-native'; + +import { AccountActionsSection } from './AccountActionsSection'; +import { AccountSection } from './AccountSection'; +import { AppSection } from './AppSection'; +import { DownloadsSection } from './DownloadsSection'; +import { ICON_SETTINGS2 } from './settingsIcons'; +import { PerformanceSection } from './PerformanceSection'; +import { PrivacySection } from './PrivacySection'; +import { SyncSection } from './SyncSection'; import { AlertTriangle, BarChart2, @@ -154,7 +166,7 @@ const AdvancedToggle = ({ expanded, onToggle }: AdvancedToggleProps) => { className="mx-4 my-3 flex-row items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 dark:border-gray-700 dark:bg-gray-800" > - + {ICON_SETTINGS2} {expanded ? 'Hide Advanced Settings' : 'Advanced Settings'} @@ -169,332 +181,27 @@ const AdvancedToggle = ({ expanded, onToggle }: AdvancedToggleProps) => { }; // ───────────────────────────────────────────────────────────── -// Component +// Component — each is now a separate memoised +// component that owns its own hooks, so toggling one section +// re-renders only that section. // ───────────────────────────────────────────────────────────── export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }: any) => { - const theme = useTheme(); - const setTheme = useAppStore(state => state.setTheme); - const router = useRouter(); - const { performReauthCheck } = useRequireReauth(); // Progressive disclosure: advanced settings collapsed by default const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const { - profileVisibility, - setProfileVisibility, - twoFactorEnabled, - setTwoFactorEnabled, - analyticsEnabled, - setAnalyticsEnabled, - downloadOverWifiOnly, - setDownloadOverWifiOnly, - downloadQuality, - setDownloadQuality, - dataSaverEnabled, - setDataSaverEnabled, - } = useSettingsStore(); - - const { - isAvailable: biometricAvailable, - isEnabled: biometricEnabled, - enable: enableBiometric, - disable: disableBiometric, - isLoading: biometricLoading, - } = useBiometricAuth(); - - const { clearCache: clearStoredFormFields } = useFormCache([]); - - const handleClearFormCache = useCallback(() => { - Alert.alert( - 'Clear Cached Form Data', - 'Remove saved names, emails, and addresses from this device?', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Clear', - style: 'destructive', - onPress: async () => { - await clearStoredFormFields(); - Alert.alert('Cleared', 'Cached form data has been removed.'); - }, - }, - ] - ); - }, [clearStoredFormFields]); - - const handleBiometricToggle = useCallback( - async (value: boolean) => { - if (value) { - const ok = await enableBiometric(); - if (!ok) { - Alert.alert('Biometric Login', 'Enable failed. Check device settings.'); - } - } else { - await disableBiometric(); - } - }, - [enableBiometric, disableBiometric] - ); - - const handleSignOut = useCallback(() => { - Alert.alert('Sign Out', 'Are you sure?', [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Sign Out', style: 'destructive', onPress: onSignOut }, - ]); - }, [onSignOut]); - - const handleManualSync = useCallback(async () => { - Alert.alert('Sync', 'Sync data with server?', [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Sync', - onPress: async () => { - try { - Alert.alert('Syncing...'); - // await syncService.manualSync(); - Alert.alert('Success'); - } catch { - Alert.alert('Failed to sync'); - } - }, - }, - ]); - }, []); - - const handleClearDownloads = useCallback(() => { - Alert.alert('Clear Downloads', 'Remove all downloads?', [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Clear', style: 'destructive' }, - ]); - }, []); - const handleToggleAdvanced = useCallback(() => { configureNext(); setShowAdvancedSettings(prev => !prev); }, []); - const handleChangePaymentMethod = useCallback(async () => { - const authorized = await performReauthCheck(); - if (authorized) { - Alert.alert('Payment Method', 'Payment method updated successfully.'); - } else { - Alert.alert('Re-authentication Failed', 'Verification required to change payment method.'); - } - }, [performReauthCheck]); - - const handleViewFullCardNumber = useCallback(async () => { - const authorized = await performReauthCheck(); - if (authorized) { - Alert.alert('Card Details', 'Card Number: **** **** **** 4242'); - } else { - Alert.alert('Re-authentication Failed', 'Verification required to view card details.'); - } - }, [performReauthCheck]); - - const handleExportData = useCallback(async () => { - const authorized = await performReauthCheck(); - if (authorized) { - Alert.alert('Export Data', 'Your personal data export request has been submitted successfully.'); - } else { - Alert.alert('Re-authentication Failed', 'Verification required to export personal data.'); - } - }, [performReauthCheck]); - - const handleAdminDashboard = useCallback(async () => { - const authorized = await performReauthCheck(); - if (authorized) { - router.push('/health-dashboard'); - } else { - Alert.alert('Re-authentication Failed', 'Verification required to access Admin Dashboard.'); - } - }, [performReauthCheck, router]); - - const deleteInputRef = useRef(null); - - const handleDeleteAccount = useCallback(async () => { - const authorized = await performReauthCheck(); - if (!authorized) { - Alert.alert('Re-authentication Failed', 'Verification required to delete your account.'); - return; - } - - Alert.alert( - 'Delete Account', - 'This action is irreversible. All your data, progress, and purchases will be permanently deleted.', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Continue', - style: 'destructive', - onPress: () => { - // Second confirmation: require typing DELETE - if (Platform.OS === 'ios') { - // iOS Alert.alert doesn't support text input; use a simple confirmation - Alert.alert( - 'Are you absolutely sure?', - 'Type DELETE in the next prompt to confirm account deletion.', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: () => { - // Final deletion action - Alert.alert('Account Deleted', 'Your account has been deleted.'); - }, - }, - ] - ); - } else { - // Android: use Alert with prompt - Alert.alert( - 'Confirm Deletion', - 'Please type DELETE to confirm', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: () => { - Alert.alert('Account Deleted', 'Your account has been deleted.'); - }, - }, - ], - { cancelable: true } - ); - } - }, - }, - ] - ); - }, [performReauthCheck]); - - // Wrap parent-provided callbacks so they are stable references - const handleChangePassword = useCallback(() => { - onChangePassword?.(); - }, [onChangePassword]); - - const handleLinkedAccounts = useCallback(() => { - onLinkedAccounts?.(); - }, [onLinkedAccounts]); - - // ── Memoised right elements (stable references) ──────── - const profileVisibilityRight = useMemo( - () => ( - - ), - [profileVisibility, setProfileVisibility] - ); - - const twoFactorRight = useMemo( - () => , - [twoFactorEnabled, setTwoFactorEnabled] - ); - - const biometricIcon = useMemo( - () => (biometricLoading ? : ICON_FINGERPRINT), - [biometricLoading] - ); - - const biometricRight = useMemo( - () => ( - - ), - [biometricEnabled, handleBiometricToggle, biometricLoading] - ); - - const themeRight = useMemo( - () => ( - - ), - [theme, setTheme] - ); - - const dataSaverRight = useMemo( - () => ( - - ), - [dataSaverEnabled, setDataSaverEnabled] - ); - - const analyticsRight = useMemo( - () => , - [analyticsEnabled, setAnalyticsEnabled] - ); - - const wifiOnlyRight = useMemo( - () => ( - - ), - [downloadOverWifiOnly, setDownloadOverWifiOnly] - ); - - const qualityRight = useMemo( - () => ( - - ), - [downloadQuality, setDownloadQuality] - ); - return ( {/* ── ESSENTIAL: ACCOUNT ─────────────────────────────── */} - - - - - - {biometricAvailable && ( - - )} - - - - - + {/* ── ESSENTIAL: APP ─────────────────────────────────── */} + - {/* PRIVACY */} - - - - - - - - - {/* DOWNLOADS */} - - - - - - - - - {/* SYNC */} - - - - - {/* PERFORMANCE & UTILITIES */} - - - - - + + + + )} {/* ── ESSENTIAL: ACCOUNT ACTIONS ─────────────────────── */} - - - - + ); }; diff --git a/src/components/mobile/PerformanceSection.tsx b/src/components/mobile/PerformanceSection.tsx new file mode 100644 index 00000000..b2596515 --- /dev/null +++ b/src/components/mobile/PerformanceSection.tsx @@ -0,0 +1,45 @@ +import React, { memo, useCallback } from 'react'; +import { Alert } from 'react-native'; +import { useRouter } from 'expo-router'; + +import { SettingRow } from './SettingRow'; +import { ICON_ZAP, ICON_SHIELD } from './settingsIcons'; +import { SettingsSection } from './SettingsSection'; +import { useRequireReauth } from '../../hooks'; + +/** + * Memoised Performance & Utilities section — clipboard optimizer and admin dashboard. + * Owns the router and reauth hooks it needs. + */ +export const PerformanceSection = memo(function PerformanceSection() { + const router = useRouter(); + const { performReauthCheck } = useRequireReauth(); + + const handleAdminDashboard = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + router.push('/health-dashboard'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to access Admin Dashboard.'); + } + }, [performReauthCheck, router]); + + return ( + + + + + + ); +}); diff --git a/src/components/mobile/PrivacySection.tsx b/src/components/mobile/PrivacySection.tsx new file mode 100644 index 00000000..c5ece593 --- /dev/null +++ b/src/components/mobile/PrivacySection.tsx @@ -0,0 +1,80 @@ +import React, { memo, useCallback, useMemo } from 'react'; +import { Alert } from 'react-native'; + +import { NativeToggle } from './NativeToggle'; +import { SettingRow } from './SettingRow'; +import { ICON_BAR_CHART, ICON_TRASH_RED, ICON_DOWNLOAD_INDIGO } from './settingsIcons'; +import { SettingsSection } from './SettingsSection'; +import { useRequireReauth } from '../../hooks'; +import { useFormCache } from '../../hooks/useFormCache'; +import { useSettingsStore } from '../../store/settingsStore'; + +/** + * Memoised Privacy section — analytics toggle, clear cached form data, + * and export personal data. Each handler is self-contained. + */ +export const PrivacySection = memo(function PrivacySection() { + const { analyticsEnabled, setAnalyticsEnabled } = useSettingsStore(); + const { clearCache: clearStoredFormFields } = useFormCache([]); + const { performReauthCheck } = useRequireReauth(); + + const handleClearFormCache = useCallback(() => { + Alert.alert( + 'Clear Cached Form Data', + 'Remove saved names, emails, and addresses from this device?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + await clearStoredFormFields(); + Alert.alert('Cleared', 'Cached form data has been removed.'); + }, + }, + ] + ); + }, [clearStoredFormFields]); + + const handleExportData = useCallback(async () => { + const authorized = await performReauthCheck(); + if (authorized) { + Alert.alert('Export Data', 'Your personal data export request has been submitted successfully.'); + } else { + Alert.alert('Re-authentication Failed', 'Verification required to export personal data.'); + } + }, [performReauthCheck]); + + const analyticsRight = useMemo( + () => , + [analyticsEnabled, setAnalyticsEnabled] + ); + + return ( + + + + + + + + ); +}); diff --git a/src/components/mobile/SettingRow.tsx b/src/components/mobile/SettingRow.tsx new file mode 100644 index 00000000..eaddd3cc --- /dev/null +++ b/src/components/mobile/SettingRow.tsx @@ -0,0 +1,67 @@ +import { ChevronDown } from 'lucide-react-native'; +import React, { memo } from 'react'; +import { TouchableOpacity, View } from 'react-native'; + +import { useDynamicFontSize } from '../../hooks'; +import { AppText } from '../common/AppText'; + +export interface SettingRowProps { + icon: React.ReactNode; + iconBg?: string; + label: string; + description?: string; + right?: React.ReactNode; + onPress?: () => void; + destructive?: boolean; + accessibilityLabel?: string; +} + +/** + * Memoised setting row used across all MobileSettings sections. + * A toggle on one section should not repaint rows in other sections. + */ +export const SettingRow = memo(function SettingRow({ + icon, + iconBg = 'bg-gray-100 dark:bg-gray-700', + label, + description, + right, + onPress, + destructive = false, + accessibilityLabel, +}: SettingRowProps) { + const Row = onPress ? TouchableOpacity : View; + const { scale } = useDynamicFontSize(); + + return ( + + + {icon} + + + + + {label} + + + {description && ( + + {description} + + )} + + + {right ?? (onPress ? : null)} + + ); +}); diff --git a/src/components/mobile/SubscriptionManager.tsx b/src/components/mobile/SubscriptionManager.tsx index 26cbcc87..1451cfe3 100644 --- a/src/components/mobile/SubscriptionManager.tsx +++ b/src/components/mobile/SubscriptionManager.tsx @@ -1,55 +1,14 @@ -import { LinearGradient } from 'expo-linear-gradient'; -import { Crown, Zap, Check, RefreshCw, ChevronRight, Star, Shield } from 'lucide-react-native'; -import React, { useEffect, useState } from 'react'; -import { - View, - Text, - ScrollView, - TouchableOpacity, - StyleSheet, - SafeAreaView, - Alert, - ActivityIndicator, -} from 'react-native'; - -import { PurchaseButton } from './PurchaseButton'; +import React, { useCallback, useEffect, useState } from 'react'; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet, SafeAreaView, Alert } from 'react-native'; + +import { BillingToggle } from './subscription/BillingToggle'; +import { CurrentPlanCard } from './subscription/CurrentPlanCard'; +import { FreePlanCard } from './subscription/FreePlanCard'; +import { PlanCard } from './subscription/PlanCard'; +import { RestoreFooter } from './subscription/RestoreFooter'; import { SubscriptionSkeleton } from './SubscriptionSkeleton'; import { useInAppPurchase } from '../../hooks'; -import { - SUBSCRIPTION_PLANS, - SubscriptionPlan, - SubscriptionTier, -} from '../../services/mobilePayments'; - -// ─── Plan metadata ───────────────────────────────────────────────────────────── - -const TIER_META: Record< - SubscriptionTier, - { label: string; colors: [string, string]; icon: React.ReactNode } -> = { - free: { - label: 'Free', - colors: ['#94a3b8', '#64748b'], - icon: , - }, - pro: { - label: 'Pro', - colors: ['#20afe7', '#586ce9'], - icon: , - }, - premium: { - label: 'Premium', - colors: ['#d97706', '#f59e0b'], - icon: , - }, -}; - -const FREE_FEATURES = [ - '5 courses per month', - 'Standard video quality', - 'Community forum access', - 'Mobile app access', -]; +import { SubscriptionPlan } from '../../services/mobilePayments'; // ─── Component ──────────────────────────────────────────────────────────────── @@ -98,209 +57,19 @@ export const SubscriptionManager: React.FC = ({ // Filter plans by billing period const visiblePlans = plans.filter(p => p.period === billingPeriod); - const handlePurchase = async (plan: SubscriptionPlan) => { - setActivatingId(plan.productId); - await purchaseSubscription(plan.productId); - setActivatingId(null); - }; + const handlePurchase = useCallback( + async (plan: SubscriptionPlan) => { + setActivatingId(plan.productId); + await purchaseSubscription(plan.productId); + setActivatingId(null); + }, + [purchaseSubscription] + ); - const handleRestore = async () => { + const handleRestore = useCallback(async () => { const result = await restorePurchases(); Alert.alert(result.count > 0 ? 'Purchases Restored' : 'Nothing to Restore', result.message); - }; - - const currentMeta = TIER_META[currentTier]; - - // ── Current plan card ─────────────────────────────────────────────────── - - const renderCurrentPlan = () => ( - - Current Plan - - - {currentMeta.icon} - - {currentMeta.label} - - {currentTier === 'free' ? 'Upgrade to unlock everything' : 'Your plan is active'} - - - - - - - ); - - // ── Billing period toggle ─────────────────────────────────────────────── - - const renderBillingToggle = () => ( - - {(['monthly', 'annual'] as const).map(period => ( - setBillingPeriod(period)} - > - - {period === 'monthly' ? 'Monthly' : 'Annual'} - - {period === 'annual' && ( - - Save 33% - - )} - - ))} - - ); - - // ── Plan card ─────────────────────────────────────────────────────────── - - const renderPlanCard = (plan: SubscriptionPlan) => { - const meta = TIER_META[plan.tier]; - const isCurrentPlan = plan.tier === currentTier; - const isActivating = activatingId === plan.productId; - const isAnyPurchasing = isPurchasing && !isActivating; - - return ( - - {/* Plan header */} - - - {meta.icon} - {plan.name} - - - ${plan.price} - /{plan.period === 'monthly' ? 'mo' : 'yr'} - - - - {/* Features list */} - - {plan.features.map((feature, i) => ( - - - - - {feature} - - ))} - - - {/* CTA */} - - {isActivating ? ( - - - - Opening payment… - - - ) : ( - handlePurchase(plan)} - isDark={isDark} - /> - )} - - - ); - }; - - // ── Free plan card ────────────────────────────────────────────────────── - - const renderFreeCard = () => ( - - - - - Free - - - $0 - /forever - - - - - {FREE_FEATURES.map((feature, i) => ( - - - - - {feature} - - ))} - - - - {}} - isDark={isDark} - /> - - - ); + }, [restorePurchases]); // ── Loading skeleton ──────────────────────────────────────────────────── @@ -311,7 +80,7 @@ export const SubscriptionManager: React.FC = ({ // ── Main render ───────────────────────────────────────────────────────── return ( - + {/* Header */} @@ -321,7 +90,12 @@ export const SubscriptionManager: React.FC = ({ {onClose && ( - + Close )} @@ -329,59 +103,66 @@ export const SubscriptionManager: React.FC = ({ {/* Current plan */} - {renderCurrentPlan()} + {/* Billing toggle */} Billing Period - {renderBillingToggle()} + {/* Plans */} Available Plans - {currentTier === 'free' && renderFreeCard()} - {visiblePlans.map(renderPlanCard)} + {currentTier === 'free' && ( + + )} + {visiblePlans.map(plan => { + const isActivating = activatingId === plan.productId; + const isAnyPurchasing = isPurchasing && !isActivating; + + return ( + + ); + })} {/* Restore & legal */} - - - {isRestoring ? ( - - ) : ( - - )} - - {isRestoring ? 'Restoring…' : 'Restore Purchases'} - - - - - Subscriptions automatically renew unless cancelled at least 24 hours before the end of - the current period. Manage or cancel in your device Settings → Subscriptions. - - - - - Terms of Use - - · - - Privacy Policy - - - + ); }; -// ─── Styles ─────────────────────────────────────────────────────────────────── +// ─── Styles (layout-only; visual styles moved to sub-components) ────────────── const styles = StyleSheet.create({ safe: { @@ -413,15 +194,6 @@ const styles = StyleSheet.create({ scroll: { paddingBottom: 40, }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - gap: 12, - }, - loadingText: { - fontSize: 15, - }, section: { paddingHorizontal: 16, marginTop: 20, @@ -433,187 +205,4 @@ const styles = StyleSheet.create({ letterSpacing: 0.5, marginBottom: 10, }, - // Current plan card - currentPlanCard: { - borderRadius: 16, - padding: 16, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - currentPlanLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - currentPlanIconBadge: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: 'rgba(255,255,255,0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - currentPlanTier: { - fontSize: 17, - fontWeight: '800', - color: '#fff', - }, - currentPlanSub: { - fontSize: 12, - color: 'rgba(255,255,255,0.75)', - marginTop: 2, - }, - // Billing toggle - toggleRow: { - flexDirection: 'row', - borderRadius: 12, - borderWidth: 1, - overflow: 'hidden', - padding: 4, - gap: 4, - }, - toggleOption: { - flex: 1, - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - paddingVertical: 10, - borderRadius: 9, - gap: 6, - }, - toggleOptionActive: { - backgroundColor: '#19c3e6', - }, - toggleText: { - fontSize: 14, - }, - toggleSavingsBadge: { - backgroundColor: '#fef3c7', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 99, - }, - toggleSavingsText: { - fontSize: 10, - fontWeight: '700', - color: '#d97706', - }, - // Plan cards - planCard: { - borderRadius: 16, - overflow: 'hidden', - marginBottom: 12, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.07, - shadowRadius: 8, - elevation: 3, - }, - planHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 14, - }, - planHeaderLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - planName: { - fontSize: 18, - fontWeight: '800', - color: '#fff', - }, - planPricing: { - flexDirection: 'row', - alignItems: 'baseline', - gap: 2, - }, - planPrice: { - fontSize: 22, - fontWeight: '800', - color: '#fff', - }, - planPeriod: { - fontSize: 13, - color: 'rgba(255,255,255,0.75)', - }, - featuresList: { - paddingHorizontal: 16, - paddingTop: 14, - paddingBottom: 4, - gap: 10, - }, - featureRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - featureCheck: { - width: 22, - height: 22, - borderRadius: 11, - justifyContent: 'center', - alignItems: 'center', - flexShrink: 0, - }, - featureText: { - fontSize: 14, - flex: 1, - }, - planCTA: { - padding: 16, - paddingTop: 12, - }, - activatingRow: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 10, - paddingVertical: 14, - }, - activatingText: { - fontSize: 15, - fontWeight: '600', - }, - // Footer - footer: { - paddingHorizontal: 16, - marginTop: 8, - gap: 16, - alignItems: 'center', - }, - restoreBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingVertical: 10, - paddingHorizontal: 16, - }, - restoreBtnText: { - fontSize: 14, - fontWeight: '600', - color: '#19c3e6', - }, - legalText: { - fontSize: 11, - textAlign: 'center', - lineHeight: 16, - }, - legalLinks: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - legalLink: { - fontSize: 12, - fontWeight: '600', - color: '#19c3e6', - }, - legalSep: { - fontSize: 12, - }, }); diff --git a/src/components/mobile/SyncSection.tsx b/src/components/mobile/SyncSection.tsx new file mode 100644 index 00000000..59148046 --- /dev/null +++ b/src/components/mobile/SyncSection.tsx @@ -0,0 +1,40 @@ +import React, { memo, useCallback } from 'react'; +import { Alert } from 'react-native'; + +import { SettingRow } from './SettingRow'; +import { ICON_REFRESH } from './settingsIcons'; +import { SettingsSection } from './SettingsSection'; + +/** + * Memoised Sync section — manual sync trigger. + */ +export const SyncSection = memo(function SyncSection() { + const handleManualSync = useCallback(() => { + Alert.alert('Sync', 'Sync data with server?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sync', + onPress: async () => { + try { + Alert.alert('Syncing...'); + // await syncService.manualSync(); + Alert.alert('Success'); + } catch { + Alert.alert('Failed to sync'); + } + }, + }, + ]); + }, []); + + return ( + + + + ); +}); diff --git a/src/components/mobile/settingsIcons.tsx b/src/components/mobile/settingsIcons.tsx new file mode 100644 index 00000000..aa18ce20 --- /dev/null +++ b/src/components/mobile/settingsIcons.tsx @@ -0,0 +1,44 @@ +import { + AlertTriangle, + BarChart2, + CreditCard, + Database, + Download, + Eye, + Fingerprint as FingerprintPattern, + Lock, + LogOut, + RefreshCw, + Settings2, + ShieldAlert, + Sun, + Trash2, + User, + Wifi, + Zap, +} from 'lucide-react-native'; +import React from 'react'; + +/** + * Stable icon elements — created once at module level so React.memo on + * SettingRow can compare props by reference and skip re-renders. + */ +export const ICON_EYE = ; +export const ICON_LOCK = ; +export const ICON_FINGERPRINT = ; +export const ICON_USER = ; +export const ICON_CREDIT_CARD_YELLOW = ; +export const ICON_CREDIT_CARD_GREEN = ; +export const ICON_SUN = ; +export const ICON_DATABASE = ; +export const ICON_BAR_CHART = ; +export const ICON_TRASH_RED = ; +export const ICON_DOWNLOAD_INDIGO = ; +export const ICON_WIFI = ; +export const ICON_DOWNLOAD = ; +export const ICON_REFRESH = ; +export const ICON_ZAP = ; +export const ICON_SHIELD = ; +export const ICON_LOGOUT_RED = ; +export const ICON_ALERT = ; +export const ICON_SETTINGS2 = ; diff --git a/src/components/mobile/settingsOptions.ts b/src/components/mobile/settingsOptions.ts new file mode 100644 index 00000000..193c3a32 --- /dev/null +++ b/src/components/mobile/settingsOptions.ts @@ -0,0 +1,19 @@ +import { PickerOption } from './SettingsPicker'; +import { DownloadQuality, ProfileVisibility } from '../../store/settingsStore'; + +export const VISIBILITY_OPTIONS: PickerOption[] = [ + { label: 'Public', value: 'public' }, + { label: 'Friends Only', value: 'friends_only' }, + { label: 'Private', value: 'private' }, +]; + +export const THEME_OPTIONS: PickerOption<'light' | 'dark'>[] = [ + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' }, +]; + +export const QUALITY_OPTIONS: PickerOption[] = [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, +]; diff --git a/src/components/mobile/subscription/BillingToggle.tsx b/src/components/mobile/subscription/BillingToggle.tsx new file mode 100644 index 00000000..ef4f2997 --- /dev/null +++ b/src/components/mobile/subscription/BillingToggle.tsx @@ -0,0 +1,91 @@ +import React, { memo } from 'react'; +import { Text, TouchableOpacity, View, StyleSheet } from 'react-native'; + +interface BillingToggleProps { + billingPeriod: 'monthly' | 'annual'; + onPeriodChange: (period: 'monthly' | 'annual') => void; + cardBg: string; + borderColor: string; + textSecondary: string; +} + +/** + * Memoised billing period toggle — monthly vs annual with savings badge. + * Only repaints when billingPeriod or theme colours change. + */ +export const BillingToggle = memo(function BillingToggle({ + billingPeriod, + onPeriodChange, + cardBg, + borderColor, + textSecondary, +}: BillingToggleProps) { + return ( + + {(['monthly', 'annual'] as const).map(period => ( + onPeriodChange(period)} + accessibilityRole="radio" + accessibilityLabel={`${period} billing`} + accessibilityState={{ checked: billingPeriod === period }} + > + + {period === 'monthly' ? 'Monthly' : 'Annual'} + + {period === 'annual' && ( + + Save 33% + + )} + + ))} + + ); +}); + +const styles = StyleSheet.create({ + toggleRow: { + flexDirection: 'row', + borderRadius: 12, + borderWidth: 1, + overflow: 'hidden', + padding: 4, + gap: 4, + }, + toggleOption: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 10, + borderRadius: 9, + gap: 6, + }, + toggleOptionActive: { + backgroundColor: '#19c3e6', + }, + toggleText: { + fontSize: 14, + }, + toggleSavingsBadge: { + backgroundColor: '#fef3c7', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 99, + }, + toggleSavingsText: { + fontSize: 10, + fontWeight: '700', + color: '#d97706', + }, +}); diff --git a/src/components/mobile/subscription/CurrentPlanCard.tsx b/src/components/mobile/subscription/CurrentPlanCard.tsx new file mode 100644 index 00000000..abaedc0e --- /dev/null +++ b/src/components/mobile/subscription/CurrentPlanCard.tsx @@ -0,0 +1,90 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { Shield } from 'lucide-react-native'; +import React, { memo } from 'react'; +import { Text, View, StyleSheet } from 'react-native'; + +import { SubscriptionTier } from '../../../services/mobilePayments'; +import { TIER_META } from '../subscriptionMeta'; + +interface CurrentPlanCardProps { + currentTier: SubscriptionTier; + textSecondary: string; +} + +/** + * Memoised current plan summary — gradient card showing the active tier. + * Only repaints when currentTier or the secondary text colour changes. + */ +export const CurrentPlanCard = memo(function CurrentPlanCard({ + currentTier, + textSecondary, +}: CurrentPlanCardProps) { + const meta = TIER_META[currentTier]; + + return ( + + Current Plan + + + {meta.icon} + + {meta.label} + + {currentTier === 'free' ? 'Upgrade to unlock everything' : 'Your plan is active'} + + + + + + + ); +}); + +const styles = StyleSheet.create({ + section: { + paddingHorizontal: 16, + marginTop: 20, + }, + sectionLabel: { + fontSize: 13, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 10, + }, + currentPlanCard: { + borderRadius: 16, + padding: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + currentPlanLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + currentPlanIconBadge: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + currentPlanTier: { + fontSize: 17, + fontWeight: '800', + color: '#fff', + }, + currentPlanSub: { + fontSize: 12, + color: 'rgba(255,255,255,0.75)', + marginTop: 2, + }, +}); diff --git a/src/components/mobile/subscription/FreePlanCard.tsx b/src/components/mobile/subscription/FreePlanCard.tsx new file mode 100644 index 00000000..f4e2644d --- /dev/null +++ b/src/components/mobile/subscription/FreePlanCard.tsx @@ -0,0 +1,153 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { Check, Star } from 'lucide-react-native'; +import React, { memo } from 'react'; +import { Text, View, StyleSheet } from 'react-native'; + +import { PurchaseButton } from '../PurchaseButton'; +import { SubscriptionTier } from '../../../services/mobilePayments'; +import { TIER_META, FREE_FEATURES } from '../subscriptionMeta'; + +interface FreePlanCardProps { + currentTier: SubscriptionTier; + isDark: boolean; + cardBg: string; + borderColor: string; + textPrimary: string; +} + +/** + * Memoised free plan card — always shown when user is on free tier. + * Features are static, so only theme colours trigger a repaint. + */ +export const FreePlanCard = memo(function FreePlanCard({ + currentTier, + isDark, + cardBg, + borderColor, + textPrimary, +}: FreePlanCardProps) { + const isCurrentPlan = currentTier === 'free'; + + return ( + + + + + Free + + + $0 + /forever + + + + + {FREE_FEATURES.map((feature, i) => ( + + + + + {feature} + + ))} + + + + {}} + isDark={isDark} + /> + + + ); +}); + +const styles = StyleSheet.create({ + planCard: { + borderRadius: 16, + overflow: 'hidden', + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.07, + shadowRadius: 8, + elevation: 3, + }, + planHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + }, + planHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + planName: { + fontSize: 18, + fontWeight: '800', + color: '#fff', + }, + planPricing: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 2, + }, + planPrice: { + fontSize: 22, + fontWeight: '800', + color: '#fff', + }, + planPeriod: { + fontSize: 13, + color: 'rgba(255,255,255,0.75)', + }, + featuresList: { + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 4, + gap: 10, + }, + featureRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + featureCheck: { + width: 22, + height: 22, + borderRadius: 11, + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + }, + featureText: { + fontSize: 14, + flex: 1, + }, + planCTA: { + padding: 16, + paddingTop: 12, + }, +}); diff --git a/src/components/mobile/subscription/PlanCard.tsx b/src/components/mobile/subscription/PlanCard.tsx new file mode 100644 index 00000000..6fab7479 --- /dev/null +++ b/src/components/mobile/subscription/PlanCard.tsx @@ -0,0 +1,198 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { Check } from 'lucide-react-native'; +import React, { memo } from 'react'; +import { Text, View, ActivityIndicator, StyleSheet } from 'react-native'; + +import { PurchaseButton } from '../PurchaseButton'; +import { SubscriptionPlan, SubscriptionTier } from '../../../services/mobilePayments'; +import { TIER_META } from '../subscriptionMeta'; + +interface PlanCardProps { + plan: SubscriptionPlan; + currentTier: SubscriptionTier; + isActivating: boolean; + isAnyPurchasing: boolean; + purchaseSuccess: boolean; + isDark: boolean; + cardBg: string; + borderColor: string; + textPrimary: string; + onPurchase: (plan: SubscriptionPlan) => void; +} + +/** + * Memoised plan card — header gradient, features list, and CTA. + * Only repaints when its owning plan's state or theme changes. + */ +export const PlanCard = memo(function PlanCard({ + plan, + currentTier, + isActivating, + isAnyPurchasing, + purchaseSuccess, + isDark, + cardBg, + borderColor, + textPrimary, + onPurchase, +}: PlanCardProps) { + const meta = TIER_META[plan.tier]; + const isCurrentPlan = plan.tier === currentTier; + + return ( + + {/* Plan header */} + + + {meta.icon} + {plan.name} + + + ${plan.price} + /{plan.period === 'monthly' ? 'mo' : 'yr'} + + + + {/* Features list */} + + {plan.features.map((feature, i) => ( + + + + + {feature} + + ))} + + + {/* CTA */} + + {isActivating ? ( + + + + Opening payment… + + + ) : ( + onPurchase(plan)} + isDark={isDark} + /> + )} + + + ); +}); + +const styles = StyleSheet.create({ + planCard: { + borderRadius: 16, + overflow: 'hidden', + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.07, + shadowRadius: 8, + elevation: 3, + }, + planHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + }, + planHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + planName: { + fontSize: 18, + fontWeight: '800', + color: '#fff', + }, + planPricing: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 2, + }, + planPrice: { + fontSize: 22, + fontWeight: '800', + color: '#fff', + }, + planPeriod: { + fontSize: 13, + color: 'rgba(255,255,255,0.75)', + }, + featuresList: { + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 4, + gap: 10, + }, + featureRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + featureCheck: { + width: 22, + height: 22, + borderRadius: 11, + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + }, + featureText: { + fontSize: 14, + flex: 1, + }, + planCTA: { + padding: 16, + paddingTop: 12, + }, + activatingRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 10, + paddingVertical: 14, + }, + activatingText: { + fontSize: 15, + fontWeight: '600', + }, +}); diff --git a/src/components/mobile/subscription/RestoreFooter.tsx b/src/components/mobile/subscription/RestoreFooter.tsx new file mode 100644 index 00000000..f6a96f6e --- /dev/null +++ b/src/components/mobile/subscription/RestoreFooter.tsx @@ -0,0 +1,94 @@ +import { RefreshCw } from 'lucide-react-native'; +import React, { memo } from 'react'; +import { Text, TouchableOpacity, ActivityIndicator, StyleSheet, View } from 'react-native'; + +interface RestoreFooterProps { + isRestoring: boolean; + onRestore: () => void; + textSecondary: string; +} + +/** + * Memoised restore & legal footer — restore button, legal text, and policy links. + * Only repaints when isRestoring or the secondary text colour changes. + */ +export const RestoreFooter = memo(function RestoreFooter({ + isRestoring, + onRestore, + textSecondary, +}: RestoreFooterProps) { + return ( + + + {isRestoring ? ( + + ) : ( + + )} + + {isRestoring ? 'Restoring…' : 'Restore Purchases'} + + + + + Subscriptions automatically renew unless cancelled at least 24 hours before the end of + the current period. Manage or cancel in your device Settings → Subscriptions. + + + + + Terms of Use + + · + + Privacy Policy + + + + ); +}); + +const styles = StyleSheet.create({ + footer: { + paddingHorizontal: 16, + marginTop: 8, + gap: 16, + alignItems: 'center', + }, + restoreBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 10, + paddingHorizontal: 16, + }, + restoreBtnText: { + fontSize: 14, + fontWeight: '600', + color: '#19c3e6', + }, + legalText: { + fontSize: 11, + textAlign: 'center', + lineHeight: 16, + }, + legalLinks: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + legalLink: { + fontSize: 12, + fontWeight: '600', + color: '#19c3e6', + }, + legalSep: { + fontSize: 12, + }, +}); diff --git a/src/components/mobile/subscriptionMeta.ts b/src/components/mobile/subscriptionMeta.ts new file mode 100644 index 00000000..ed83b19d --- /dev/null +++ b/src/components/mobile/subscriptionMeta.ts @@ -0,0 +1,32 @@ +import { Crown, Zap, Star } from 'lucide-react-native'; +import React from 'react'; + +import { SubscriptionTier } from '../../services/mobilePayments'; + +export const TIER_META: Record< + SubscriptionTier, + { label: string; colors: [string, string]; icon: React.ReactNode } +> = { + free: { + label: 'Free', + colors: ['#94a3b8', '#64748b'], + icon: , + }, + pro: { + label: 'Pro', + colors: ['#20afe7', '#586ce9'], + icon: , + }, + premium: { + label: 'Premium', + colors: ['#d97706', '#f59e0b'], + icon: , + }, +}; + +export const FREE_FEATURES = [ + '5 courses per month', + 'Standard video quality', + 'Community forum access', + 'Mobile app access', +]; diff --git a/src/services/analytics/AnalyticsBatchQueue.ts b/src/services/analytics/AnalyticsBatchQueue.ts index 8784e4cb..b7a3ffc7 100644 --- a/src/services/analytics/AnalyticsBatchQueue.ts +++ b/src/services/analytics/AnalyticsBatchQueue.ts @@ -5,6 +5,7 @@ import { appLogger } from '../../utils/logger'; import { safeStorageWrite } from '../../utils/storage'; import { AnalyticsEvent, EventProperties } from '../../utils/trackingEvents'; import apiClient from '../api/axios.config'; +import { SESSION_EVENT_BUDGET } from './samplingPolicy'; const MAX_BATCH_SIZE = 20; const FLUSH_INTERVAL_MS = 30000; @@ -27,8 +28,23 @@ export class AnalyticsBatchQueue { private timer: ReturnType | null = null; private isFlushing = false; private currentFlushPromise: Promise | null = null; + private sessionEventCount = 0; + private droppedCount = 0; enqueue(event: AnalyticsEvent, properties?: EventProperties): void { + // Enforce per-session event budget + if (SESSION_EVENT_BUDGET > 0 && this.sessionEventCount >= SESSION_EVENT_BUDGET) { + this.droppedCount++; + if (this.droppedCount % 50 === 1) { + appLogger.warn( + `AnalyticsBatchQueue: session budget exhausted (${SESSION_EVENT_BUDGET}), ` + + `${this.droppedCount} events dropped this session` + ); + } + return; + } + + this.sessionEventCount++; this.buffer.push({ event, properties: properties as Record | undefined, @@ -94,6 +110,11 @@ export class AnalyticsBatchQueue { return this.buffer.length; } + /** Number of events dropped due to session budget since queue creation. */ + getDroppedCount(): number { + return this.droppedCount; + } + destroy(): void { this.clearTimer(); } diff --git a/src/services/analytics/samplingPolicy.ts b/src/services/analytics/samplingPolicy.ts new file mode 100644 index 00000000..e4ade592 --- /dev/null +++ b/src/services/analytics/samplingPolicy.ts @@ -0,0 +1,130 @@ +/** + * Analytics Sampling Policy + * + * Defines which events are sampled, at what rate, and what the per-session + * event budget is. This is the single source of truth for analytics volume + * control — the policy is enforced at the source (MobileAnalyticsService) + * and in the batch queue (AnalyticsBatchQueue). + * + * See docs/PERFORMANCE_MONITORING.md for the human-readable policy document. + */ + +import { AnalyticsEvent } from '../../utils/trackingEvents'; + +// ─── Event Frequency Classification ───────────────────────────────────────── + +export type EventFrequency = 'critical' | 'high' | 'medium' | 'low'; + +/** + * Maps every AnalyticsEvent to its expected frequency class. + * + * - `critical`: session lifecycle, auth, errors, crashes — always sent (100%) + * - `high`: navigation, content interactions — sampled at 20% + * - `medium`: user actions, button clicks — sampled at 10% + * - `low`: performance metrics, web vitals, A/B — sampled at 5% + */ +export const EVENT_FREQUENCY: Record = { + // ── Critical (100%) ────────────────────────────────────────────── + [AnalyticsEvent.APP_LAUNCH]: 'critical', + [AnalyticsEvent.SESSION_START]: 'critical', + [AnalyticsEvent.SESSION_END]: 'critical', + [AnalyticsEvent.AUTH_LOGIN]: 'critical', + [AnalyticsEvent.AUTH_LOGOUT]: 'critical', + [AnalyticsEvent.COURSE_STARTED]: 'critical', + [AnalyticsEvent.COURSE_COMPLETED]: 'critical', + [AnalyticsEvent.QUIZ_STARTED]: 'critical', + [AnalyticsEvent.QUIZ_COMPLETED]: 'critical', + [AnalyticsEvent.API_ERROR]: 'critical', + [AnalyticsEvent.CRASH_REPORT]: 'critical', + + // ── High (20%) ─────────────────────────────────────────────────── + [AnalyticsEvent.SCREEN_VIEW]: 'high', + [AnalyticsEvent.CONTENT_VIEW]: 'high', + [AnalyticsEvent.CONTENT_SHARE]: 'high', + [AnalyticsEvent.SEARCH_QUERY]: 'high', + [AnalyticsEvent.FORM_SUBMIT]: 'high', + + // ── Medium (10%) ───────────────────────────────────────────────── + [AnalyticsEvent.UI_CLICK]: 'medium', + [AnalyticsEvent.BUTTON_CLICK]: 'medium', + [AnalyticsEvent.CONTENT_LIKE]: 'medium', + [AnalyticsEvent.REVIEW_REQUESTED]: 'medium', + [AnalyticsEvent.REVIEW_PROMPT_SHOWN]: 'medium', + [AnalyticsEvent.REVIEW_PROMPT_DISMISSED]: 'medium', + + // ── Low (5%) ───────────────────────────────────────────────────── + [AnalyticsEvent.PERFORMANCE_METRIC]: 'low', + [AnalyticsEvent.REACT_PROFILER_RENDER]: 'low', + [AnalyticsEvent.REACT_PROFILER_SLOW_RENDER]: 'low', + [AnalyticsEvent.AB_ASSIGNMENT]: 'low', + [AnalyticsEvent.AB_EXPOSURE]: 'low', + [AnalyticsEvent.DEVICE_COMPLEXITY_ASSIGNED]: 'low', + [AnalyticsEvent.APP_BACKGROUND]: 'low', + [AnalyticsEvent.APP_FOREGROUND]: 'low', + [AnalyticsEvent.UPDATE_CHECK_STARTED]: 'low', + [AnalyticsEvent.UPDATE_AVAILABLE]: 'low', + [AnalyticsEvent.UPDATE_NOT_AVAILABLE]: 'low', + [AnalyticsEvent.UPDATE_DOWNLOAD_STARTED]: 'low', + [AnalyticsEvent.UPDATE_DOWNLOAD_COMPLETED]: 'low', + [AnalyticsEvent.UPDATE_DOWNLOAD_FAILED]: 'low', + [AnalyticsEvent.UPDATE_APPLIED]: 'low', + [AnalyticsEvent.UPDATE_DISMISSED]: 'low', + [AnalyticsEvent.UPDATE_STORE_REDIRECT]: 'low', + [AnalyticsEvent.WEB_VITALS_LCP]: 'low', + [AnalyticsEvent.WEB_VITALS_FID]: 'low', + [AnalyticsEvent.WEB_VITALS_CLS]: 'low', + [AnalyticsEvent.WEB_VITALS_FCP]: 'low', + [AnalyticsEvent.WEB_VITALS_TTFB]: 'low', + [AnalyticsEvent.WEB_VITALS_REGRESSION]: 'low', +}; + +// ─── Sampling Rates ───────────────────────────────────────────────────────── + +/** + * Sampling rate per frequency class. Value is the probability of sending + * an event (0.0 = never send, 1.0 = always send). + * + * Critical events bypass this check entirely and are always sent. + */ +export const SAMPLING_RATES: Record = { + critical: 1.0, + high: 0.2, + medium: 0.1, + low: 0.05, +}; + +/** + * High-frequency throttle: max events per second for events tagged + * with `event_category: 'high_frequency'` in their properties. + * This is a per-event_name rate limiter, applied before sampling. + */ +export const HIGH_FREQUENCY_MAX_PER_SECOND = 10; + +// ─── Session Event Budget ─────────────────────────────────────────────────── + +/** + * Maximum number of events that can be sent per session. + * Once the budget is exhausted, all subsequent events are silently dropped. + * The budget is tracked by AnalyticsBatchQueue. + * + * Set to 0 for unlimited (not recommended in production). + */ +export const SESSION_EVENT_BUDGET = 500; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Should this event be sampled at the given frequency? + * Returns true if the event should be sent, false if it should be dropped. + */ +export function shouldSampleEvent(frequency: EventFrequency): boolean { + if (frequency === 'critical') return true; + return Math.random() < SAMPLING_RATES[frequency]; +} + +/** + * Get the frequency class for an event. + */ +export function getEventFrequency(event: AnalyticsEvent): EventFrequency { + return EVENT_FREQUENCY[event] ?? 'medium'; +} diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index 7dc8e589..754dbcb4 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -17,7 +17,7 @@ import { getEnv } from '../../config'; import { MUTATION_INVALIDATION_MAP } from '../../config/apiCacheConfig'; import { SSL_PINNING } from '../../config/security'; import { useAppStore } from '../../store'; -import { useConflictStore, type ConflictData } from '../../store/conflictStore'; +import { useConflictStore } from '../../store/conflictStore'; import { appLogger } from '../../utils/logger'; import { notifyEntry, startTiming } from '../../utils/performanceTiming'; import { healthMetricsService } from '../healthMetrics'; @@ -30,25 +30,10 @@ import { } from './cache'; import { buildSanitizedApiError } from './errorSanitization'; import { requestQueue } from './requestQueue'; - -/** - * #806: Runtime shape validator for 409 conflict response bodies. - * - * Axios casts response.data to `ConflictData` at the TypeScript level, but - * provides no runtime guarantee. If the server changes its response format the - * cast silently yields `undefined` field accesses instead of a clear error. - * This guard validates the minimum structure before we read any field. - */ -function isConflictResponseShape(data: unknown): data is { - serverVersion?: unknown; - serverVersionNumber?: number; - localVersion?: unknown; - entityType?: string; - entityId?: string; - message?: string; -} { - return data !== null && data !== undefined && typeof data === 'object'; -} +import { + isConflictResponseShape, + buildConflictDataFromHttpError, +} from '../sync/httpConflictDetection'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -629,26 +614,16 @@ apiClient.interceptors.response.use( }); } - // Extract version metadata from request headers - const clientVersionHeader = originalRequest.headers?.['X-Last-Known-Version']; - const clientTimestampHeader = originalRequest.headers?.['X-Client-Timestamp']; - const entityTypeHeader = originalRequest.headers?.['X-Entity-Type']; - const entityIdHeader = originalRequest.headers?.['X-Entity-Id']; - - const conflictData: ConflictData = { - id: `conflict_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, - entityId: responseData?.entityId ?? String(entityIdHeader ?? ''), - entityType: responseData?.entityType ?? String(entityTypeHeader ?? 'unknown'), - localData: originalRequest.data, - serverData: responseData?.serverVersion, - localVersion: clientVersionHeader ? Number(clientVersionHeader) : undefined, - serverVersion: responseData?.serverVersionNumber, - clientTimestamp: clientTimestampHeader ? Number(clientTimestampHeader) : Date.now(), - serverTimestamp: Date.now(), - endpoint: originalRequest.url ?? '', - method: (originalRequest.method ?? 'UNKNOWN').toUpperCase(), - detectedAt: Date.now(), - }; + // Build conflict record using the centralised detection utility + const conflictData = buildConflictDataFromHttpError({ + responseData, + requestConfig: { + data: originalRequest.data, + url: originalRequest.url ?? '', + method: originalRequest.method ?? 'UNKNOWN', + headers: originalRequest.headers as Record, + }, + }); appLogger.warnSync('409 Conflict - mutation conflicts with server state', { endpoint: originalRequest.url, diff --git a/src/services/api/requestQueue.ts b/src/services/api/requestQueue.ts index c7a901db..04ee1a1c 100644 --- a/src/services/api/requestQueue.ts +++ b/src/services/api/requestQueue.ts @@ -52,6 +52,12 @@ interface QueueMetrics { const QUEUE_KEY = '@teachlink_request_queue'; const QUEUE_METRICS_KEY = '@teachlink_request_queue_metrics'; const MONITOR_INTERVAL_MS = 10000; +/** + * Maximum number of requests the queue will hold. + * When exceeded, the oldest low-priority request is evicted first, + * then normal, then high. Critical requests are never evicted. + */ +const MAX_QUEUE_SIZE = 100; const PRIORITY_ORDER: Record = { critical: 0, high: 1, @@ -67,6 +73,7 @@ class RequestQueue { private networkListener: (() => void) | null = null; // #802: typed as ApiClient to prevent passing arbitrary objects private apiClient: ApiClient | null = null; + private droppedCount = 0; private metrics: QueueMetrics = { totalQueued: 0, byPriority: { critical: 0, high: 0, normal: 0, low: 0 }, @@ -95,8 +102,16 @@ class RequestQueue { const fp = this.fingerprint(config); const existing = queue.find(r => r.fingerprint === fp); if (existing) { - logger.info(`RequestQueue: duplicate ${method} ${endpoint} suppressed — returning existing id ${existing.id}`); - return existing.id; + // For GET requests, replace the existing entry with the newest version + // so reconnection replays only the most recent read. + if (method === 'GET') { + const idx = queue.indexOf(existing); + queue.splice(idx, 1); + logger.info(`RequestQueue: GET ${endpoint} collapsed — replaced older entry ${existing.id}`); + } else { + logger.info(`RequestQueue: duplicate ${method} ${endpoint} suppressed — returning existing id ${existing.id}`); + return existing.id; + } } const queuedRequest: QueuedRequest = { @@ -117,6 +132,23 @@ class RequestQueue { queue.push(queuedRequest); this.sortByPriority(queue); + + // Evict oldest non-critical requests when over capacity + while (queue.length > MAX_QUEUE_SIZE) { + // Find the oldest evictable request (skip critical) + const evictIdx = queue.findIndex(r => r.priority !== 'critical'); + if (evictIdx === -1) break; // all critical — stop evicting + const evicted = queue.splice(evictIdx, 1)[0]; + this.droppedCount++; + this.metrics.byPriority[evicted.priority] = Math.max( + 0, + this.metrics.byPriority[evicted.priority] - 1 + ); + logger.warn( + `RequestQueue: evicted [${evicted.priority}] ${evicted.method} ${evicted.endpoint} (queue full, dropped #${this.droppedCount})` + ); + } + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue)); this.metrics.totalQueued++; @@ -373,6 +405,11 @@ class RequestQueue { }; } + /** Number of requests evicted due to MAX_QUEUE_SIZE since app start. */ + getDroppedCount(): number { + return this.droppedCount; + } + private createBatches(requests: QueuedRequest[]): BatchGroup[] { const groups = new Map(); diff --git a/src/services/mobileAnalytics.ts b/src/services/mobileAnalytics.ts index d18c53f6..493446b4 100644 --- a/src/services/mobileAnalytics.ts +++ b/src/services/mobileAnalytics.ts @@ -1,6 +1,11 @@ import { appLogger } from '../utils/logger'; import { AnalyticsEvent, EventProperties } from '../utils/trackingEvents'; import { AnalyticsBatchQueue } from './analytics/AnalyticsBatchQueue'; +import { + HIGH_FREQUENCY_MAX_PER_SECOND, + getEventFrequency, + shouldSampleEvent, +} from './analytics/samplingPolicy'; import { DPConfig, privatizeDuration, sanitizeProperties } from '../utils/differentialPrivacy'; /** @@ -13,9 +18,8 @@ import { DPConfig, privatizeDuration, sanitizeProperties } from '../utils/differ * - DP is applied per-event before any external SDK call. */ class MobileAnalyticsService { - private static readonly HIGH_FREQUENCY_EVENT_MAX_PER_SECOND = 10; private static readonly HIGH_FREQUENCY_EVENT_INTERVAL_MS = - 1000 / MobileAnalyticsService.HIGH_FREQUENCY_EVENT_MAX_PER_SECOND; + 1000 / HIGH_FREQUENCY_MAX_PER_SECOND; private isInitialized: boolean = false; private currentSessionId: string | null = null; private currentScreen: string | null = null; @@ -23,20 +27,11 @@ class MobileAnalyticsService { private readonly batchQueue = new AnalyticsBatchQueue(); private dpConfig: DPConfig = { epsilon: 1.0, sensitivity: 1.0, enabled: true }; - // Critical events that must always be sent (100% volume) - private readonly CRITICAL_EVENTS: Set = new Set([ - AnalyticsEvent.APP_LAUNCH, - AnalyticsEvent.SESSION_START, - AnalyticsEvent.SESSION_END, - AnalyticsEvent.AUTH_LOGIN, - AnalyticsEvent.AUTH_LOGOUT, - AnalyticsEvent.COURSE_STARTED, - AnalyticsEvent.COURSE_COMPLETED, - AnalyticsEvent.QUIZ_STARTED, - AnalyticsEvent.QUIZ_COMPLETED, - AnalyticsEvent.API_ERROR, - AnalyticsEvent.CRASH_REPORT, - ]); + /** + * Per-session drop counter for observability. + * Exposed via getDroppedCount() so callers can measure analytics volume. + */ + private droppedCount = 0; /** * Initialize the analytics SDK. @@ -101,17 +96,24 @@ class MobileAnalyticsService { * Track a custom event with differential privacy applied to all properties. * Numeric properties receive Laplace noise; strings are PII-sanitized. */ + /** Return the number of events dropped this session (sampling + throttle + budget). */ + public getDroppedCount(): number { + return this.droppedCount + this.batchQueue.getDroppedCount(); + } + public trackEvent(event: AnalyticsEvent, properties?: EventProperties): void { + // 1. High-frequency throttle (per event_name, applied before sampling) if (this.shouldThrottleHighFrequencyEvent(event, properties)) { + this.droppedCount++; return; } - // Implement sampling for non-critical events (10% rate) - if (!this.CRITICAL_EVENTS.has(event)) { - if (Math.random() > 0.1) { - appLogger.debug(`📊 [Analytics] Event: ${event} skipped due to sampling`); - return; - } + // 2. Per-frequency sampling using the documented policy + const frequency = getEventFrequency(event); + if (!shouldSampleEvent(frequency)) { + this.droppedCount++; + appLogger.debug(`📊 [Analytics] Event: ${event} [${frequency}] dropped by sampling policy`); + return; } const payload = { diff --git a/src/services/sync/httpConflictDetection.ts b/src/services/sync/httpConflictDetection.ts new file mode 100644 index 00000000..63106a6e --- /dev/null +++ b/src/services/sync/httpConflictDetection.ts @@ -0,0 +1,87 @@ +/** + * Unified conflict detection for HTTP 409 responses. + * + * Before this module, conflict detection was duplicated in three places: + * 1. axios.config.ts — the 409 response handler + * 2. syncService.ts — isConflictError() / extractConflictPayload() + * 3. syncEntityManager — used by the WebSocket sync path + * + * This module provides the single detection entry point for HTTP-originated + * conflicts. The WebSocket path continues to use syncEntityManager which + * calls conflictResolver.ts directly. + * + * See docs/conflict-resolution-strategy.md for the full strategy. + */ + +import type { ConflictData } from '../../store/conflictStore'; + +/** Runtime shape validator for 409 conflict response bodies. */ +export function isConflictResponseShape(data: unknown): data is { + serverVersion?: unknown; + serverVersionNumber?: number; + localVersion?: unknown; + entityType?: string; + entityId?: string; + message?: string; +} { + return data !== null && data !== undefined && typeof data === 'object'; +} + +/** + * Detect whether an error is a sync conflict (HTTP 409). + * Centralises the check that was previously duplicated in syncService.ts + * (isConflictError) and axios.config.ts (status === 409). + */ +export function isConflictError(error: any): boolean { + return ( + error?.status === 409 || + error?.response?.status === 409 || + error?.code === 'CONFLICT' + ); +} + +/** + * Build a ConflictData record from a 409 HTTP response. + * + * Used by the axios.config.ts response interceptor to feed the unified + * conflictStore, and replaces the inline construction that was there before. + */ +export function buildConflictDataFromHttpError(params: { + responseData: ReturnType extends boolean ? any : never; + requestConfig: { + data?: unknown; + url?: string; + method?: string; + headers?: Record; + }; +}): ConflictData { + const { responseData, requestConfig } = params; + + const clientVersionHeader = requestConfig.headers?.['X-Last-Known-Version']; + const clientTimestampHeader = requestConfig.headers?.['X-Client-Timestamp']; + const entityTypeHeader = requestConfig.headers?.['X-Entity-Type']; + const entityIdHeader = requestConfig.headers?.['X-Entity-Id']; + + return { + id: `conflict_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, + entityId: responseData?.entityId ?? String(entityIdHeader ?? ''), + entityType: responseData?.entityType ?? String(entityTypeHeader ?? 'unknown'), + localData: requestConfig.data, + serverData: responseData?.serverVersion, + localVersion: clientVersionHeader ? Number(clientVersionHeader) : undefined, + serverVersion: responseData?.serverVersionNumber, + clientTimestamp: clientTimestampHeader ? Number(clientTimestampHeader) : Date.now(), + serverTimestamp: Date.now(), + endpoint: requestConfig.url ?? '', + method: (requestConfig.method ?? 'UNKNOWN').toUpperCase(), + detectedAt: Date.now(), + }; +} + +/** + * Extract conflict payload from an error for logging / event emission. + * Replaces syncService.extractConflictPayload. + */ +export function extractConflictPayload(error: any): any { + return error?.response?.data ?? error?.data ?? error?.body ?? null; +} diff --git a/src/services/syncService.ts b/src/services/syncService.ts index 36693b1c..a9b86d5b 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -4,6 +4,7 @@ import { apiService } from './api'; import { batchClient } from './api/batchClient'; import { offlineStorage, SyncOperation, SyncOperationType } from './offlineStorage'; import { syncEntityManager } from './sync/syncEntityManager'; +import { extractConflictPayload, isConflictError } from './sync/httpConflictDetection'; import { useBookmarkStore } from '../store/bookmarkStore'; import { useDeviceStore } from '../store/deviceStore'; import { useSettingsStore } from '../store/settingsStore'; @@ -28,11 +29,8 @@ const MAX_AUTO_SYNC_BACKOFF_MS = 300_000; const CIRCUIT_OPEN_FAILURE_THRESHOLD = 5; const CIRCUIT_OPEN_MS = 600_000; -// Conflict resolution strategies -type LegacyConflictResolutionStrategy = 'serverWins' | 'clientWins' | 'merge' | 'manual'; -type ConflictResolutionStrategy = - | VersionedConflictResolutionStrategy - | LegacyConflictResolutionStrategy; +// Conflict resolution strategies — legacy camelCase aliases are normalised +// by the unified syncEntityManager pathway. No local resolution logic needed. // Sync event types type SyncEventType = @@ -522,14 +520,14 @@ export class SyncService { private recordSyncFailure(operation: SyncOperation, error: any): void { this.metrics.failedOperations += 1; - if (this.isConflictError(error)) { + if (isConflictError(error)) { this.metrics.conflictsDetected += 1; this.emitEvent({ type: 'conflictDetected', operationId: operation.id, data: { operation, - serverData: this.extractConflictPayload(error), + serverData: extractConflictPayload(error), strategy: operation.conflictStrategy ?? 'server-wins', }, error, @@ -538,14 +536,6 @@ export class SyncService { } } - private isConflictError(error: any): boolean { - return error?.status === 409 || error?.response?.status === 409 || error?.code === 'CONFLICT'; - } - - private extractConflictPayload(error: any): any { - return error?.response?.data ?? error?.data ?? error?.body ?? null; - } - /** * Add event listener. Duplicate listeners are ignored. */ @@ -613,31 +603,28 @@ export class SyncService { } /** - * Resolve conflicts using specified strategy + * Resolve conflicts using the unified syncEntityManager pathway. + * Delegates to syncEntityManager.resolveRawConflict for raw data, + * or syncEntityManager.handleServerEntity for versioned entities. */ async resolveConflicts( localData: any, serverData: any, - strategy: ConflictResolutionStrategy = 'server-wins', + strategy: VersionedConflictResolutionStrategy = 'server-wins', baseData?: any ): Promise { - const normalizedStrategy = this.normalizeConflictStrategy(strategy); this.metrics.conflictsDetected += 1; this.emitEvent({ type: 'conflictDetected', - data: { localData, serverData, strategy: normalizedStrategy }, + data: { localData, serverData, strategy }, timestamp: Date.now(), }); - if (strategy === 'manual') { - return { local: localData, server: serverData, base: baseData }; - } - const result = syncEntityManager.resolveRawConflict( localData, serverData, - normalizedStrategy, + strategy, baseData ); @@ -652,29 +639,14 @@ export class SyncService { /** * Resolve a versioned conflict and persist the result in the version store. + * Delegates to syncEntityManager.handleServerEntity. */ resolveVersionedConflict>( serverEntity: VersionedEntity, - strategy: ConflictResolutionStrategy = 'merge', + strategy: VersionedConflictResolutionStrategy = 'merge', baseEntity?: VersionedEntity ) { - const normalizedStrategy = this.normalizeConflictStrategy(strategy); - return syncEntityManager.handleServerEntity(serverEntity, normalizedStrategy, baseEntity); - } - - private normalizeConflictStrategy( - strategy: ConflictResolutionStrategy - ): VersionedConflictResolutionStrategy { - switch (strategy) { - case 'serverWins': - return 'server-wins'; - case 'clientWins': - return 'client-wins'; - case 'manual': - return 'server-wins'; - default: - return strategy; - } + return syncEntityManager.handleServerEntity(serverEntity, strategy, baseEntity); } /** diff --git a/tests/requestQueue.e2e.test.ts b/tests/requestQueue.e2e.test.ts index d08c982b..34282c09 100644 --- a/tests/requestQueue.e2e.test.ts +++ b/tests/requestQueue.e2e.test.ts @@ -234,4 +234,130 @@ describe('requestQueue offline-to-online sync E2E (#840)', () => { const queue = await requestQueue.getQueue(); expect(queue).toHaveLength(0); }); + + // ─── Deduplication tests ──────────────────────────────────────────────── + + it('suppresses duplicate POST requests sharing the same method+URL+body', async () => { + const id1 = await requestQueue.addToQueue( + mockConfig({ method: 'POST', url: '/api/notes', data: { title: 'A' } }) + ); + const id2 = await requestQueue.addToQueue( + mockConfig({ method: 'POST', url: '/api/notes', data: { title: 'A' } }) + ); + + // Second call returns the existing id, not a new one + expect(id2).toBe(id1); + + const queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(1); + }); + + it('allows duplicate POST requests with different bodies', async () => { + await requestQueue.addToQueue( + mockConfig({ method: 'POST', url: '/api/notes', data: { title: 'A' } }) + ); + await requestQueue.addToQueue( + mockConfig({ method: 'POST', url: '/api/notes', data: { title: 'B' } }) + ); + + const queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(2); + }); + + it('collapses GET requests: replaces older entry with the newest', async () => { + await requestQueue.addToQueue( + mockConfig({ method: 'GET', url: '/api/feed' }) + ); + await requestQueue.addToQueue( + mockConfig({ method: 'GET', url: '/api/feed' }) + ); + await requestQueue.addToQueue( + mockConfig({ method: 'GET', url: '/api/feed' }) + ); + + const queue = await requestQueue.getQueue(); + // GETs are collapsed to one entry (the most recent) + expect(queue).toHaveLength(1); + }); + + it('reconnection does not produce duplicate writes for idempotent POSTs', async () => { + const client = jest.fn().mockResolvedValue({ data: 'ok' }); + + // Simulate the same POST being queued multiple times during offline + await requestQueue.addToQueue( + mockConfig({ method: 'POST', url: '/api/enroll', data: { courseId: 'c1' } }) + ); + await requestQueue.addToQueue( + mockConfig({ method: 'POST', url: '/api/enroll', data: { courseId: 'c1' } }) + ); + + goOnline(); + await requestQueue.processQueue(client); + + // Only one network call should have been made + expect(client).toHaveBeenCalledTimes(1); + }); + + // ─── MAX_QUEUE_SIZE eviction tests ────────────────────────────────────── + + it('evicts oldest low-priority requests when queue exceeds MAX_QUEUE_SIZE', async () => { + // Fill queue with 100 low-priority requests + for (let i = 0; i < 100; i++) { + await requestQueue.addToQueue( + mockConfig({ url: `/api/item-${i}` }), + 'low' + ); + } + + let queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(100); + + // Adding one more should evict the oldest low-priority entry + await requestQueue.addToQueue( + mockConfig({ url: '/api/item-new' }), + 'low' + ); + + queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(100); + + // The oldest entry (/api/item-0) should have been evicted + expect(queue.find(r => r.endpoint === '/api/item-0')).toBeUndefined(); + // The newest entry should be present + expect(queue.find(r => r.endpoint === '/api/item-new')).toBeTruthy(); + }); + + it('never evicts critical requests even when queue is full', async () => { + // Fill queue with 100 low-priority requests + for (let i = 0; i < 100; i++) { + await requestQueue.addToQueue( + mockConfig({ url: `/api/low-${i}` }), + 'low' + ); + } + + // Add a critical request — queue is already full + await requestQueue.addToQueue( + mockConfig({ url: '/api/critical-payment' }), + 'critical' + ); + + const queue = await requestQueue.getQueue(); + // Critical request should always be present + expect(queue.find(r => r.endpoint === '/api/critical-payment')).toBeTruthy(); + }); + + it('tracks dropped request count', async () => { + const initialDropped = requestQueue.getDroppedCount(); + + // Fill queue past capacity + for (let i = 0; i < 101; i++) { + await requestQueue.addToQueue( + mockConfig({ url: `/api/items-${i}` }), + 'low' + ); + } + + expect(requestQueue.getDroppedCount()).toBeGreaterThanOrEqual(initialDropped + 1); + }); }); diff --git a/tests/services/sync/conflictResolver.test.ts b/tests/services/sync/conflictResolver.test.ts index 67467afd..a9dd32f9 100644 --- a/tests/services/sync/conflictResolver.test.ts +++ b/tests/services/sync/conflictResolver.test.ts @@ -6,6 +6,12 @@ import { processServerUpdate, resolveConflict, } from '../../../src/services/sync/conflictResolver'; +import { + isConflictError, + isConflictResponseShape, + buildConflictDataFromHttpError, + extractConflictPayload, +} from '../../../src/services/sync/httpConflictDetection'; import syncEntityManager from '../../../src/services/sync/syncEntityManager'; describe('sync conflict resolution', () => { @@ -160,4 +166,119 @@ describe('sync conflict resolution', () => { }); expect(syncEntityManager.getLocal('course', 'course-3')?.clientSeq).toBe(0); }); + + it('syncEntityManager.resolveRawConflict delegates to conflictResolver', () => { + const result = syncEntityManager.resolveRawConflict( + { body: 'Local' }, + { body: 'Server' }, + 'server-wins', + ); + + expect(result.hadConflict).toBe(true); + expect(result.resolved.data.body).toBe('Server'); + }); +}); + +// ─── Unified HTTP conflict detection (httpConflictDetection.ts) ─────────────── + +describe('httpConflictDetection — unified conflict detection', () => { + describe('isConflictError', () => { + it('detects 409 via status property', () => { + expect(isConflictError({ status: 409 })).toBe(true); + }); + + it('detects 409 via response.status', () => { + expect(isConflictError({ response: { status: 409 } })).toBe(true); + }); + + it('detects 409 via code property', () => { + expect(isConflictError({ code: 'CONFLICT' })).toBe(true); + }); + + it('returns false for non-409 errors', () => { + expect(isConflictError({ status: 500 })).toBe(false); + expect(isConflictError({ status: 404 })).toBe(false); + expect(isConflictError(null)).toBe(false); + }); + }); + + describe('isConflictResponseShape', () => { + it('accepts valid conflict response objects', () => { + expect(isConflictResponseShape({ serverVersion: {} })).toBe(true); + expect(isConflictResponseShape({ entityId: '1', entityType: 'note' })).toBe(true); + }); + + it('rejects null, undefined, and non-objects', () => { + expect(isConflictResponseShape(null)).toBe(false); + expect(isConflictResponseShape(undefined)).toBe(false); + expect(isConflictResponseShape('string')).toBe(false); + expect(isConflictResponseShape(42)).toBe(false); + }); + }); + + describe('buildConflictDataFromHttpError', () => { + it('constructs a ConflictData record from a 409 response', () => { + const conflictData = buildConflictDataFromHttpError({ + responseData: { + entityId: 'note-1', + entityType: 'note', + serverVersion: { body: 'Server text' }, + serverVersionNumber: 5, + }, + requestConfig: { + data: { body: 'Local text' }, + url: '/api/notes/note-1', + method: 'PUT', + headers: { + 'X-Last-Known-Version': '4', + 'X-Entity-Type': 'note', + 'X-Entity-Id': 'note-1', + }, + }, + }); + + expect(conflictData.entityId).toBe('note-1'); + expect(conflictData.entityType).toBe('note'); + expect(conflictData.localData).toEqual({ body: 'Local text' }); + expect(conflictData.serverData).toEqual({ body: 'Server text' }); + expect(conflictData.localVersion).toBe(4); + expect(conflictData.serverVersion).toBe(5); + expect(conflictData.endpoint).toBe('/api/notes/note-1'); + expect(conflictData.method).toBe('PUT'); + expect(conflictData.id).toMatch(/^conflict_/); + }); + + it('falls back to defaults for missing response data', () => { + const conflictData = buildConflictDataFromHttpError({ + responseData: undefined, + requestConfig: { + data: { body: 'Local' }, + url: '/api/items', + method: 'POST', + headers: {}, + }, + }); + + expect(conflictData.entityType).toBe('unknown'); + expect(conflictData.entityId).toBe(''); + expect(conflictData.localVersion).toBeUndefined(); + expect(conflictData.serverVersion).toBeUndefined(); + }); + }); + + describe('extractConflictPayload', () => { + it('extracts payload from response.data', () => { + expect(extractConflictPayload({ response: { data: 'payload' } })).toBe('payload'); + }); + + it('falls back to error.data and error.body', () => { + expect(extractConflictPayload({ data: 'fallback' })).toBe('fallback'); + expect(extractConflictPayload({ body: 'body-fallback' })).toBe('body-fallback'); + }); + + it('returns null for errors without payload', () => { + expect(extractConflictPayload({})).toBeNull(); + expect(extractConflictPayload(null)).toBeNull(); + }); + }); });