From 80b982a1826aacbc398c2c4e069f4af6ea1837e2 Mon Sep 17 00:00:00 2001 From: abdulrcrtw Date: Mon, 29 Jun 2026 15:56:00 +0100 Subject: [PATCH] feat: grid sort nullsFirst, network probe, token cache, dedup subscriptions Closes #632 - Add nullsFirst prop and case-insensitive/diacritic sort Closes #634 - Add connectivity probe service with periodic endpoint ping Closes #635 - Add in-memory+AsyncStorage TokenCache with TTL eviction Closes #638 - Deduplicate sync event listeners and fix nested subscription bug --- src/hooks/useNetworkStatus.ts | 40 ++++------- src/services/networkMonitor.ts | 125 +++++++++++++++++++++++++++++++++ src/services/secureStorage.ts | 102 +++++++++++++++++++++++++++ src/services/syncService.ts | 35 +++++---- src/utils/gridUtils.ts | 27 +++++-- 5 files changed, 284 insertions(+), 45 deletions(-) create mode 100644 src/services/networkMonitor.ts diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index df18fde6..6ad9ec44 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -1,13 +1,9 @@ import { Network } from 'expo-network'; import { useState, useEffect, useCallback } from 'react'; -export type ConnectionType = 'wifi' | 'cellular' | 'none' | 'unknown'; +import { networkMonitor, type ConnectionType, type NetworkStatus } from '../services/networkMonitor'; -export interface NetworkStatus { - isConnected: boolean; - isInternetReachable: boolean; - type: ConnectionType; -} +export type { ConnectionType, NetworkStatus }; export interface ConnectionQuality { quality: 'slow-3g' | 'fast-3g' | '4g' | '5g' | 'wifi' | 'unknown'; @@ -15,11 +11,7 @@ export interface ConnectionQuality { } export function useNetworkStatus() { - const [networkStatus, setNetworkStatus] = useState({ - isConnected: false, - isInternetReachable: false, - type: 'unknown', - }); + const [networkStatus, setNetworkStatus] = useState(networkMonitor.getStatus()); const [connectionQuality, setConnectionQuality] = useState({ quality: 'unknown', isFast: false, @@ -29,14 +21,10 @@ export function useNetworkStatus() { const fetchNetworkState = useCallback(async () => { setIsChecking(true); try { + const probeStatus = await networkMonitor.probe(); + setNetworkStatus(probeStatus); + const networkState = await Network.getNetworkStateAsync(); - - // Update network status - setNetworkStatus({ - isConnected: networkState.isConnected, - isInternetReachable: networkState.isInternetReachable ?? true, // Assume true if not provided - type: networkState.type, - }); // Determine connection quality let quality: ConnectionQuality['quality'] = 'unknown'; @@ -61,7 +49,6 @@ export function useNetworkStatus() { quality = '4g'; isFast = true; } else if (generation === '3g') { - // Consider 3G with signal strength >= 50 as fast-3G if (signalStrength >= 50) { quality = 'fast-3g'; isFast = true; @@ -70,18 +57,15 @@ export function useNetworkStatus() { isFast = false; } } else { - // 2g or unknown generation quality = 'slow-3g'; isFast = false; } } catch (error) { console.warn('Failed to get cellular state', error); - // Fallback to treating cellular as unknown quality quality = 'unknown'; isFast = false; } } else { - // unknown or none (but we already checked isConnected) quality = 'unknown'; isFast = false; } @@ -104,14 +88,16 @@ export function useNetworkStatus() { }, []); useEffect(() => { - // Fetch initial state - fetchNetworkState(); + void networkMonitor.init(); + + const unsubMonitor = networkMonitor.subscribe(setNetworkStatus); - // Subscribe to network state changes - const subscription = Network.addNetworkStateListener(fetchNetworkState); + const subscription = Network.addNetworkStateListener(() => { + fetchNetworkState(); + }); - // Cleanup return () => { + unsubMonitor(); subscription.remove(); }; }, [fetchNetworkState]); diff --git a/src/services/networkMonitor.ts b/src/services/networkMonitor.ts new file mode 100644 index 00000000..2920c5d1 --- /dev/null +++ b/src/services/networkMonitor.ts @@ -0,0 +1,125 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Network from 'expo-network'; + +import logger from '../utils/logger'; + +export type ConnectionType = 'wifi' | 'cellular' | 'none' | 'unknown'; + +export interface NetworkStatus { + isConnected: boolean; + isInternetReachable: boolean; + type: ConnectionType; +} + +const PROBE_URL = 'https://clients3.google.com/generate_204'; +const PROBE_INTERVAL_MS = 30_000; +const PROBE_TIMEOUT_MS = 5_000; +const STORAGE_KEY = '@teachlink_network_status'; + +type Listener = (status: NetworkStatus) => void; + +class NetworkMonitor { + private status: NetworkStatus = { + isConnected: false, + isInternetReachable: false, + type: 'unknown', + }; + private listeners: Set = new Set(); + private probeInterval: ReturnType | null = null; + private networkSubscription: (() => void) | null = null; + private initialized = false; + + getStatus(): NetworkStatus { + return { ...this.status }; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify() { + this.listeners.forEach(fn => fn(this.status)); + } + + async init(): Promise { + if (this.initialized) return; + this.initialized = true; + + // Load cached status + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (raw) { + this.status = JSON.parse(raw); + } + } catch { + // Ignore + } + + // Subscribe to OS-level network changes + const sub = Network.addNetworkStateListener(state => { + this.status.isConnected = state.isConnected ?? false; + this.status.type = state.type as ConnectionType; + this.probe(); + }); + this.networkSubscription = () => sub.remove(); + + // Start periodic probing + this.probeInterval = setInterval(() => this.probe(), PROBE_INTERVAL_MS); + + // Initial probe + await this.probe(); + } + + async probe(): Promise { + const start = Date.now(); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + + const response = await fetch(PROBE_URL, { + method: 'HEAD', + signal: controller.signal, + }); + clearTimeout(timeout); + + this.status.isInternetReachable = response.ok || response.status === 204; + } catch { + this.status.isInternetReachable = false; + } + + this.notify(); + await this.persist(); + + return { ...this.status }; + } + + private async persist(): Promise { + try { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(this.status)); + } catch { + // Ignore + } + } + + async refresh(): Promise { + return this.probe(); + } + + destroy(): void { + if (this.probeInterval) { + clearInterval(this.probeInterval); + this.probeInterval = null; + } + if (this.networkSubscription) { + this.networkSubscription(); + this.networkSubscription = null; + } + this.listeners.clear(); + this.initialized = false; + } +} + +export const networkMonitor = new NetworkMonitor(); diff --git a/src/services/secureStorage.ts b/src/services/secureStorage.ts index 41e2e834..3078cf69 100644 --- a/src/services/secureStorage.ts +++ b/src/services/secureStorage.ts @@ -326,6 +326,108 @@ export async function isBiometricEnabled(): Promise { return value === '1'; } +// ─── Token Cache ────────────────────────────────────────────────────────────── + +const TOKEN_CACHE_KEY = '@teachlink_token_cache'; +const DEFAULT_TTL_MS = 5 * 60 * 1_000; // 5 minutes + +interface CacheEntry { + value: string; + expiresAt: number; + createdAt: number; +} + +class TokenCache { + private memory: Map = new Map(); + private initialized = false; + + private isExpired(entry: CacheEntry): boolean { + return Date.now() > entry.expiresAt; + } + + private async persist(): Promise { + try { + const obj: Record = {}; + this.memory.forEach((entry, key) => { + if (!this.isExpired(entry)) { + obj[key] = entry; + } + }); + await AsyncStorage.setItem(TOKEN_CACHE_KEY, JSON.stringify(obj)); + } catch { + // Non-critical; cache will warm from SecureStore on next read + } + } + + async init(): Promise { + if (this.initialized) return; + try { + const raw = await AsyncStorage.getItem(TOKEN_CACHE_KEY); + if (raw) { + const parsed: Record = JSON.parse(raw); + for (const [key, entry] of Object.entries(parsed)) { + if (!this.isExpired(entry)) { + this.memory.set(key, entry); + } + } + } + } catch { + // Ignore + } + this.initialized = true; + } + + get(key: string): string | null { + const entry = this.memory.get(key); + if (!entry) return null; + if (this.isExpired(entry)) { + this.memory.delete(key); + this.persist(); + return null; + } + return entry.value; + } + + async set(key: string, value: string, ttlMs: number = DEFAULT_TTL_MS): Promise { + const entry: CacheEntry = { + value, + expiresAt: Date.now() + ttlMs, + createdAt: Date.now(), + }; + this.memory.set(key, entry); + await this.persist(); + } + + invalidate(key: string): void { + this.memory.delete(key); + this.persist(); + } + + async clear(): Promise { + this.memory.clear(); + try { + await AsyncStorage.removeItem(TOKEN_CACHE_KEY); + } catch { + // Ignore + } + } + + get size(): number { + this.evictExpired(); + return this.memory.size; + } + + private evictExpired(): void { + for (const [key, entry] of this.memory) { + if (this.isExpired(entry)) { + this.memory.delete(key); + } + } + } +} + +export const tokenCache = new TokenCache(); + // ─── Remember Me ────────────────────────────────────────────────────────────── /** diff --git a/src/services/syncService.ts b/src/services/syncService.ts index 26ff89b6..36693b1c 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -98,18 +98,18 @@ export class SyncService { this.stopAutoSync(); this.startAutoSync(); } + } + }); - // If the app goes to background, stop auto-sync to conserve CPU/battery. - if (state.isInBackground !== prevState.isInBackground) { - logger.info(`SyncService: App background state changed to ${state.isInBackground}`); - if (state.isInBackground) { - this.stopAutoSync(); - // also clear listeners to reduce memory usage while backgrounded - this.removeAllEventListeners(); - } else { - // resumed to foreground - this.startAutoSync(); - } + // Subscribe to app foreground/background state changes + useDeviceStore.subscribe((state: any, prevState: any) => { + if (state.isInBackground !== prevState.isInBackground) { + logger.info(`SyncService: App background state changed to ${state.isInBackground}`); + if (state.isInBackground) { + this.stopAutoSync(); + this.removeAllEventListeners(); + } else { + this.startAutoSync(); } } }); @@ -547,10 +547,12 @@ export class SyncService { } /** - * Add event listener + * Add event listener. Duplicate listeners are ignored. */ addEventListener(listener: (event: SyncEvent) => void): void { - this.eventListeners.push(listener); + if (!this.eventListeners.includes(listener)) { + this.eventListeners.push(listener); + } } /** @@ -563,6 +565,13 @@ export class SyncService { } } + /** + * Check if a listener is already registered + */ + hasEventListener(listener: (event: SyncEvent) => void): boolean { + return this.eventListeners.includes(listener); + } + removeAllEventListeners(): void { if (this.eventListeners.length > 0) { this.eventListeners = []; diff --git a/src/utils/gridUtils.ts b/src/utils/gridUtils.ts index 1bdcc6ea..515c5f55 100644 --- a/src/utils/gridUtils.ts +++ b/src/utils/gridUtils.ts @@ -83,14 +83,28 @@ export interface EditingCell { // ─── Sorting ───────────────────────────────────────────────────────────────── +/** + * Normalize a string for comparison: strip diacritics (NFD + remove combining marks) + * and convert to lower case for case-insensitive ordering. + */ +function normalizeForCompare(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase(); +} + /** * Return a new sorted copy of `rows` using the provided `config`. - * Null / undefined values are always placed at the end regardless of direction. + * Null / undefined values are placed according to `nullsFirst`: + * - true → nulls first (regardless of direction) + * - false → nulls last (regardless of direction, default) */ export function sortRows( rows: T[], config: SortConfig, - columns: ColumnDef[] + columns: ColumnDef[], + nullsFirst = false ): T[] { const col = columns.find((c) => c.key === config.columnKey); const type = col?.type ?? 'string'; @@ -102,8 +116,8 @@ export function sortRows( const bVal = b[columnKey]; if (aVal == null && bVal == null) return 0; - if (aVal == null) return 1; - if (bVal == null) return -1; + if (aVal == null) return nullsFirst ? -1 : 1; + if (bVal == null) return nullsFirst ? 1 : -1; if (type === 'number') { return (Number(aVal) - Number(bVal)) * multiplier; @@ -112,6 +126,9 @@ export function sortRows( if (type === 'date') { const aTime = new Date(aVal as string).getTime(); const bTime = new Date(bVal as string).getTime(); + if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0; + if (Number.isNaN(aTime)) return nullsFirst ? -1 : 1; + if (Number.isNaN(bTime)) return nullsFirst ? 1 : -1; return (aTime - bTime) * multiplier; } @@ -122,7 +139,7 @@ export function sortRows( return (aNum - bNum) * multiplier; } - return String(aVal).localeCompare(String(bVal)) * multiplier; + return normalizeForCompare(String(aVal)).localeCompare(normalizeForCompare(String(bVal))) * multiplier; }); }