- Convert all .js files to .tsx/.ts - Add types for data structures (MuseumRecord, Metrics, etc.) - Add type declarations for react-chartjs-2 - Configure tsconfig with relaxed strictness for gradual adoption - All components now use TypeScript
407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
// 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<T>(tableId: string, limit: number = 1000): Promise<T[]> {
|
|
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<MuseumRecord[]> {
|
|
console.log('Fetching from NocoDB...');
|
|
|
|
// Fetch all three tables in parallel
|
|
const [districts, museums, dailyStats] = await Promise.all([
|
|
fetchNocoDBTable<NocoDBDistrict>(NOCODB_TABLES.districts),
|
|
fetchNocoDBTable<NocoDBMuseum>(NOCODB_TABLES.museums),
|
|
fetchNocoDBTable<NocoDBDailyStat>(NOCODB_TABLES.dailyStats)
|
|
]);
|
|
|
|
// Build lookup maps
|
|
const districtMap: Record<number, string> = {};
|
|
districts.forEach(d => { districtMap[d.Id] = d.Name; });
|
|
|
|
const museumMap: Record<number, MuseumMapEntry> = {};
|
|
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<FetchResult> {
|
|
// 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<FetchResult> {
|
|
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<DateRangeFilters> = {}
|
|
): 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<string, GroupedData> {
|
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
|
const grouped: Record<string, GroupedData> = {};
|
|
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<string, GroupedData> {
|
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
|
const grouped: Record<string, GroupedData> = {};
|
|
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<string, GroupedData> {
|
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
|
const grouped: Record<string, GroupedData> = {};
|
|
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<string, Set<string>> = {};
|
|
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';
|
|
}
|