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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,13 @@
|
|||||||
"district": "المنطقة",
|
"district": "المنطقة",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب"
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال NocoDB.",
|
||||||
|
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||||
|
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
|
||||||
|
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
|
||||||
|
"unknown": "حدث خطأ أثناء تحميل البيانات. يرجى المحاولة مرة أخرى."
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"switch": "EN"
|
"switch": "EN"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": "عربي"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user