// Data source: NocoDB only // Offline mode: caches data to localStorage for resilience import type { MuseumRecord, Metrics, Filters, DateRangeFilters, CacheStatus, CacheResult, FetchResult, GroupedData, DistrictMuseumMap, UmrahData, NocoDBDistrict, NocoDBMuseum, NocoDBDailyStat } from '../types'; const NOCODB_URL = process.env.REACT_APP_NOCODB_URL || ''; const NOCODB_TOKEN = process.env.REACT_APP_NOCODB_TOKEN || ''; // Table IDs const NOCODB_TABLES = { districts: 'm8cup7lesbet0sa', museums: 'm1c7od7mdirffvu', dailyStats: 'mc7qhbdh3mjjwl8' }; // Cache keys const CACHE_KEY = 'hihala_data_cache'; const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp'; const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days export const umrahData: UmrahData = { 2024: { 1: 11574494, 2: 10521465, 3: 3364627, 4: 7435625 }, 2025: { 1: 15222497, 2: 5443393, 3: null, 4: null } }; // ============================================ // Offline Cache Functions // ============================================ function saveToCache(data: MuseumRecord[]): void { try { localStorage.setItem(CACHE_KEY, JSON.stringify(data)); localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString()); console.log(`Cached ${data.length} rows to localStorage`); } catch (err) { console.warn('Failed to save to cache:', (err as Error).message); } } function loadFromCache(): CacheResult | null { try { const cached = localStorage.getItem(CACHE_KEY); const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY); if (!cached) return null; const data: MuseumRecord[] = JSON.parse(cached); const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity; const isStale = age > CACHE_MAX_AGE_MS; console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`); return { data, isStale, timestamp: parseInt(timestamp || '0') }; } catch (err) { console.warn('Failed to load from cache:', (err as Error).message); return null; } } export function getCacheStatus(): CacheStatus { const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY); const cached = localStorage.getItem(CACHE_KEY); if (!cached || !timestamp) { return { available: false, timestamp: null, age: null, rows: 0 }; } const ts = parseInt(timestamp); const data: MuseumRecord[] = JSON.parse(cached); return { available: true, timestamp: new Date(ts).toISOString(), age: Date.now() - ts, rows: data.length, isStale: (Date.now() - ts) > CACHE_MAX_AGE_MS }; } export function clearCache(): void { localStorage.removeItem(CACHE_KEY); localStorage.removeItem(CACHE_TIMESTAMP_KEY); console.log('Cache cleared'); } // ============================================ // NocoDB Data Fetching // ============================================ async function fetchNocoDBTable(tableId: string, limit: number = 1000): Promise { let allRecords: T[] = []; let offset = 0; while (true) { const response = await fetch( `${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); if (records.length < limit) break; offset += limit; } return allRecords; } interface MuseumMapEntry { code: string; name: string; district: string; } async function fetchFromNocoDB(): Promise { console.log('Fetching from NocoDB...'); // Fetch all three tables in parallel const [districts, museums, dailyStats] = await Promise.all([ fetchNocoDBTable(NOCODB_TABLES.districts), fetchNocoDBTable(NOCODB_TABLES.museums), fetchNocoDBTable(NOCODB_TABLES.dailyStats) ]); // Build lookup maps const districtMap: Record = {}; districts.forEach(d => { districtMap[d.Id] = d.Name; }); const museumMap: Record = {}; museums.forEach(m => { museumMap[m.Id] = { code: m.Code, name: m.Name, district: districtMap[m['nc_epk____Districts_id']] || 'Unknown' }; }); // Join data into flat structure const data: MuseumRecord[] = dailyStats.map(row => { const museum = museumMap[row['nc_epk____Museums_id']] || { code: '', name: '', district: '' }; const date = row.Date; const year = date ? date.substring(0, 4) : ''; const month = date ? parseInt(date.substring(5, 7)) : 0; const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4'; // GrossRevenue = including VAT, NetRevenue = excluding VAT const grossRevenue = row.GrossRevenue || 0; const netRevenue = row.NetRevenue || (grossRevenue / 1.15); return { date: date, museum_code: museum.code, museum_name: museum.name, district: museum.district, visits: row.Visits, tickets: row.Tickets, revenue_gross: grossRevenue, revenue_net: netRevenue, revenue_incl_tax: grossRevenue, // Legacy compatibility year: year, quarter: quarter }; }).filter(r => r.date && r.museum_name); console.log(`Loaded ${data.length} rows from NocoDB`); return data; } // ============================================ // Main Data Fetcher (with offline fallback) // ============================================ export async function fetchData(): Promise { // Check if NocoDB is configured if (!NOCODB_URL || !NOCODB_TOKEN) { // 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 REACT_APP_NOCODB_URL and REACT_APP_NOCODB_TOKEN in .env.local'); } 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}`); } } // Force refresh (bypass cache read, but still write to cache) export async function refreshData(): Promise { if (!NOCODB_URL || !NOCODB_TOKEN) { throw new Error('NocoDB not configured'); } const data = await fetchFromNocoDB(); saveToCache(data); return { data, fromCache: false }; } // ============================================ // Data Filtering & Metrics // ============================================ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] { return data.filter(row => { if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false; if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false; if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false; if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false; return true; }); } export function filterDataByDateRange( data: MuseumRecord[], startDate: string, endDate: string, filters: Partial = {} ): MuseumRecord[] { return data.filter(row => { if (!row.date) return false; if (row.date < startDate || row.date > endDate) return false; if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false; if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false; return true; }); } export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const revenue = data.reduce((sum, row) => sum + (row[revenueField] || row.revenue_incl_tax || 0), 0); const visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0); const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0); const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0; return { revenue, visitors, tickets, avgRevPerVisitor }; } // ============================================ // Formatting Functions // ============================================ export function formatCurrency(num: number): string { if (isNaN(num)) return 'SAR 0'; return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'SAR', maximumFractionDigits: 0 }).format(num); } export function formatNumber(num: number): string { if (isNaN(num)) return '0'; return new Intl.NumberFormat('en-US').format(Math.round(num)); } export function formatCompact(num: number): string { if (isNaN(num)) return '0'; const absNum = Math.abs(num); if (absNum >= 1000000) return (num / 1000000).toFixed(1) + 'M'; if (absNum >= 1000) return (num / 1000).toFixed(0) + 'K'; return formatNumber(num); } export function formatCompactCurrency(num: number): string { if (isNaN(num)) return 'SAR 0'; const absNum = Math.abs(num); if (absNum >= 1000000) return 'SAR ' + (num / 1000000).toFixed(1) + 'M'; if (absNum >= 1000) return 'SAR ' + (num / 1000).toFixed(0) + 'K'; return formatCurrency(num); } // ============================================ // Grouping Functions // ============================================ export function getWeekStart(dateStr: string): string | null { if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null; const [year, month, day] = dateStr.split('-').map(Number); const date = new Date(year, month - 1, day); const dayOfWeek = date.getDay(); const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const monday = new Date(year, month - 1, day + diff); const y = monday.getFullYear(); const m = String(monday.getMonth() + 1).padStart(2, '0'); const d = String(monday.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } export function groupByWeek(data: MuseumRecord[], includeVAT: boolean = true): Record { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const grouped: Record = {}; data.forEach(row => { if (!row.date) return; const weekStart = getWeekStart(row.date); if (!weekStart) return; if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 }; grouped[weekStart].revenue += row[revenueField] || row.revenue_incl_tax || 0; grouped[weekStart].visitors += row.visits || 0; grouped[weekStart].tickets += row.tickets || 0; }); return grouped; } export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true): Record { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const grouped: Record = {}; data.forEach(row => { if (!row.museum_name) return; if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 }; grouped[row.museum_name].revenue += row[revenueField] || row.revenue_incl_tax || 0; grouped[row.museum_name].visitors += row.visits || 0; grouped[row.museum_name].tickets += row.tickets || 0; }); return grouped; } export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const grouped: Record = {}; data.forEach(row => { if (!row.district) return; if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 }; grouped[row.district].revenue += row[revenueField] || row.revenue_incl_tax || 0; grouped[row.district].visitors += row.visits || 0; grouped[row.district].tickets += row.tickets || 0; }); return grouped; } // ============================================ // Data Extraction Helpers // ============================================ export function getUniqueYears(data: MuseumRecord[]): string[] { const years = [...new Set(data.map(r => r.year).filter(Boolean))]; return years.sort((a, b) => parseInt(a) - parseInt(b)); } export function getUniqueDistricts(data: MuseumRecord[]): string[] { return [...new Set(data.map(r => r.district).filter(Boolean))].sort(); } export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap { const map: Record> = {}; data.forEach(row => { if (!row.district || !row.museum_name) return; if (!map[row.district]) map[row.district] = new Set(); map[row.district].add(row.museum_name); }); const result: DistrictMuseumMap = {}; Object.keys(map).forEach(d => { result[d] = [...map[d]].sort(); }); return result; } export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] { if (district === 'all') { return Object.values(districtMuseumMap).flat().sort(); } return districtMuseumMap[district] || []; } export function getLatestYear(data: MuseumRecord[]): string { const years = getUniqueYears(data); return years.length > 0 ? years[years.length - 1] : '2025'; }