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:
73
package-lock.json
generated
73
package-lock.json
generated
@@ -22,6 +22,13 @@
|
|||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"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": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -3615,6 +3622,13 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/html-minifier-terser": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
"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==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
||||||
@@ -6341,6 +6398,13 @@
|
|||||||
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
|
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -16336,17 +16400,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "4.9.5",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.2.0"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
|
|||||||
@@ -42,5 +42,12 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
import Comparison from './components/Comparison';
|
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 './App.css';
|
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 location = useLocation();
|
||||||
const isActive = location.pathname === to;
|
const isActive = location.pathname === to;
|
||||||
return (
|
return (
|
||||||
<Link to={to} className={`nav-link ${isActive ? 'active' : ''}`}>
|
<Link to={to} className={`nav-link ${isActive ? 'active' : ''} ${className || ''}`}>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DataSource {
|
||||||
|
id: string;
|
||||||
|
labelKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, dir, switchLanguage } = useLanguage();
|
const { t, dir, switchLanguage } = useLanguage();
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState<MuseumRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isOffline, setIsOffline] = useState(false);
|
const [isOffline, setIsOffline] = useState<boolean>(false);
|
||||||
const [cacheInfo, setCacheInfo] = useState(null);
|
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
||||||
const [showDataLabels, setShowDataLabels] = useState(false);
|
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
||||||
const [includeVAT, setIncludeVAT] = useState(true);
|
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||||
const [dataSource, setDataSource] = useState('museums');
|
const [dataSource, setDataSource] = useState<string>('museums');
|
||||||
|
|
||||||
const dataSources = [
|
const dataSources: DataSource[] = [
|
||||||
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
|
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
|
||||||
{ id: 'coffees', labelKey: 'dataSources.coffees', enabled: false },
|
{ id: 'coffees', labelKey: 'dataSources.coffees', enabled: false },
|
||||||
{ id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
|
{ id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
const loadData = useCallback(async (forceRefresh = false) => {
|
const loadData = useCallback(async (forceRefresh: boolean = false) => {
|
||||||
try {
|
try {
|
||||||
setLoading(!forceRefresh);
|
setLoading(!forceRefresh);
|
||||||
setRefreshing(forceRefresh);
|
setRefreshing(forceRefresh);
|
||||||
@@ -49,7 +62,7 @@ function App() {
|
|||||||
const status = getCacheStatus();
|
const status = getCacheStatus();
|
||||||
setCacheInfo(status);
|
setCacheInfo(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError((err as Error).message);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -59,6 +72,7 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
@@ -132,7 +146,7 @@ function App() {
|
|||||||
{t('nav.comparison')}
|
{t('nav.comparison')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp).toLocaleString()}` : ''}>
|
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||||
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef, useState, ReactNode } from 'react';
|
||||||
import JSZip from 'jszip';
|
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
|
// Wrapper component that adds PNG export to any chart
|
||||||
export function ExportableChart({ children, filename = 'chart', title = '', className = '', controls = null }) {
|
export function ExportableChart({
|
||||||
const chartRef = useRef(null);
|
children,
|
||||||
|
filename = 'chart',
|
||||||
|
title = '',
|
||||||
|
className = '',
|
||||||
|
controls = null
|
||||||
|
}: ExportableChartProps) {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const exportAsPNG = () => {
|
const exportAsPNG = () => {
|
||||||
const chartContainer = chartRef.current;
|
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
|
// Create a new canvas with white background and title
|
||||||
const exportCanvas = document.createElement('canvas');
|
const exportCanvas = document.createElement('canvas');
|
||||||
const ctx = exportCanvas.getContext('2d');
|
const ctx = exportCanvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
// Set dimensions with padding and title space
|
// Set dimensions with padding and title space
|
||||||
const padding = 24;
|
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
|
// 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<void> {
|
||||||
const container = document.querySelector(containerSelector);
|
const container = document.querySelector(containerSelector);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -93,6 +108,7 @@ export async function exportAllCharts(containerSelector, zipFilename = 'charts')
|
|||||||
// Create export canvas with white background and title
|
// Create export canvas with white background and title
|
||||||
const exportCanvas = document.createElement('canvas');
|
const exportCanvas = document.createElement('canvas');
|
||||||
const ctx = exportCanvas.getContext('2d');
|
const ctx = exportCanvas.getContext('2d');
|
||||||
|
if (!ctx) continue;
|
||||||
|
|
||||||
const padding = 32;
|
const padding = 32;
|
||||||
const titleHeight = 56;
|
const titleHeight = 56;
|
||||||
@@ -129,9 +145,16 @@ export async function exportAllCharts(containerSelector, zipFilename = 'charts')
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExportAllButtonProps {
|
||||||
|
containerSelector: string;
|
||||||
|
zipFilename?: string;
|
||||||
|
label: string;
|
||||||
|
loadingLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Button component for exporting all charts
|
// Button component for exporting all charts
|
||||||
export function ExportAllButton({ containerSelector, zipFilename, label, loadingLabel }) {
|
export function ExportAllButton({ containerSelector, zipFilename = 'charts', label, loadingLabel }: ExportAllButtonProps) {
|
||||||
const [exporting, setExporting] = React.useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
@@ -46,11 +46,13 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
|
|
||||||
// Get available years from data
|
// Get available years from data
|
||||||
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
||||||
const availableYears = useMemo(() => {
|
const availableYears = useMemo((): number[] => {
|
||||||
const years = [...new Set(data.map(r => {
|
const yearsSet = new Set<number>();
|
||||||
const d = r.date || r.Date;
|
data.forEach(r => {
|
||||||
return d ? new Date(d).getFullYear() : null;
|
const d = r.date || (r as any).Date;
|
||||||
}).filter(Boolean))].sort((a, b) => b - a);
|
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()];
|
return years.length ? years : [new Date().getFullYear()];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -267,7 +269,17 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const pilgrimCounts = quarterData?.pilgrims || null;
|
const pilgrimCounts = quarterData?.pilgrims || null;
|
||||||
|
|
||||||
// Build cards array dynamically
|
// 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 revenueChange = calcChange(prevMetrics.revenue, currMetrics.revenue);
|
||||||
const visitorsChange = calcChange(prevMetrics.visitors, currMetrics.visitors);
|
const visitorsChange = calcChange(prevMetrics.visitors, currMetrics.visitors);
|
||||||
const ticketsChange = calcChange(prevMetrics.tickets, currMetrics.tickets);
|
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 pilgrimsChange = pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null;
|
||||||
const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.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.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.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
|
||||||
{ title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
|
{ 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 => {
|
periodData.forEach(row => {
|
||||||
if (!row.date) return;
|
if (!row.date) return;
|
||||||
const rowDate = new Date(row.date);
|
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;
|
let key;
|
||||||
if (granularity === 'month') {
|
if (granularity === 'month') {
|
||||||
@@ -413,9 +425,9 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const museumChart = useMemo(() => {
|
const museumChart = useMemo(() => {
|
||||||
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
||||||
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
||||||
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean);
|
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
||||||
const prevByMuseum = {};
|
const prevByMuseum: Record<string, number> = {};
|
||||||
const currByMuseum = {};
|
const currByMuseum: Record<string, number> = {};
|
||||||
allMuseums.forEach(m => {
|
allMuseums.forEach(m => {
|
||||||
const prevRows = prevData.filter(r => r.museum_name === m);
|
const prevRows = prevData.filter(r => r.museum_name === m);
|
||||||
const currRows = currData.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]);
|
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
const chartOptions = {
|
const chartOptions: any = {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
plugins: {
|
plugins: {
|
||||||
...baseOptions.plugins,
|
...baseOptions.plugins,
|
||||||
@@ -125,11 +125,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Daily granularity
|
// Daily granularity
|
||||||
const dailyData = {};
|
const dailyData: Record<string, number> = {};
|
||||||
filteredData.forEach(row => {
|
filteredData.forEach(row => {
|
||||||
const date = row.date;
|
const date = row.date;
|
||||||
if (!dailyData[date]) dailyData[date] = 0;
|
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();
|
const days = Object.keys(dailyData).sort();
|
||||||
return {
|
return {
|
||||||
@@ -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 s = new Date(start);
|
||||||
const e = new Date(end);
|
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)}`;
|
return `${s.toLocaleDateString('en-US', opts)} – ${e.toLocaleDateString('en-US', opts)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export const chartColors = {
|
|||||||
grid: '#f1f5f9'
|
grid: '#f1f5f9'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDataLabelConfig = (showDataLabels) => ({
|
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
font: { size: 10, weight: 600 },
|
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,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
locale: 'en-US', // Force LTR number formatting
|
locale: 'en-US', // Force LTR number formatting
|
||||||
@@ -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 en from '../locales/en.json';
|
||||||
import ar from '../locales/ar.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 translations: Record<LanguageCode, Translations> = { en, ar };
|
||||||
const [lang, setLang] = useState(() => {
|
|
||||||
|
interface LanguageContextType {
|
||||||
|
lang: LanguageCode;
|
||||||
|
dir: Direction;
|
||||||
|
t: (key: string, fallback?: string) => string;
|
||||||
|
switchLanguage: () => void;
|
||||||
|
setLanguage: (lang: LanguageCode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageContext = createContext<LanguageContextType | null>(null);
|
||||||
|
|
||||||
|
interface LanguageProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageProvider({ children }: LanguageProviderProps) {
|
||||||
|
const [lang, setLang] = useState<LanguageCode>(() => {
|
||||||
// Check localStorage first, then browser preference
|
// 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;
|
if (saved && translations[saved]) return saved;
|
||||||
|
|
||||||
// Check browser language
|
// Check browser language
|
||||||
@@ -18,7 +37,7 @@ export function LanguageProvider({ children }) {
|
|||||||
return 'en';
|
return 'en';
|
||||||
});
|
});
|
||||||
|
|
||||||
const dir = lang === 'ar' ? 'rtl' : 'ltr';
|
const dir: Direction = lang === 'ar' ? 'rtl' : 'ltr';
|
||||||
|
|
||||||
// Apply direction to document
|
// Apply direction to document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,9 +47,9 @@ export function LanguageProvider({ children }) {
|
|||||||
}, [lang, dir]);
|
}, [lang, dir]);
|
||||||
|
|
||||||
// Translation function with dot notation support
|
// Translation function with dot notation support
|
||||||
const t = useCallback((key, fallback) => {
|
const t = useCallback((key: string, fallback?: string): string => {
|
||||||
const keys = key.split('.');
|
const keys = key.split('.');
|
||||||
let value = translations[lang];
|
let value: Translations | string = translations[lang];
|
||||||
|
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
if (value && typeof value === 'object' && k in value) {
|
if (value && typeof value === 'object' && k in value) {
|
||||||
@@ -58,7 +77,7 @@ export function LanguageProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Set specific language
|
// Set specific language
|
||||||
const setLanguage = useCallback((newLang) => {
|
const setLanguage = useCallback((newLang: LanguageCode) => {
|
||||||
if (translations[newLang]) {
|
if (translations[newLang]) {
|
||||||
setLang(newLang);
|
setLang(newLang);
|
||||||
}
|
}
|
||||||
@@ -71,7 +90,7 @@ export function LanguageProvider({ children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLanguage() {
|
export function useLanguage(): LanguageContextType {
|
||||||
const context = useContext(LanguageContext);
|
const context = useContext(LanguageContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||||
15
src/react-chartjs-2.d.ts
vendored
Normal file
15
src/react-chartjs-2.d.ts
vendored
Normal file
@@ -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<any>;
|
||||||
|
export const Bar: ComponentType<any>;
|
||||||
|
export const Doughnut: ComponentType<any>;
|
||||||
|
export const Pie: ComponentType<any>;
|
||||||
|
export const Radar: ComponentType<any>;
|
||||||
|
export const PolarArea: ComponentType<any>;
|
||||||
|
export const Bubble: ComponentType<any>;
|
||||||
|
export const Scatter: ComponentType<any>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
// Data source: NocoDB only
|
// Data source: NocoDB only
|
||||||
// Offline mode: caches data to localStorage for resilience
|
// 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_URL = process.env.REACT_APP_NOCODB_URL || '';
|
||||||
const NOCODB_TOKEN = process.env.REACT_APP_NOCODB_TOKEN || '';
|
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_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
|
||||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
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 },
|
2024: { 1: 11574494, 2: 10521465, 3: 3364627, 4: 7435625 },
|
||||||
2025: { 1: 15222497, 2: 5443393, 3: null, 4: null }
|
2025: { 1: 15222497, 2: 5443393, 3: null, 4: null }
|
||||||
};
|
};
|
||||||
@@ -25,43 +41,37 @@ export const umrahData = {
|
|||||||
// Offline Cache Functions
|
// Offline Cache Functions
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
function saveToCache(data) {
|
function saveToCache(data: MuseumRecord[]): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
||||||
localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
|
localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
|
||||||
console.log(`Cached ${data.length} rows to localStorage`);
|
console.log(`Cached ${data.length} rows to localStorage`);
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||||
|
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
|
|
||||||
const data = JSON.parse(cached);
|
const data: MuseumRecord[] = JSON.parse(cached);
|
||||||
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
||||||
const isStale = age > CACHE_MAX_AGE_MS;
|
const isStale = age > CACHE_MAX_AGE_MS;
|
||||||
|
|
||||||
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
|
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) {
|
} catch (err) {
|
||||||
console.warn('Failed to load from cache:', err.message);
|
console.warn('Failed to load from cache:', (err as Error).message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCacheAge() {
|
export function getCacheStatus(): CacheStatus {
|
||||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
|
||||||
if (!timestamp) return null;
|
|
||||||
return Date.now() - parseInt(timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCacheStatus() {
|
|
||||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
|
||||||
@@ -70,7 +80,7 @@ export function getCacheStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ts = parseInt(timestamp);
|
const ts = parseInt(timestamp);
|
||||||
const data = JSON.parse(cached);
|
const data: MuseumRecord[] = JSON.parse(cached);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: true,
|
available: true,
|
||||||
@@ -81,7 +91,7 @@ export function getCacheStatus() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearCache() {
|
export function clearCache(): void {
|
||||||
localStorage.removeItem(CACHE_KEY);
|
localStorage.removeItem(CACHE_KEY);
|
||||||
localStorage.removeItem(CACHE_TIMESTAMP_KEY);
|
localStorage.removeItem(CACHE_TIMESTAMP_KEY);
|
||||||
console.log('Cache cleared');
|
console.log('Cache cleared');
|
||||||
@@ -91,8 +101,8 @@ export function clearCache() {
|
|||||||
// NocoDB Data Fetching
|
// NocoDB Data Fetching
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
async function fetchNocoDBTable(tableId, limit = 1000) {
|
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
|
||||||
let allRecords = [];
|
let allRecords: T[] = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -104,7 +114,7 @@ async function fetchNocoDBTable(tableId, limit = 1000) {
|
|||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const records = json.list || [];
|
const records: T[] = json.list || [];
|
||||||
allRecords = allRecords.concat(records);
|
allRecords = allRecords.concat(records);
|
||||||
|
|
||||||
if (records.length < limit) break;
|
if (records.length < limit) break;
|
||||||
@@ -114,21 +124,27 @@ async function fetchNocoDBTable(tableId, limit = 1000) {
|
|||||||
return allRecords;
|
return allRecords;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromNocoDB() {
|
interface MuseumMapEntry {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
district: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
||||||
console.log('Fetching from NocoDB...');
|
console.log('Fetching from NocoDB...');
|
||||||
|
|
||||||
// Fetch all three tables in parallel
|
// Fetch all three tables in parallel
|
||||||
const [districts, museums, dailyStats] = await Promise.all([
|
const [districts, museums, dailyStats] = await Promise.all([
|
||||||
fetchNocoDBTable(NOCODB_TABLES.districts),
|
fetchNocoDBTable<NocoDBDistrict>(NOCODB_TABLES.districts),
|
||||||
fetchNocoDBTable(NOCODB_TABLES.museums),
|
fetchNocoDBTable<NocoDBMuseum>(NOCODB_TABLES.museums),
|
||||||
fetchNocoDBTable(NOCODB_TABLES.dailyStats)
|
fetchNocoDBTable<NocoDBDailyStat>(NOCODB_TABLES.dailyStats)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build lookup maps
|
// Build lookup maps
|
||||||
const districtMap = {};
|
const districtMap: Record<number, string> = {};
|
||||||
districts.forEach(d => { districtMap[d.Id] = d.Name; });
|
districts.forEach(d => { districtMap[d.Id] = d.Name; });
|
||||||
|
|
||||||
const museumMap = {};
|
const museumMap: Record<number, MuseumMapEntry> = {};
|
||||||
museums.forEach(m => {
|
museums.forEach(m => {
|
||||||
museumMap[m.Id] = {
|
museumMap[m.Id] = {
|
||||||
code: m.Code,
|
code: m.Code,
|
||||||
@@ -138,8 +154,8 @@ async function fetchFromNocoDB() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Join data into flat structure
|
// Join data into flat structure
|
||||||
const data = dailyStats.map(row => {
|
const data: MuseumRecord[] = dailyStats.map(row => {
|
||||||
const museum = museumMap[row['nc_epk____Museums_id']] || {};
|
const museum = museumMap[row['nc_epk____Museums_id']] || { code: '', name: '', district: '' };
|
||||||
const date = row.Date;
|
const date = row.Date;
|
||||||
const year = date ? date.substring(0, 4) : '';
|
const year = date ? date.substring(0, 4) : '';
|
||||||
const month = date ? parseInt(date.substring(5, 7)) : 0;
|
const month = date ? parseInt(date.substring(5, 7)) : 0;
|
||||||
@@ -172,7 +188,7 @@ async function fetchFromNocoDB() {
|
|||||||
// Main Data Fetcher (with offline fallback)
|
// Main Data Fetcher (with offline fallback)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export async function fetchData() {
|
export async function fetchData(): Promise<FetchResult> {
|
||||||
// Check if NocoDB is configured
|
// Check if NocoDB is configured
|
||||||
if (!NOCODB_URL || !NOCODB_TOKEN) {
|
if (!NOCODB_URL || !NOCODB_TOKEN) {
|
||||||
// Try cache
|
// Try cache
|
||||||
@@ -193,7 +209,7 @@ export async function fetchData() {
|
|||||||
|
|
||||||
return { data, fromCache: false };
|
return { data, fromCache: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('NocoDB fetch failed:', err.message);
|
console.error('NocoDB fetch failed:', (err as Error).message);
|
||||||
|
|
||||||
// Try to load from cache
|
// Try to load from cache
|
||||||
const cached = loadFromCache();
|
const cached = loadFromCache();
|
||||||
@@ -202,12 +218,12 @@ export async function fetchData() {
|
|||||||
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.message}`);
|
throw new Error(`Database unavailable and no cached data: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force refresh (bypass cache read, but still write to cache)
|
// 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) {
|
if (!NOCODB_URL || !NOCODB_TOKEN) {
|
||||||
throw new Error('NocoDB not configured');
|
throw new Error('NocoDB not configured');
|
||||||
}
|
}
|
||||||
@@ -221,7 +237,7 @@ export async function refreshData() {
|
|||||||
// Data Filtering & Metrics
|
// Data Filtering & Metrics
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export function filterData(data, filters) {
|
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
|
||||||
return data.filter(row => {
|
return data.filter(row => {
|
||||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
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.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 => {
|
return data.filter(row => {
|
||||||
if (!row.date) return false;
|
if (!row.date) return false;
|
||||||
if (row.date < startDate || row.date > endDate) 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 revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const revenue = data.reduce((sum, row) => sum + parseFloat(row[revenueField] || row.revenue_incl_tax || 0), 0);
|
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || row.revenue_incl_tax || 0), 0);
|
||||||
const visitors = data.reduce((sum, row) => sum + parseInt(row.visits || 0), 0);
|
const visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0);
|
||||||
const tickets = data.reduce((sum, row) => sum + parseInt(row.tickets || 0), 0);
|
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
|
||||||
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
||||||
return { revenue, visitors, tickets, avgRevPerVisitor };
|
return { revenue, visitors, tickets, avgRevPerVisitor };
|
||||||
}
|
}
|
||||||
@@ -254,7 +275,7 @@ export function calculateMetrics(data, includeVAT = true) {
|
|||||||
// Formatting Functions
|
// Formatting Functions
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export function formatCurrency(num) {
|
export function formatCurrency(num: number): string {
|
||||||
if (isNaN(num)) return 'SAR 0';
|
if (isNaN(num)) return 'SAR 0';
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -263,12 +284,12 @@ export function formatCurrency(num) {
|
|||||||
}).format(num);
|
}).format(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNumber(num) {
|
export function formatNumber(num: number): string {
|
||||||
if (isNaN(num)) return '0';
|
if (isNaN(num)) return '0';
|
||||||
return new Intl.NumberFormat('en-US').format(Math.round(num));
|
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';
|
if (isNaN(num)) return '0';
|
||||||
const absNum = Math.abs(num);
|
const absNum = Math.abs(num);
|
||||||
if (absNum >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
if (absNum >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||||
@@ -276,7 +297,7 @@ export function formatCompact(num) {
|
|||||||
return formatNumber(num);
|
return formatNumber(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCompactCurrency(num) {
|
export function formatCompactCurrency(num: number): string {
|
||||||
if (isNaN(num)) return 'SAR 0';
|
if (isNaN(num)) return 'SAR 0';
|
||||||
const absNum = Math.abs(num);
|
const absNum = Math.abs(num);
|
||||||
if (absNum >= 1000000) return 'SAR ' + (num / 1000000).toFixed(1) + 'M';
|
if (absNum >= 1000000) return 'SAR ' + (num / 1000000).toFixed(1) + 'M';
|
||||||
@@ -288,7 +309,7 @@ export function formatCompactCurrency(num) {
|
|||||||
// Grouping Functions
|
// 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;
|
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
||||||
|
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
@@ -304,43 +325,43 @@ export function getWeekStart(dateStr) {
|
|||||||
return `${y}-${m}-${d}`;
|
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 revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const grouped = {};
|
const grouped: Record<string, GroupedData> = {};
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.date) return;
|
if (!row.date) return;
|
||||||
const weekStart = getWeekStart(row.date);
|
const weekStart = getWeekStart(row.date);
|
||||||
if (!weekStart) return;
|
if (!weekStart) return;
|
||||||
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
|
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||||
grouped[weekStart].revenue += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
|
grouped[weekStart].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
||||||
grouped[weekStart].visitors += parseInt(row.visits || 0);
|
grouped[weekStart].visitors += row.visits || 0;
|
||||||
grouped[weekStart].tickets += parseInt(row.tickets || 0);
|
grouped[weekStart].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
return grouped;
|
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 revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const grouped = {};
|
const grouped: Record<string, GroupedData> = {};
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.museum_name) return;
|
if (!row.museum_name) return;
|
||||||
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
|
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].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
||||||
grouped[row.museum_name].visitors += parseInt(row.visits || 0);
|
grouped[row.museum_name].visitors += row.visits || 0;
|
||||||
grouped[row.museum_name].tickets += parseInt(row.tickets || 0);
|
grouped[row.museum_name].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
return grouped;
|
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 revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const grouped = {};
|
const grouped: Record<string, GroupedData> = {};
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.district) return;
|
if (!row.district) return;
|
||||||
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
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].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
||||||
grouped[row.district].visitors += parseInt(row.visits || 0);
|
grouped[row.district].visitors += row.visits || 0;
|
||||||
grouped[row.district].tickets += parseInt(row.tickets || 0);
|
grouped[row.district].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
@@ -349,36 +370,37 @@ export function groupByDistrict(data, includeVAT = true) {
|
|||||||
// Data Extraction Helpers
|
// Data Extraction Helpers
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export function getUniqueYears(data) {
|
export function getUniqueYears(data: MuseumRecord[]): string[] {
|
||||||
const years = [...new Set(data.map(r => r.year).filter(Boolean))];
|
const years = [...new Set(data.map(r => r.year).filter(Boolean))];
|
||||||
return years.sort((a, b) => parseInt(a) - parseInt(b));
|
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();
|
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDistrictMuseumMap(data) {
|
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
|
||||||
const map = {};
|
const map: Record<string, Set<string>> = {};
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.district || !row.museum_name) return;
|
if (!row.district || !row.museum_name) return;
|
||||||
if (!map[row.district]) map[row.district] = new Set();
|
if (!map[row.district]) map[row.district] = new Set();
|
||||||
map[row.district].add(row.museum_name);
|
map[row.district].add(row.museum_name);
|
||||||
});
|
});
|
||||||
|
const result: DistrictMuseumMap = {};
|
||||||
Object.keys(map).forEach(d => {
|
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') {
|
if (district === 'all') {
|
||||||
return Object.values(districtMuseumMap).flat().sort();
|
return Object.values(districtMuseumMap).flat().sort();
|
||||||
}
|
}
|
||||||
return districtMuseumMap[district] || [];
|
return districtMuseumMap[district] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLatestYear(data) {
|
export function getLatestYear(data: MuseumRecord[]): string {
|
||||||
const years = getUniqueYears(data);
|
const years = getUniqueYears(data);
|
||||||
return years.length > 0 ? years[years.length - 1] : '2025';
|
return years.length > 0 ? years[years.length - 1] : '2025';
|
||||||
}
|
}
|
||||||
166
src/types/index.ts
Normal file
166
src/types/index.ts
Normal file
@@ -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;
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user