From 8934ba1e5197736e32c90b129830923fdf2cac02 Mon Sep 17 00:00:00 2001 From: fahed Date: Wed, 25 Mar 2026 18:08:24 +0300 Subject: [PATCH] Add fetch timeout/retry, friendly error messages, and VAT rate constant - fetchWithTimeout (10s) + fetchWithRetry (3 attempts, exponential backoff) - DataError class with type classification (config/network/auth/timeout/unknown) - User-friendly error messages in EN/AR instead of raw error strings - Extract VAT_RATE constant (was hardcoded 1.15) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 14 ++++-- src/locales/ar.json | 7 +++ src/locales/en.json | 7 +++ src/services/dataService.ts | 91 ++++++++++++++++++++++++++++--------- src/types/index.ts | 10 ++++ 5 files changed, 103 insertions(+), 26 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c8b3f80..33f65c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,8 @@ import Comparison from './components/Comparison'; import Slides from './components/Slides'; import { fetchData, getCacheStatus, refreshData } from './services/dataService'; import { useLanguage } from './contexts/LanguageContext'; -import type { MuseumRecord, CacheStatus } from './types'; +import type { MuseumRecord, CacheStatus, DataErrorType } from './types'; +import { DataError } from './types'; import './App.css'; interface NavLinkProps { @@ -35,7 +36,7 @@ function App() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null); const [isOffline, setIsOffline] = useState(false); const [cacheInfo, setCacheInfo] = useState(null); const [showDataLabels, setShowDataLabels] = useState(false); @@ -62,7 +63,8 @@ function App() { const status = getCacheStatus(); setCacheInfo(status); } catch (err) { - setError((err as Error).message); + const type = err instanceof DataError ? err.type : 'unknown'; + setError({ message: (err as Error).message, type }); console.error(err); } finally { setLoading(false); @@ -92,8 +94,10 @@ function App() { return (

{t('app.error')}

-

{error}

- +

+ {t(`errors.${error.type}`)} +

+
); } diff --git a/src/locales/ar.json b/src/locales/ar.json index 9c7ea69..e45545f 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -149,6 +149,13 @@ "district": "المنطقة", "captureRate": "نسبة الاستقطاب" }, + "errors": { + "config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال NocoDB.", + "network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.", + "auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.", + "timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.", + "unknown": "حدث خطأ أثناء تحميل البيانات. يرجى المحاولة مرة أخرى." + }, "language": { "switch": "EN" }, diff --git a/src/locales/en.json b/src/locales/en.json index 69f785d..a8e01a8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -149,6 +149,13 @@ "district": "District", "captureRate": "Capture Rate" }, + "errors": { + "config": "The dashboard is not configured. Please set up the NocoDB connection.", + "network": "Cannot reach the database server. Please check your internet connection.", + "auth": "Access denied. The API token may be invalid or expired.", + "timeout": "The database server is taking too long to respond. Please try again.", + "unknown": "Something went wrong while loading data. Please try again." + }, "language": { "switch": "عربي" }, diff --git a/src/services/dataService.ts b/src/services/dataService.ts index eed4c3f..25b4c78 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -14,26 +14,70 @@ import type { UmrahData, NocoDBDistrict, NocoDBMuseum, - NocoDBDailyStat + NocoDBDailyStat, + DataErrorType } from '../types'; +import { DataError } from '../types'; const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || ''; const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || ''; const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || ''; +const FETCH_TIMEOUT_MS = 10_000; +const MAX_RETRIES = 3; +const VAT_RATE = 1.15; + // Table IDs discovered dynamically from NocoDB meta API let discoveredTables: Record | null = null; +// ============================================ +// Fetch Helpers (timeout + retry) +// ============================================ + +async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = FETCH_TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { ...options, signal: controller.signal }); + return res; + } catch (err) { + if ((err as Error).name === 'AbortError') { + throw new Error(`Request timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +async function fetchWithRetry(url: string, options: RequestInit = {}, retries: number = MAX_RETRIES): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt < retries; attempt++) { + try { + const res = await fetchWithTimeout(url, options); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res; + } catch (err) { + lastError = err as Error; + if (attempt < retries - 1) { + const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s + console.warn(`Fetch attempt ${attempt + 1} failed, retrying in ${delay}ms...`); + await new Promise(r => setTimeout(r, delay)); + } + } + } + throw lastError; +} + async function discoverTableIds(): Promise> { if (discoveredTables) return discoveredTables; if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured'); - const res = await fetch( + const res = await fetchWithRetry( `${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`, { headers: { 'xc-token': NOCODB_TOKEN } } ); - if (!res.ok) throw new Error(`Failed to discover tables: HTTP ${res.status}`); const json = await res.json(); const tables: Record = {}; @@ -71,8 +115,7 @@ export async function fetchPilgrimStats(): Promise { return umrahData; } const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`; - const res = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const json = await res.json(); const records = json.list || []; @@ -165,15 +208,13 @@ export function clearCache(): void { async function fetchNocoDBTable(tableId: string, limit: number = 1000): Promise { let allRecords: T[] = []; let offset = 0; - + while (true) { - const response = await fetch( + const response = await fetchWithRetry( `${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`, { headers: { 'xc-token': NOCODB_TOKEN } } ); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const json = await response.json(); const records: T[] = json.list || []; allRecords = allRecords.concat(records); @@ -226,7 +267,7 @@ async function fetchFromNocoDB(): Promise { // GrossRevenue = including VAT, NetRevenue = excluding VAT const grossRevenue = row.GrossRevenue || 0; - const netRevenue = row.NetRevenue || (grossRevenue / 1.15); + const netRevenue = row.NetRevenue || (grossRevenue / VAT_RATE); return { date: date, @@ -247,6 +288,19 @@ async function fetchFromNocoDB(): Promise { return data; } +// ============================================ +// Error Classification +// ============================================ + +function classifyError(err: Error): DataErrorType { + const msg = err.message.toLowerCase(); + if (msg.includes('not configured')) return 'config'; + if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'; + if (msg.includes('401') || msg.includes('403') || msg.includes('authentication') || msg.includes('unauthorized')) return 'auth'; + if (msg.includes('failed to fetch') || msg.includes('network') || msg.includes('econnrefused') || msg.includes('err_connection')) return 'network'; + return 'unknown'; +} + // ============================================ // Main Data Fetcher (with offline fallback) // ============================================ @@ -254,34 +308,29 @@ async function fetchFromNocoDB(): Promise { export async function fetchData(): Promise { // Check if NocoDB is configured if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) { - // Try cache const cached = loadFromCache(); if (cached) { console.warn('NocoDB not configured, using cached data'); return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp }; } - throw new Error('NocoDB not configured and no cached data available. Set VITE_NOCODB_URL, VITE_NOCODB_TOKEN, and VITE_NOCODB_BASE_ID in .env.local'); + throw new DataError('NocoDB not configured', 'config'); } - + try { - // Try to fetch fresh data const data = await fetchFromNocoDB(); - - // Save to cache on success saveToCache(data); - return { data, fromCache: false }; } catch (err) { console.error('NocoDB fetch failed:', (err as Error).message); - - // Try to load from cache + const cached = loadFromCache(); if (cached) { console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`); return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp }; } - - throw new Error(`Database unavailable and no cached data: ${(err as Error).message}`); + + const errorType = classifyError(err as Error); + throw new DataError((err as Error).message, errorType); } } diff --git a/src/types/index.ts b/src/types/index.ts index 21e4611..578adda 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -53,6 +53,16 @@ export interface FetchResult { cacheTimestamp?: number; } +export type DataErrorType = 'config' | 'network' | 'auth' | 'timeout' | 'unknown'; + +export class DataError extends Error { + type: DataErrorType; + constructor(message: string, type: DataErrorType) { + super(message); + this.type = type; + } +} + export interface GroupedData { revenue: number; visitors: number;