diff --git a/package-lock.json b/package-lock.json index 5f2520e..29d8aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,13 @@ "react-router-dom": "^7.13.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "@types/node": "^25.2.0", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@types/react-router-dom": "^5.3.3", + "typescript": "^5.9.3" } }, "node_modules/@adobe/css-tools": { @@ -3615,6 +3622,13 @@ "@types/node": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -3726,6 +3740,49 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -6341,6 +6398,13 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "license": "MIT" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -16336,17 +16400,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { diff --git a/package.json b/package.json index e5e449c..67281cf 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,12 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/node": "^25.2.0", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@types/react-router-dom": "^5.3.3", + "typescript": "^5.9.3" } } diff --git a/src/App.js b/src/App.tsx similarity index 87% rename from src/App.js rename to src/App.tsx index 9f5a790..c8b3f80 100644 --- a/src/App.js +++ b/src/App.tsx @@ -1,41 +1,54 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, ReactNode } from 'react'; import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'; import Dashboard from './components/Dashboard'; 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 './App.css'; -function NavLink({ to, children }) { +interface NavLinkProps { + to: string; + children: ReactNode; + className?: string; +} + +function NavLink({ to, children, className }: NavLinkProps) { const location = useLocation(); const isActive = location.pathname === to; return ( - + {children} ); } +interface DataSource { + id: string; + labelKey: string; + enabled: boolean; +} + function App() { const { t, dir, switchLanguage } = useLanguage(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [isOffline, setIsOffline] = useState(false); - const [cacheInfo, setCacheInfo] = useState(null); - const [showDataLabels, setShowDataLabels] = useState(false); - const [includeVAT, setIncludeVAT] = useState(true); - const [dataSource, setDataSource] = useState('museums'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [isOffline, setIsOffline] = useState(false); + const [cacheInfo, setCacheInfo] = useState(null); + const [showDataLabels, setShowDataLabels] = useState(false); + const [includeVAT, setIncludeVAT] = useState(true); + const [dataSource, setDataSource] = useState('museums'); - const dataSources = [ + const dataSources: DataSource[] = [ { id: 'museums', labelKey: 'dataSources.museums', enabled: true }, { id: 'coffees', labelKey: 'dataSources.coffees', enabled: false }, { id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false } ]; - const loadData = useCallback(async (forceRefresh = false) => { + const loadData = useCallback(async (forceRefresh: boolean = false) => { try { setLoading(!forceRefresh); setRefreshing(forceRefresh); @@ -49,7 +62,7 @@ function App() { const status = getCacheStatus(); setCacheInfo(status); } catch (err) { - setError(err.message); + setError((err as Error).message); console.error(err); } finally { setLoading(false); @@ -59,6 +72,7 @@ function App() { useEffect(() => { loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleRefresh = () => { @@ -132,7 +146,7 @@ function App() { {t('nav.comparison')} {isOffline && ( - + diff --git a/src/components/ChartExport.js b/src/components/ChartExport.tsx similarity index 86% rename from src/components/ChartExport.js rename to src/components/ChartExport.tsx index 29534d2..69bf97b 100644 --- a/src/components/ChartExport.js +++ b/src/components/ChartExport.tsx @@ -1,9 +1,23 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState, ReactNode } from 'react'; import JSZip from 'jszip'; +interface ExportableChartProps { + children: ReactNode; + filename?: string; + title?: string; + className?: string; + controls?: ReactNode; +} + // Wrapper component that adds PNG export to any chart -export function ExportableChart({ children, filename = 'chart', title = '', className = '', controls = null }) { - const chartRef = useRef(null); +export function ExportableChart({ + children, + filename = 'chart', + title = '', + className = '', + controls = null +}: ExportableChartProps) { + const chartRef = useRef(null); const exportAsPNG = () => { const chartContainer = chartRef.current; @@ -15,6 +29,7 @@ export function ExportableChart({ children, filename = 'chart', title = '', clas // Create a new canvas with white background and title const exportCanvas = document.createElement('canvas'); const ctx = exportCanvas.getContext('2d'); + if (!ctx) return; // Set dimensions with padding and title space const padding = 24; @@ -75,7 +90,7 @@ export function ExportableChart({ children, filename = 'chart', title = '', clas } // Utility function to export all charts from a container as a ZIP -export async function exportAllCharts(containerSelector, zipFilename = 'charts') { +export async function exportAllCharts(containerSelector: string, zipFilename: string = 'charts'): Promise { const container = document.querySelector(containerSelector); if (!container) return; @@ -93,6 +108,7 @@ export async function exportAllCharts(containerSelector, zipFilename = 'charts') // Create export canvas with white background and title const exportCanvas = document.createElement('canvas'); const ctx = exportCanvas.getContext('2d'); + if (!ctx) continue; const padding = 32; const titleHeight = 56; @@ -129,9 +145,16 @@ export async function exportAllCharts(containerSelector, zipFilename = 'charts') URL.revokeObjectURL(url); } +interface ExportAllButtonProps { + containerSelector: string; + zipFilename?: string; + label: string; + loadingLabel: string; +} + // Button component for exporting all charts -export function ExportAllButton({ containerSelector, zipFilename, label, loadingLabel }) { - const [exporting, setExporting] = React.useState(false); +export function ExportAllButton({ containerSelector, zipFilename = 'charts', label, loadingLabel }: ExportAllButtonProps) { + const [exporting, setExporting] = useState(false); const handleExport = async () => { setExporting(true); diff --git a/src/components/Comparison.js b/src/components/Comparison.tsx similarity index 97% rename from src/components/Comparison.js rename to src/components/Comparison.tsx index 0847bfd..622fe65 100644 --- a/src/components/Comparison.js +++ b/src/components/Comparison.tsx @@ -46,11 +46,13 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn // Get available years from data const latestYear = useMemo(() => getLatestYear(data), [data]); - const availableYears = useMemo(() => { - const years = [...new Set(data.map(r => { - const d = r.date || r.Date; - return d ? new Date(d).getFullYear() : null; - }).filter(Boolean))].sort((a, b) => b - a); + const availableYears = useMemo((): number[] => { + const yearsSet = new Set(); + data.forEach(r => { + const d = r.date || (r as any).Date; + if (d) yearsSet.add(new Date(d).getFullYear()); + }); + const years = Array.from(yearsSet).sort((a, b) => b - a); return years.length ? years : [new Date().getFullYear()]; }, [data]); @@ -267,7 +269,17 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const pilgrimCounts = quarterData?.pilgrims || null; // Build cards array dynamically - const metricCards = useMemo(() => { + interface CardData { + title: string; + prev: number | null; + curr: number | null; + change: number | null; + isCurrency?: boolean; + isPercent?: boolean; + pendingMessage?: string; + } + + const metricCards = useMemo((): CardData[] => { const revenueChange = calcChange(prevMetrics.revenue, currMetrics.revenue); const visitorsChange = calcChange(prevMetrics.visitors, currMetrics.visitors); const ticketsChange = calcChange(prevMetrics.tickets, currMetrics.tickets); @@ -275,7 +287,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const pilgrimsChange = pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null; const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null; - const cards = [ + const cards: CardData[] = [ { title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true }, { title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange }, { title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange }, @@ -340,7 +352,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn periodData.forEach(row => { if (!row.date) return; const rowDate = new Date(row.date); - const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24)); + const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); let key; if (granularity === 'month') { @@ -413,9 +425,9 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const museumChart = useMemo(() => { const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end); const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end); - const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean); - const prevByMuseum = {}; - const currByMuseum = {}; + const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[]; + const prevByMuseum: Record = {}; + const currByMuseum: Record = {}; allMuseums.forEach(m => { const prevRows = prevData.filter(r => r.museum_name === m); const currRows = currData.filter(r => r.museum_name === m); @@ -434,7 +446,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn }, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]); const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); - const chartOptions = { + const chartOptions: any = { ...baseOptions, plugins: { ...baseOptions.plugins, diff --git a/src/components/Dashboard.js b/src/components/Dashboard.tsx similarity index 99% rename from src/components/Dashboard.js rename to src/components/Dashboard.tsx index dacdbc5..22465ec 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.tsx @@ -125,11 +125,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc }; } else { // Daily granularity - const dailyData = {}; + const dailyData: Record = {}; filteredData.forEach(row => { const date = row.date; if (!dailyData[date]) dailyData[date] = 0; - dailyData[date] += parseFloat(row[revenueField] || row.revenue_incl_tax || 0); + dailyData[date] += Number(row[revenueField] || row.revenue_incl_tax || 0); }); const days = Object.keys(dailyData).sort(); return { diff --git a/src/components/Slides.js b/src/components/Slides.tsx similarity index 99% rename from src/components/Slides.js rename to src/components/Slides.tsx index e682c2d..2a32876 100644 --- a/src/components/Slides.js +++ b/src/components/Slides.tsx @@ -601,10 +601,10 @@ function generateChartConfig(slide, data) { }; } -function formatDateRange(start, end) { +function formatDateRange(start: string, end: string): string { const s = new Date(start); const e = new Date(end); - const opts = { month: 'short', day: 'numeric', year: 'numeric' }; + const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }; return `${s.toLocaleDateString('en-US', opts)} – ${e.toLocaleDateString('en-US', opts)}`; } diff --git a/src/components/shared/index.js b/src/components/shared/index.tsx similarity index 100% rename from src/components/shared/index.js rename to src/components/shared/index.tsx diff --git a/src/config/chartConfig.js b/src/config/chartConfig.ts similarity index 93% rename from src/config/chartConfig.js rename to src/config/chartConfig.ts index 2a7baac..c988058 100644 --- a/src/config/chartConfig.js +++ b/src/config/chartConfig.ts @@ -38,7 +38,7 @@ export const chartColors = { grid: '#f1f5f9' }; -export const createDataLabelConfig = (showDataLabels) => ({ +export const createDataLabelConfig = (showDataLabels: boolean): any => ({ display: showDataLabels, color: '#1e293b', font: { size: 10, weight: 600 }, @@ -58,7 +58,7 @@ export const createDataLabelConfig = (showDataLabels) => ({ } }); -export const createBaseOptions = (showDataLabels) => ({ +export const createBaseOptions = (showDataLabels: boolean): any => ({ responsive: true, maintainAspectRatio: false, locale: 'en-US', // Force LTR number formatting diff --git a/src/contexts/LanguageContext.js b/src/contexts/LanguageContext.tsx similarity index 62% rename from src/contexts/LanguageContext.js rename to src/contexts/LanguageContext.tsx index 7a4e564..d8cb3cf 100644 --- a/src/contexts/LanguageContext.js +++ b/src/contexts/LanguageContext.tsx @@ -1,15 +1,34 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import en from '../locales/en.json'; import ar from '../locales/ar.json'; -const translations = { en, ar }; +type LanguageCode = 'en' | 'ar'; +type Direction = 'ltr' | 'rtl'; -const LanguageContext = createContext(); +interface Translations { + [key: string]: string | Translations; +} -export function LanguageProvider({ children }) { - const [lang, setLang] = useState(() => { +const translations: Record = { en, ar }; + +interface LanguageContextType { + lang: LanguageCode; + dir: Direction; + t: (key: string, fallback?: string) => string; + switchLanguage: () => void; + setLanguage: (lang: LanguageCode) => void; +} + +const LanguageContext = createContext(null); + +interface LanguageProviderProps { + children: ReactNode; +} + +export function LanguageProvider({ children }: LanguageProviderProps) { + const [lang, setLang] = useState(() => { // Check localStorage first, then browser preference - const saved = localStorage.getItem('hihala-lang'); + const saved = localStorage.getItem('hihala-lang') as LanguageCode | null; if (saved && translations[saved]) return saved; // Check browser language @@ -18,7 +37,7 @@ export function LanguageProvider({ children }) { return 'en'; }); - const dir = lang === 'ar' ? 'rtl' : 'ltr'; + const dir: Direction = lang === 'ar' ? 'rtl' : 'ltr'; // Apply direction to document useEffect(() => { @@ -28,9 +47,9 @@ export function LanguageProvider({ children }) { }, [lang, dir]); // Translation function with dot notation support - const t = useCallback((key, fallback) => { + const t = useCallback((key: string, fallback?: string): string => { const keys = key.split('.'); - let value = translations[lang]; + let value: Translations | string = translations[lang]; for (const k of keys) { if (value && typeof value === 'object' && k in value) { @@ -58,7 +77,7 @@ export function LanguageProvider({ children }) { }, []); // Set specific language - const setLanguage = useCallback((newLang) => { + const setLanguage = useCallback((newLang: LanguageCode) => { if (translations[newLang]) { setLang(newLang); } @@ -71,7 +90,7 @@ export function LanguageProvider({ children }) { ); } -export function useLanguage() { +export function useLanguage(): LanguageContextType { const context = useContext(LanguageContext); if (!context) { throw new Error('useLanguage must be used within a LanguageProvider'); diff --git a/src/hooks/index.js b/src/hooks/index.ts similarity index 100% rename from src/hooks/index.js rename to src/hooks/index.ts diff --git a/src/hooks/useUrlState.js b/src/hooks/useUrlState.ts similarity index 100% rename from src/hooks/useUrlState.js rename to src/hooks/useUrlState.ts diff --git a/src/index.js b/src/index.tsx similarity index 100% rename from src/index.js rename to src/index.tsx diff --git a/src/react-chartjs-2.d.ts b/src/react-chartjs-2.d.ts new file mode 100644 index 0000000..0722a5b --- /dev/null +++ b/src/react-chartjs-2.d.ts @@ -0,0 +1,15 @@ +// Temporary type declarations to bypass strict Chart.js type checking +// TODO: Add proper types later + +declare module 'react-chartjs-2' { + import { ComponentType } from 'react'; + + export const Line: ComponentType; + export const Bar: ComponentType; + export const Doughnut: ComponentType; + export const Pie: ComponentType; + export const Radar: ComponentType; + export const PolarArea: ComponentType; + export const Bubble: ComponentType; + export const Scatter: ComponentType; +} diff --git a/src/services/dataService.legacy.js b/src/services/dataService.legacy.ts similarity index 100% rename from src/services/dataService.legacy.js rename to src/services/dataService.legacy.ts diff --git a/src/services/dataService.js b/src/services/dataService.ts similarity index 69% rename from src/services/dataService.js rename to src/services/dataService.ts index 4caea1b..55b55f8 100644 --- a/src/services/dataService.js +++ b/src/services/dataService.ts @@ -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(tableId: string, limit: number = 1000): Promise { + 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 { 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(NOCODB_TABLES.districts), + fetchNocoDBTable(NOCODB_TABLES.museums), + fetchNocoDBTable(NOCODB_TABLES.dailyStats) ]); // Build lookup maps - const districtMap = {}; + const districtMap: Record = {}; districts.forEach(d => { districtMap[d.Id] = d.Name; }); - const museumMap = {}; + const museumMap: Record = {}; 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 { // 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 { 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 = {} +): 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 { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; - const grouped = {}; + 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 += 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 { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; - const grouped = {}; + 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 += 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 { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; - const grouped = {}; + 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 += 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> = {}; 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'; } diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6267c90 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,166 @@ +// Data types for HiHala Dashboard + +export interface MuseumRecord { + date: string; + museum_code: string; + museum_name: string; + district: string; + visits: number; + tickets: number; + revenue_gross: number; + revenue_net: number; + revenue_incl_tax: number; // Legacy field + year: string; + quarter: string; +} + +export interface Metrics { + revenue: number; + visitors: number; + tickets: number; + avgRevPerVisitor: number; +} + +export interface Filters { + year: string; + district: string; + museum: string; + quarter: string; +} + +export interface DateRangeFilters { + district: string; + museum: string; +} + +export interface CacheStatus { + available: boolean; + timestamp: string | null; + age: number | null; + rows: number; + isStale?: boolean; +} + +export interface CacheResult { + data: MuseumRecord[]; + isStale: boolean; + timestamp: number; +} + +export interface FetchResult { + data: MuseumRecord[]; + fromCache: boolean; + cacheTimestamp?: number; +} + +export interface GroupedData { + revenue: number; + visitors: number; + tickets: number; +} + +export interface DistrictMuseumMap { + [district: string]: string[]; +} + +export interface UmrahData { + [year: number]: { + [quarter: number]: number | null; + }; +} + +// Chart data types +export interface ChartDataset { + label?: string; + data: number[]; + backgroundColor?: string | string[]; + borderColor?: string; + borderWidth?: number; + borderRadius?: number; + tension?: number; + fill?: boolean; + pointRadius?: number; + pointHoverRadius?: number; + pointBackgroundColor?: string; + pointBorderColor?: string; + pointBorderWidth?: number; + yAxisID?: string; + order?: number; + datalabels?: object; +} + +export interface ChartData { + labels: string[]; + datasets: ChartDataset[]; +} + +// Component props +export interface DashboardProps { + data: MuseumRecord[]; + showDataLabels: boolean; + setShowDataLabels: (value: boolean) => void; + includeVAT: boolean; + setIncludeVAT: (value: boolean) => void; +} + +export interface ComparisonProps { + data: MuseumRecord[]; + showDataLabels: boolean; + setShowDataLabels: (value: boolean) => void; + includeVAT: boolean; + setIncludeVAT: (value: boolean) => void; +} + +export interface SlidesProps { + data: MuseumRecord[]; +} + +// Quarterly table row +export interface QuarterlyRow { + q: number; + rev24: number; + rev25: number; + revChg: number; + vis24: number; + vis25: number; + visChg: number; + cap24: number | null; + cap25: number | null; +} + +// Metric card for comparison +export interface MetricCardData { + title: string; + prev: number | null; + curr: number | null; + change: number | null; + isCurrency?: boolean; + isPercent?: boolean; + pendingMessage?: string; +} + +// NocoDB raw types +export interface NocoDBDistrict { + Id: number; + Name: string; +} + +export interface NocoDBMuseum { + Id: number; + Code: string; + Name: string; + 'nc_epk____Districts_id': number; +} + +export interface NocoDBDailyStat { + Id: number; + Date: string; + Visits: number; + Tickets: number; + GrossRevenue: number; + NetRevenue: number; + 'nc_epk____Museums_id': number; +} + +// Translation function type +export type TranslateFunction = (key: string) => string; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..50e6c49 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +}