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();
+ });
+ });
});