chore: migrate to TypeScript

- 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
This commit is contained in:
fahed
2026-02-04 13:45:50 +03:00
parent e98bebd60b
commit 868f46fc6e
18 changed files with 484 additions and 121 deletions
@@ -1,6 +1,22 @@
// 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 || '';
@@ -16,7 +32,7 @@ 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 = {
export const umrahData: UmrahData = {
2024: { 1: 11574494, 2: 10521465, 3: 3364627, 4: 7435625 },
2025: { 1: 15222497, 2: 5443393, 3: null, 4: null }
};
@@ -25,43 +41,37 @@ export const umrahData = {
// Offline Cache Functions
// ============================================
function saveToCache(data) {
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.message);
console.warn('Failed to save to cache:', (err as Error).message);
}
}
function loadFromCache() {
function loadFromCache(): CacheResult | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
if (!cached) return null;
const data = JSON.parse(cached);
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) };
return { data, isStale, timestamp: parseInt(timestamp || '0') };
} catch (err) {
console.warn('Failed to load from cache:', err.message);
console.warn('Failed to load from cache:', (err as Error).message);
return null;
}
}
function getCacheAge() {
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
if (!timestamp) return null;
return Date.now() - parseInt(timestamp);
}
export function getCacheStatus() {
export function getCacheStatus(): CacheStatus {
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
const cached = localStorage.getItem(CACHE_KEY);
@@ -70,7 +80,7 @@ export function getCacheStatus() {
}
const ts = parseInt(timestamp);
const data = JSON.parse(cached);
const data: MuseumRecord[] = JSON.parse(cached);
return {
available: true,
@@ -81,7 +91,7 @@ export function getCacheStatus() {
};
}
export function clearCache() {
export function clearCache(): void {
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_TIMESTAMP_KEY);
console.log('Cache cleared');
@@ -91,8 +101,8 @@ export function clearCache() {
// NocoDB Data Fetching
// ============================================
async function fetchNocoDBTable(tableId, limit = 1000) {
let allRecords = [];
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
let allRecords: T[] = [];
let offset = 0;
while (true) {
@@ -104,7 +114,7 @@ async function fetchNocoDBTable(tableId, limit = 1000) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
const records = json.list || [];
const records: T[] = json.list || [];
allRecords = allRecords.concat(records);
if (records.length < limit) break;
@@ -114,21 +124,27 @@ async function fetchNocoDBTable(tableId, limit = 1000) {
return allRecords;
}
async function fetchFromNocoDB() {
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(NOCODB_TABLES.districts),
fetchNocoDBTable(NOCODB_TABLES.museums),
fetchNocoDBTable(NOCODB_TABLES.dailyStats)
fetchNocoDBTable<NocoDBDistrict>(NOCODB_TABLES.districts),
fetchNocoDBTable<NocoDBMuseum>(NOCODB_TABLES.museums),
fetchNocoDBTable<NocoDBDailyStat>(NOCODB_TABLES.dailyStats)
]);
// Build lookup maps
const districtMap = {};
const districtMap: Record<number, string> = {};
districts.forEach(d => { districtMap[d.Id] = d.Name; });
const museumMap = {};
const museumMap: Record<number, MuseumMapEntry> = {};
museums.forEach(m => {
museumMap[m.Id] = {
code: m.Code,
@@ -138,8 +154,8 @@ async function fetchFromNocoDB() {
});
// Join data into flat structure
const data = dailyStats.map(row => {
const museum = museumMap[row['nc_epk____Museums_id']] || {};
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;
@@ -172,7 +188,7 @@ async function fetchFromNocoDB() {
// Main Data Fetcher (with offline fallback)
// ============================================
export async function fetchData() {
export async function fetchData(): Promise<FetchResult> {
// Check if NocoDB is configured
if (!NOCODB_URL || !NOCODB_TOKEN) {
// Try cache
@@ -193,7 +209,7 @@ export async function fetchData() {
return { data, fromCache: false };
} catch (err) {
console.error('NocoDB fetch failed:', err.message);
console.error('NocoDB fetch failed:', (err as Error).message);
// Try to load from cache
const cached = loadFromCache();
@@ -202,12 +218,12 @@ export async function fetchData() {
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
throw new Error(`Database unavailable and no cached data: ${err.message}`);
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() {
export async function refreshData(): Promise<FetchResult> {
if (!NOCODB_URL || !NOCODB_TOKEN) {
throw new Error('NocoDB not configured');
}
@@ -221,7 +237,7 @@ export async function refreshData() {
// Data Filtering & Metrics
// ============================================
export function filterData(data, filters) {
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;
@@ -231,7 +247,12 @@ export function filterData(data, filters) {
});
}
export function filterDataByDateRange(data, startDate, endDate, filters = {}) {
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;
@@ -241,11 +262,11 @@ export function filterDataByDateRange(data, startDate, endDate, filters = {}) {
});
}
export function calculateMetrics(data, includeVAT = true) {
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const revenue = data.reduce((sum, row) => sum + parseFloat(row[revenueField] || row.revenue_incl_tax || 0), 0);
const visitors = data.reduce((sum, row) => sum + parseInt(row.visits || 0), 0);
const tickets = data.reduce((sum, row) => sum + parseInt(row.tickets || 0), 0);
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 };
}
@@ -254,7 +275,7 @@ export function calculateMetrics(data, includeVAT = true) {
// Formatting Functions
// ============================================
export function formatCurrency(num) {
export function formatCurrency(num: number): string {
if (isNaN(num)) return 'SAR 0';
return new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -263,12 +284,12 @@ export function formatCurrency(num) {
}).format(num);
}
export function formatNumber(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) {
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';
@@ -276,7 +297,7 @@ export function formatCompact(num) {
return formatNumber(num);
}
export function formatCompactCurrency(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';
@@ -288,7 +309,7 @@ export function formatCompactCurrency(num) {
// Grouping Functions
// ============================================
export function getWeekStart(dateStr) {
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);
@@ -304,43 +325,43 @@ export function getWeekStart(dateStr) {
return `${y}-${m}-${d}`;
}
export function groupByWeek(data, includeVAT = true) {
export function groupByWeek(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped = {};
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 += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
grouped[weekStart].visitors += parseInt(row.visits || 0);
grouped[weekStart].tickets += parseInt(row.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, includeVAT = true) {
export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped = {};
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 += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
grouped[row.museum_name].visitors += parseInt(row.visits || 0);
grouped[row.museum_name].tickets += parseInt(row.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, includeVAT = true) {
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped = {};
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 += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
grouped[row.district].visitors += parseInt(row.visits || 0);
grouped[row.district].tickets += parseInt(row.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;
}
@@ -349,36 +370,37 @@ export function groupByDistrict(data, includeVAT = true) {
// Data Extraction Helpers
// ============================================
export function getUniqueYears(data) {
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) {
export function getUniqueDistricts(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
}
export function getDistrictMuseumMap(data) {
const map = {};
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 => {
map[d] = [...map[d]].sort();
result[d] = [...map[d]].sort();
});
return map;
return result;
}
export function getMuseumsForDistrict(districtMuseumMap, district) {
export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] {
if (district === 'all') {
return Object.values(districtMuseumMap).flat().sort();
}
return districtMuseumMap[district] || [];
}
export function getLatestYear(data) {
export function getLatestYear(data: MuseumRecord[]): string {
const years = getUniqueYears(data);
return years.length > 0 ? years[years.length - 1] : '2025';
}