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) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-25 18:08:24 +03:00
parent ed29e7c22c
commit 8934ba1e51
5 changed files with 103 additions and 26 deletions

View File

@@ -5,7 +5,8 @@ import Comparison from './components/Comparison';
import Slides from './components/Slides'; import Slides from './components/Slides';
import { fetchData, getCacheStatus, refreshData } from './services/dataService'; import { fetchData, getCacheStatus, refreshData } from './services/dataService';
import { useLanguage } from './contexts/LanguageContext'; 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'; import './App.css';
interface NavLinkProps { interface NavLinkProps {
@@ -35,7 +36,7 @@ function App() {
const [data, setData] = useState<MuseumRecord[]>([]); const [data, setData] = useState<MuseumRecord[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [refreshing, setRefreshing] = useState<boolean>(false); const [refreshing, setRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
const [isOffline, setIsOffline] = useState<boolean>(false); const [isOffline, setIsOffline] = useState<boolean>(false);
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null); const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
const [showDataLabels, setShowDataLabels] = useState<boolean>(false); const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
@@ -62,7 +63,8 @@ function App() {
const status = getCacheStatus(); const status = getCacheStatus();
setCacheInfo(status); setCacheInfo(status);
} catch (err) { } 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); console.error(err);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -92,8 +94,10 @@ function App() {
return ( return (
<div className="error-container" dir={dir}> <div className="error-container" dir={dir}>
<h2>{t('app.error')}</h2> <h2>{t('app.error')}</h2>
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p> <p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>
<button onClick={() => window.location.reload()}>{t('app.retry')}</button> {t(`errors.${error.type}`)}
</p>
<button onClick={() => loadData()}>{t('app.retry')}</button>
</div> </div>
); );
} }

View File

@@ -149,6 +149,13 @@
"district": "المنطقة", "district": "المنطقة",
"captureRate": "نسبة الاستقطاب" "captureRate": "نسبة الاستقطاب"
}, },
"errors": {
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال NocoDB.",
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
"unknown": "حدث خطأ أثناء تحميل البيانات. يرجى المحاولة مرة أخرى."
},
"language": { "language": {
"switch": "EN" "switch": "EN"
}, },

View File

@@ -149,6 +149,13 @@
"district": "District", "district": "District",
"captureRate": "Capture Rate" "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": { "language": {
"switch": "عربي" "switch": "عربي"
}, },

View File

@@ -14,26 +14,70 @@ import type {
UmrahData, UmrahData,
NocoDBDistrict, NocoDBDistrict,
NocoDBMuseum, NocoDBMuseum,
NocoDBDailyStat NocoDBDailyStat,
DataErrorType
} from '../types'; } from '../types';
import { DataError } from '../types';
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || ''; const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || ''; const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || ''; 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 // Table IDs discovered dynamically from NocoDB meta API
let discoveredTables: Record<string, string> | null = null; let discoveredTables: Record<string, string> | null = null;
// ============================================
// Fetch Helpers (timeout + retry)
// ============================================
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = FETCH_TIMEOUT_MS): Promise<Response> {
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<Response> {
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<Record<string, string>> { async function discoverTableIds(): Promise<Record<string, string>> {
if (discoveredTables) return discoveredTables; if (discoveredTables) return discoveredTables;
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured'); 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`, `${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`,
{ headers: { 'xc-token': NOCODB_TOKEN } } { headers: { 'xc-token': NOCODB_TOKEN } }
); );
if (!res.ok) throw new Error(`Failed to discover tables: HTTP ${res.status}`);
const json = await res.json(); const json = await res.json();
const tables: Record<string, string> = {}; const tables: Record<string, string> = {};
@@ -71,8 +115,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
return umrahData; return umrahData;
} }
const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`; const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`;
const res = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json(); const json = await res.json();
const records = json.list || []; const records = json.list || [];
@@ -165,15 +208,13 @@ export function clearCache(): void {
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> { async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
let allRecords: T[] = []; let allRecords: T[] = [];
let offset = 0; let offset = 0;
while (true) { while (true) {
const response = await fetch( const response = await fetchWithRetry(
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`, `${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
{ headers: { 'xc-token': NOCODB_TOKEN } } { headers: { 'xc-token': NOCODB_TOKEN } }
); );
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json(); const json = await response.json();
const records: T[] = json.list || []; const records: T[] = json.list || [];
allRecords = allRecords.concat(records); allRecords = allRecords.concat(records);
@@ -226,7 +267,7 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
// GrossRevenue = including VAT, NetRevenue = excluding VAT // GrossRevenue = including VAT, NetRevenue = excluding VAT
const grossRevenue = row.GrossRevenue || 0; const grossRevenue = row.GrossRevenue || 0;
const netRevenue = row.NetRevenue || (grossRevenue / 1.15); const netRevenue = row.NetRevenue || (grossRevenue / VAT_RATE);
return { return {
date: date, date: date,
@@ -247,6 +288,19 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
return data; 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) // Main Data Fetcher (with offline fallback)
// ============================================ // ============================================
@@ -254,34 +308,29 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
export async function fetchData(): Promise<FetchResult> { export async function fetchData(): Promise<FetchResult> {
// Check if NocoDB is configured // Check if NocoDB is configured
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) { if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
// Try cache
const cached = loadFromCache(); const cached = loadFromCache();
if (cached) { if (cached) {
console.warn('NocoDB not configured, using cached data'); console.warn('NocoDB not configured, using cached data');
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp }; 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 {
// Try to fetch fresh data
const data = await fetchFromNocoDB(); const data = await fetchFromNocoDB();
// Save to cache on success
saveToCache(data); saveToCache(data);
return { data, fromCache: false }; return { data, fromCache: false };
} catch (err) { } catch (err) {
console.error('NocoDB fetch failed:', (err as Error).message); console.error('NocoDB fetch failed:', (err as Error).message);
// Try to load from cache
const cached = loadFromCache(); const cached = loadFromCache();
if (cached) { if (cached) {
console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`); console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`);
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp }; 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);
} }
} }

View File

@@ -53,6 +53,16 @@ export interface FetchResult {
cacheTimestamp?: number; 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 { export interface GroupedData {
revenue: number; revenue: number;
visitors: number; visitors: number;