diff --git a/App.tsx b/App.tsx index b11ce06..19b1f43 100644 --- a/App.tsx +++ b/App.tsx @@ -29,10 +29,10 @@ import socketService from './src/services/socket'; import syncService from './src/services/syncService'; import { useAppStore } from './src/store'; import { useNotificationStore } from './src/store/notificationStore'; +import { warmCriticalCaches } from './src/services/cacheWarming'; +import webVitalsService from './src/services/webVitals'; import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; -import { requireEnvVariables } from './src/utils/env'; import { appLogger, logger } from './src/utils/logger'; -import { appLogger } from './src/utils/logger'; import { handleNotificationReceived } from './src/utils/notificationHandlers'; import { prefetchExternalResources } from './src/utils/resourceHints'; @@ -54,6 +54,9 @@ initializeLogging().catch(err => { console.error('[App] Failed to initialize logging:', err); }); +// Start Core Web Vitals monitoring +webVitalsService.init(); + if (__DEV__) { appLogger.infoSync('Development mode: centralized logger active'); LogBox.ignoreLogs(['Non-serializable values were found in the navigation state']); @@ -107,6 +110,9 @@ const App = () => { startupProgressService.startStep('data'); await new Promise(resolve => setTimeout(resolve, 500)); startupProgressService.completeStep('data'); + + // 5. Warm critical caches (user profile + home feed) in parallel + await warmCriticalCaches(); } catch (e) { console.warn('Error during app initialization:', e); // Mark the last step as failed diff --git a/package.json b/package.json index 5181232..3e6b9c6 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "socket.io-client": "^4.8.3", + "web-vitals": "4.2.4", "zustand": "^5.0.10" }, "devDependencies": { diff --git a/src/__tests__/services/cacheWarming.test.ts b/src/__tests__/services/cacheWarming.test.ts new file mode 100644 index 0000000..d354547 --- /dev/null +++ b/src/__tests__/services/cacheWarming.test.ts @@ -0,0 +1,63 @@ +import { warmCriticalCaches } from '../../services/cacheWarming'; +import { courseApi } from '../../services/api/courseApi'; +import { userApi } from '../../services/api/userApi'; +import { useAppStore } from '../../store'; + +jest.mock('../../services/api/courseApi', () => ({ + courseApi: { getCourses: jest.fn() }, +})); +jest.mock('../../services/api/userApi', () => ({ + userApi: { getUser: jest.fn() }, +})); +jest.mock('../../store', () => ({ + useAppStore: { getState: jest.fn() }, +})); + +const getCourses = courseApi.getCourses as jest.Mock; +const getUser = userApi.getUser as jest.Mock; +const getState = useAppStore.getState as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + getCourses.mockResolvedValue([]); + getUser.mockResolvedValue({ id: 'u1' }); +}); + +describe('warmCriticalCaches', () => { + it('always fetches the course list', async () => { + getState.mockReturnValue({ user: null }); + await warmCriticalCaches(); + expect(getCourses).toHaveBeenCalledTimes(1); + }); + + it('fetches user profile when userId is available', async () => { + getState.mockReturnValue({ user: { id: 'u1' } }); + await warmCriticalCaches(); + expect(getUser).toHaveBeenCalledWith('u1'); + }); + + it('skips user profile fetch when not authenticated', async () => { + getState.mockReturnValue({ user: null }); + await warmCriticalCaches(); + expect(getUser).not.toHaveBeenCalled(); + }); + + it('resolves even if course fetch fails', async () => { + getState.mockReturnValue({ user: null }); + getCourses.mockRejectedValue(new Error('network')); + await expect(warmCriticalCaches()).resolves.toBeUndefined(); + }); + + it('resolves even if user fetch fails', async () => { + getState.mockReturnValue({ user: { id: 'u1' } }); + getUser.mockRejectedValue(new Error('network')); + await expect(warmCriticalCaches()).resolves.toBeUndefined(); + }); + + it('fetches courses and user profile in parallel', async () => { + getState.mockReturnValue({ user: { id: 'u1' } }); + await warmCriticalCaches(); + expect(getCourses).toHaveBeenCalledTimes(1); + expect(getUser).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/services/webVitals.test.ts b/src/__tests__/services/webVitals.test.ts new file mode 100644 index 0000000..9f85206 --- /dev/null +++ b/src/__tests__/services/webVitals.test.ts @@ -0,0 +1,135 @@ +import { mobileAnalyticsService } from '../../services/mobileAnalytics'; +import { + clearBaselines, + handleMetric, + setBaseline, + WEB_VITALS_THRESHOLDS, +} from '../../services/webVitals'; +import { AnalyticsEvent, PerformanceMetric } from '../../utils/trackingEvents'; + +jest.mock('../../services/mobileAnalytics', () => ({ + mobileAnalyticsService: { trackEvent: jest.fn() }, +})); + +const trackEvent = mobileAnalyticsService.trackEvent as jest.Mock; + +function makeMetric(value: number, delta = value) { + return { value, delta, id: 'test-id', name: 'LCP', navigationType: 'navigate' } as any; +} + +beforeEach(() => { + trackEvent.mockClear(); + clearBaselines(); +}); + +describe('WEB_VITALS_THRESHOLDS', () => { + it('defines thresholds for all 5 vitals', () => { + const keys = [ + PerformanceMetric.LCP, + PerformanceMetric.FID, + PerformanceMetric.CLS, + PerformanceMetric.FCP, + PerformanceMetric.TTFB, + ]; + keys.forEach(k => { + expect(WEB_VITALS_THRESHOLDS[k]).toHaveProperty('good'); + expect(WEB_VITALS_THRESHOLDS[k]).toHaveProperty('needsImprovement'); + }); + }); +}); + +describe('handleMetric — rating', () => { + it('reports "good" for LCP <= 2500ms', () => { + handleMetric(PerformanceMetric.LCP, makeMetric(2000)); + expect(trackEvent).toHaveBeenCalledWith( + AnalyticsEvent.WEB_VITALS_LCP, + expect.objectContaining({ metric_rating: 'good' }) + ); + }); + + it('reports "needs-improvement" for LCP between 2500 and 4000ms', () => { + handleMetric(PerformanceMetric.LCP, makeMetric(3000)); + expect(trackEvent).toHaveBeenCalledWith( + AnalyticsEvent.WEB_VITALS_LCP, + expect.objectContaining({ metric_rating: 'needs-improvement' }) + ); + }); + + it('reports "poor" for LCP > 4000ms', () => { + handleMetric(PerformanceMetric.LCP, makeMetric(5000)); + expect(trackEvent).toHaveBeenCalledWith( + AnalyticsEvent.WEB_VITALS_LCP, + expect.objectContaining({ metric_rating: 'poor' }) + ); + }); + + it('reports "good" for CLS <= 0.1', () => { + handleMetric(PerformanceMetric.CLS, makeMetric(0.05)); + expect(trackEvent).toHaveBeenCalledWith( + AnalyticsEvent.WEB_VITALS_CLS, + expect.objectContaining({ metric_rating: 'good' }) + ); + }); +}); + +describe('handleMetric — analytics event mapping', () => { + const cases: [PerformanceMetric, AnalyticsEvent][] = [ + [PerformanceMetric.LCP, AnalyticsEvent.WEB_VITALS_LCP], + [PerformanceMetric.FID, AnalyticsEvent.WEB_VITALS_FID], + [PerformanceMetric.CLS, AnalyticsEvent.WEB_VITALS_CLS], + [PerformanceMetric.FCP, AnalyticsEvent.WEB_VITALS_FCP], + [PerformanceMetric.TTFB, AnalyticsEvent.WEB_VITALS_TTFB], + ]; + + test.each(cases)('%s maps to %s', (metric, event) => { + handleMetric(metric, makeMetric(100)); + expect(trackEvent).toHaveBeenCalledWith(event, expect.any(Object)); + }); +}); + +describe('handleMetric — regression detection', () => { + it('sets baseline on first reading and does not fire regression event', () => { + handleMetric(PerformanceMetric.LCP, makeMetric(2000)); + const calls = trackEvent.mock.calls.map(c => c[0]); + expect(calls).not.toContain(AnalyticsEvent.WEB_VITALS_REGRESSION); + }); + + it('fires regression event when value is >20% above baseline', () => { + setBaseline(PerformanceMetric.LCP, 2000); + handleMetric(PerformanceMetric.LCP, makeMetric(2500)); // 25% worse + expect(trackEvent).toHaveBeenCalledWith( + AnalyticsEvent.WEB_VITALS_REGRESSION, + expect.objectContaining({ + metric_name: PerformanceMetric.LCP, + baseline_value: 2000, + }) + ); + }); + + it('does not fire regression event when value is within 20% of baseline', () => { + setBaseline(PerformanceMetric.LCP, 2000); + handleMetric(PerformanceMetric.LCP, makeMetric(2300)); // 15% worse — OK + const calls = trackEvent.mock.calls.map(c => c[0]); + expect(calls).not.toContain(AnalyticsEvent.WEB_VITALS_REGRESSION); + }); + + it('includes regression_pct in the regression event payload', () => { + setBaseline(PerformanceMetric.TTFB, 800); + handleMetric(PerformanceMetric.TTFB, makeMetric(1000)); // 25% worse + expect(trackEvent).toHaveBeenCalledWith( + AnalyticsEvent.WEB_VITALS_REGRESSION, + expect.objectContaining({ regression_pct: 25 }) + ); + }); +}); + +describe('setBaseline / clearBaselines', () => { + it('clearBaselines resets so next reading becomes new baseline', () => { + setBaseline(PerformanceMetric.FCP, 1000); + clearBaselines(); + // First reading after clear should not trigger regression + handleMetric(PerformanceMetric.FCP, makeMetric(5000)); + const calls = trackEvent.mock.calls.map(c => c[0]); + expect(calls).not.toContain(AnalyticsEvent.WEB_VITALS_REGRESSION); + }); +}); diff --git a/src/components/mobile/MobileProfile.tsx b/src/components/mobile/MobileProfile.tsx index b4462d7..3b8bcd9 100644 --- a/src/components/mobile/MobileProfile.tsx +++ b/src/components/mobile/MobileProfile.tsx @@ -623,7 +623,7 @@ export const MobileProfile: React.FC = ({ {/* Quick stats strip */} - + {stripItems.map((s, i) => ( { + const start = Date.now(); + + const userId = useAppStore.getState().user?.id; + + const tasks: Promise[] = [ + courseApi.getCourses().catch(() => null), + ]; + + if (userId) { + tasks.push(userApi.getUser(userId).catch(() => null)); + } + + await Promise.all(tasks); + + appLogger.infoSync(`[CacheWarming] Completed in ${Date.now() - start}ms`); +} diff --git a/src/services/webVitals.ts b/src/services/webVitals.ts new file mode 100644 index 0000000..bb6206b --- /dev/null +++ b/src/services/webVitals.ts @@ -0,0 +1,140 @@ +import { mobileAnalyticsService } from './mobileAnalytics'; +import { appLogger } from '../utils/logger'; +import { AnalyticsEvent, PerformanceMetric } from '../utils/trackingEvents'; + +import type { Metric } from 'web-vitals'; + +/** + * Google's recommended thresholds for Core Web Vitals. + * "good" = passes, "needsImprovement" = warning, above = poor. + */ +export const WEB_VITALS_THRESHOLDS: Record< + PerformanceMetric.LCP | PerformanceMetric.FID | PerformanceMetric.CLS | PerformanceMetric.FCP | PerformanceMetric.TTFB, + { good: number; needsImprovement: number } +> = { + [PerformanceMetric.LCP]: { good: 2500, needsImprovement: 4000 }, + [PerformanceMetric.FID]: { good: 100, needsImprovement: 300 }, + [PerformanceMetric.CLS]: { good: 0.1, needsImprovement: 0.25 }, + [PerformanceMetric.FCP]: { good: 1800, needsImprovement: 3000 }, + [PerformanceMetric.TTFB]: { good: 800, needsImprovement: 1800 }, +}; + +export type VitalRating = 'good' | 'needs-improvement' | 'poor'; + +export interface VitalReport { + name: PerformanceMetric; + value: number; + rating: VitalRating; + delta: number; + id: string; +} + +/** Stored baselines for regression detection (keyed by metric name). */ +const baselines = new Map(); + +function getRating( + metric: PerformanceMetric.LCP | PerformanceMetric.FID | PerformanceMetric.CLS | PerformanceMetric.FCP | PerformanceMetric.TTFB, + value: number +): VitalRating { + const { good, needsImprovement } = WEB_VITALS_THRESHOLDS[metric]; + if (value <= good) return 'good'; + if (value <= needsImprovement) return 'needs-improvement'; + return 'poor'; +} + +function toAnalyticsEvent(metric: PerformanceMetric): AnalyticsEvent { + const map: Record = { + [PerformanceMetric.LCP]: AnalyticsEvent.WEB_VITALS_LCP, + [PerformanceMetric.FID]: AnalyticsEvent.WEB_VITALS_FID, + [PerformanceMetric.CLS]: AnalyticsEvent.WEB_VITALS_CLS, + [PerformanceMetric.FCP]: AnalyticsEvent.WEB_VITALS_FCP, + [PerformanceMetric.TTFB]: AnalyticsEvent.WEB_VITALS_TTFB, + }; + return map[metric] ?? AnalyticsEvent.PERFORMANCE_METRIC; +} + +function handleMetric(perfMetric: PerformanceMetric, raw: Metric): void { + const rating = getRating( + perfMetric as PerformanceMetric.LCP | PerformanceMetric.FID | PerformanceMetric.CLS | PerformanceMetric.FCP | PerformanceMetric.TTFB, + raw.value + ); + + const report: VitalReport = { + name: perfMetric, + value: raw.value, + rating, + delta: raw.delta, + id: raw.id, + }; + + // Report to analytics + mobileAnalyticsService.trackEvent(toAnalyticsEvent(perfMetric), { + metric_name: perfMetric, + metric_value: raw.value, + metric_delta: raw.delta, + metric_rating: rating, + metric_id: raw.id, + }); + + appLogger.infoSync(`[WebVitals] ${perfMetric}: ${raw.value} (${rating})`); + + // Regression detection: alert if value is >20% worse than stored baseline + const baseline = baselines.get(perfMetric); + if (baseline !== undefined) { + const regressionThreshold = baseline * 1.2; + if (raw.value > regressionThreshold) { + appLogger.infoSync( + `[WebVitals] Regression detected for ${perfMetric}: ${raw.value} > baseline ${baseline}` + ); + mobileAnalyticsService.trackEvent(AnalyticsEvent.WEB_VITALS_REGRESSION, { + metric_name: perfMetric, + metric_value: raw.value, + baseline_value: baseline, + regression_pct: Math.round(((raw.value - baseline) / baseline) * 100), + }); + } + } else { + // First reading becomes the baseline + baselines.set(perfMetric, raw.value); + } + + return report as unknown as void; // typed void for callback compatibility +} + +/** + * Initialise Core Web Vitals collection. + * Safe to call in React Native / non-browser environments — the web-vitals + * functions are no-ops when the browser Performance API is unavailable. + */ +export function init(): void { + // Dynamic import keeps the web-vitals bundle out of the critical path and + // avoids hard failures in environments where the APIs don't exist. + import('web-vitals') + .then(({ onLCP, onFID, onCLS, onFCP, onTTFB }) => { + onLCP((m) => handleMetric(PerformanceMetric.LCP, m)); + onFID((m) => handleMetric(PerformanceMetric.FID, m)); + onCLS((m) => handleMetric(PerformanceMetric.CLS, m)); + onFCP((m) => handleMetric(PerformanceMetric.FCP, m)); + onTTFB((m) => handleMetric(PerformanceMetric.TTFB, m)); + appLogger.infoSync('[WebVitals] Monitoring initialised'); + }) + .catch((err) => { + appLogger.infoSync(`[WebVitals] Failed to load web-vitals: ${err}`); + }); +} + +/** Exposed for testing — allows injecting a known baseline. */ +export function setBaseline(metric: PerformanceMetric, value: number): void { + baselines.set(metric, value); +} + +/** Exposed for testing — clears all stored baselines. */ +export function clearBaselines(): void { + baselines.clear(); +} + +/** Exposed for testing — directly invoke the metric handler. */ +export { handleMetric }; + +const webVitalsService = { init, setBaseline, clearBaselines }; +export default webVitalsService; diff --git a/src/utils/trackingEvents.ts b/src/utils/trackingEvents.ts index 2b5b4c2..1cbc37b 100644 --- a/src/utils/trackingEvents.ts +++ b/src/utils/trackingEvents.ts @@ -40,6 +40,14 @@ export enum AnalyticsEvent { AB_EXPOSURE = 'ab_exposure', API_ERROR = 'api_error', CRASH_REPORT = 'crash_report', + + // Core Web Vitals + WEB_VITALS_LCP = 'web_vitals_lcp', + WEB_VITALS_FID = 'web_vitals_fid', + WEB_VITALS_CLS = 'web_vitals_cls', + WEB_VITALS_FCP = 'web_vitals_fcp', + WEB_VITALS_TTFB = 'web_vitals_ttfb', + WEB_VITALS_REGRESSION = 'web_vitals_regression', } /** @@ -74,4 +82,11 @@ export enum PerformanceMetric { APP_LOAD_TIME = 'app_load_time', SCREEN_TRANSITION_TIME = 'screen_transition_time', API_RESPONSE_TIME = 'api_response_time', + + // Core Web Vitals + LCP = 'lcp', + FID = 'fid', + CLS = 'cls', + FCP = 'fcp', + TTFB = 'ttfb', }