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:
14
src/App.tsx
14
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<MuseumRecord[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
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 [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState<boolean>(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 (
|
||||
<div className="error-container" dir={dir}>
|
||||
<h2>{t('app.error')}</h2>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
||||
<button onClick={() => window.location.reload()}>{t('app.retry')}</button>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>
|
||||
{t(`errors.${error.type}`)}
|
||||
</p>
|
||||
<button onClick={() => loadData()}>{t('app.retry')}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"district": "المنطقة",
|
||||
"captureRate": "نسبة الاستقطاب"
|
||||
},
|
||||
"errors": {
|
||||
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال NocoDB.",
|
||||
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
|
||||
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
|
||||
"unknown": "حدث خطأ أثناء تحميل البيانات. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"language": {
|
||||
"switch": "EN"
|
||||
},
|
||||
|
||||
@@ -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": "عربي"
|
||||
},
|
||||
|
||||
@@ -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<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>> {
|
||||
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<string, string> = {};
|
||||
@@ -71,8 +115,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||
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 || [];
|
||||
|
||||
@@ -167,13 +210,11 @@ async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promi
|
||||
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<MuseumRecord[]> {
|
||||
|
||||
// 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<MuseumRecord[]> {
|
||||
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<MuseumRecord[]> {
|
||||
export async function fetchData(): Promise<FetchResult> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user