feat(dashboard): add data labels toggle, dual-axis capture rate chart, mobile bottom nav
- Global data labels toggle in header (works on Dashboard & Comparison pages) - Labels show formatted values (K/M suffix, max 2 decimals) with white pill background - Capture Rate chart now shows pilgrims as curved line on right Y-axis - Revenue Trends toggle moved to top-right corner of chart container - Mobile: bottom navigation bar with Dashboard, Compare, Labels toggle - Mobile: top nav simplified to brand only, bottom nav is thumb-friendly
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
70
README.md
Normal file
70
README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
17398
package-lock.json
generated
Normal file
17398
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "hihala-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "http://localhost:8090",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
public/index.html
Normal file
43
public/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>HiHala Museums Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
1176
src/App.css
Normal file
1176
src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
137
src/App.js
Normal file
137
src/App.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect } 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 { fetchSheetData } from './services/dataService';
|
||||
import './App.css';
|
||||
|
||||
function NavLink({ to, children }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link to={to} className={`nav-link ${isActive ? 'active' : ''}`}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await fetchSheetData();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h2>Unable to load data</h2>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
||||
<button onClick={() => window.location.reload()}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<nav className="nav-bar">
|
||||
<div className="nav-brand">Hi<span>Hala</span> Museums</div>
|
||||
<div className="nav-links">
|
||||
<NavLink to="/">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/comparison">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
<polyline points="18 14 22 10 18 6"/>
|
||||
<polyline points="6 10 2 14 6 18"/>
|
||||
</svg>
|
||||
Comparison
|
||||
</NavLink>
|
||||
<button
|
||||
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
title="Show values on charts"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
{showDataLabels ? 'Labels On' : 'Labels Off'}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} />} />
|
||||
</Routes>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<nav className="mobile-nav">
|
||||
<NavLink to="/" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</NavLink>
|
||||
<NavLink to="/comparison" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>Compare</span>
|
||||
</NavLink>
|
||||
<button
|
||||
className={`mobile-nav-item ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
<span>Labels</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
712
src/components/Comparison.js
Normal file
712
src/components/Comparison.js
Normal file
@@ -0,0 +1,712 @@
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Line, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import {
|
||||
filterDataByDateRange,
|
||||
calculateMetrics,
|
||||
formatCurrency,
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
umrahData,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict,
|
||||
getLatestYear
|
||||
} from '../services/dataService';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale, LinearScale, PointElement, LineElement,
|
||||
BarElement, Title, Tooltip, Legend, Filler, ChartDataLabels
|
||||
);
|
||||
|
||||
const chartColors = {
|
||||
primary: '#2563eb',
|
||||
muted: '#94a3b8',
|
||||
grid: '#f1f5f9'
|
||||
};
|
||||
|
||||
// Generate preset dates for a given year
|
||||
const generatePresetDates = (year) => ({
|
||||
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
||||
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
||||
'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
|
||||
'apr': { start: `${year}-04-01`, end: `${year}-04-30` },
|
||||
'may': { start: `${year}-05-01`, end: `${year}-05-31` },
|
||||
'jun': { start: `${year}-06-01`, end: `${year}-06-30` },
|
||||
'jul': { start: `${year}-07-01`, end: `${year}-07-31` },
|
||||
'aug': { start: `${year}-08-01`, end: `${year}-08-31` },
|
||||
'sep': { start: `${year}-09-01`, end: `${year}-09-30` },
|
||||
'oct': { start: `${year}-10-01`, end: `${year}-10-31` },
|
||||
'nov': { start: `${year}-11-01`, end: `${year}-11-30` },
|
||||
'dec': { start: `${year}-12-01`, end: `${year}-12-31` },
|
||||
'q1': { start: `${year}-01-01`, end: `${year}-03-31` },
|
||||
'q2': { start: `${year}-04-01`, end: `${year}-06-30` },
|
||||
'q3': { start: `${year}-07-01`, end: `${year}-09-30` },
|
||||
'q4': { start: `${year}-10-01`, end: `${year}-12-31` },
|
||||
'h1': { start: `${year}-01-01`, end: `${year}-06-30` },
|
||||
'h2': { start: `${year}-07-01`, end: `${year}-12-31` },
|
||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||
});
|
||||
|
||||
function Comparison({ data, showDataLabels }) {
|
||||
// Get latest year from data for default presets
|
||||
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
||||
|
||||
const [preset, setPreset] = useState('jan');
|
||||
const [startDate, setStartDate] = useState(`${latestYear}-01-01`);
|
||||
const [endDate, setEndDate] = useState(`${latestYear}-01-31`);
|
||||
const [filters, setFilters] = useState({ district: 'all', museum: 'all' });
|
||||
const [chartMetric, setChartMetric] = useState('revenue');
|
||||
const [chartGranularity, setChartGranularity] = useState('week');
|
||||
const [controlsExpanded, setControlsExpanded] = useState(true);
|
||||
const [activeChart, setActiveChart] = useState(0);
|
||||
const [activeCard, setActiveCard] = useState(0);
|
||||
|
||||
const charts = [
|
||||
{ id: 'timeseries', label: 'Trend' },
|
||||
{ id: 'museum', label: 'By Museum' }
|
||||
];
|
||||
|
||||
// Touch swipe handlers
|
||||
const touchStartChart = useRef(null);
|
||||
const touchStartCard = useRef(null);
|
||||
|
||||
const handleChartTouchStart = (e) => {
|
||||
touchStartChart.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleChartTouchEnd = (e) => {
|
||||
if (!touchStartChart.current) return;
|
||||
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeChart < charts.length - 1) {
|
||||
setActiveChart(activeChart + 1);
|
||||
} else if (diff < 0 && activeChart > 0) {
|
||||
setActiveChart(activeChart - 1);
|
||||
}
|
||||
}
|
||||
touchStartChart.current = null;
|
||||
};
|
||||
|
||||
const granularityOptions = [
|
||||
{ value: 'day', label: 'Daily' },
|
||||
{ value: 'week', label: 'Weekly' }
|
||||
];
|
||||
|
||||
const metricOptions = [
|
||||
{ value: 'revenue', label: 'Revenue', field: 'revenue_incl_tax', format: 'currency' },
|
||||
{ value: 'visitors', label: 'Visitors', field: 'visits', format: 'number' },
|
||||
{ value: 'tickets', label: 'Tickets', field: 'tickets', format: 'number' },
|
||||
{ value: 'avgRevenue', label: 'Avg Rev/Visitor', field: null, format: 'currency' }
|
||||
];
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
if (metric === 'avgRevenue') {
|
||||
const revenue = rows.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
||||
const visitors = rows.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
return visitors > 0 ? revenue / visitors : 0;
|
||||
}
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[metric];
|
||||
return rows.reduce((s, r) => s + parseFloat(r[field] || 0), 0);
|
||||
}, []);
|
||||
|
||||
// Dynamic lists from data
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
|
||||
// Generate presets based on latest year
|
||||
const presetDates = useMemo(() => generatePresetDates(latestYear), [latestYear]);
|
||||
|
||||
const handlePresetChange = (newPreset) => {
|
||||
setPreset(newPreset);
|
||||
if (newPreset !== 'custom' && presetDates[newPreset]) {
|
||||
setStartDate(presetDates[newPreset].start);
|
||||
setEndDate(presetDates[newPreset].end);
|
||||
}
|
||||
};
|
||||
|
||||
// Year-over-year comparison: same dates, previous year
|
||||
const ranges = useMemo(() => ({
|
||||
curr: { start: startDate, end: endDate },
|
||||
prev: {
|
||||
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1),
|
||||
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1)
|
||||
}
|
||||
}), [startDate, endDate]);
|
||||
|
||||
const prevData = useMemo(() =>
|
||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
||||
[data, ranges.prev, filters]
|
||||
);
|
||||
|
||||
const currData = useMemo(() =>
|
||||
filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters),
|
||||
[data, ranges.curr, filters]
|
||||
);
|
||||
|
||||
const prevMetrics = useMemo(() => calculateMetrics(prevData), [prevData]);
|
||||
const currMetrics = useMemo(() => calculateMetrics(currData), [currData]);
|
||||
|
||||
const calcChange = (prev, curr) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
|
||||
// Get quarter from date range (returns null if not a clean quarter)
|
||||
const getQuarterFromRange = (start, end) => {
|
||||
const quarterRanges = {
|
||||
1: { start: '-01-01', end: '-03-31' },
|
||||
2: { start: '-04-01', end: '-06-30' },
|
||||
3: { start: '-07-01', end: '-09-30' },
|
||||
4: { start: '-10-01', end: '-12-31' }
|
||||
};
|
||||
for (let q = 1; q <= 4; q++) {
|
||||
if (start.endsWith(quarterRanges[q].start) && end.endsWith(quarterRanges[q].end)) {
|
||||
return q;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Calculate capture rate and pilgrim data for quarters
|
||||
const quarterData = useMemo(() => {
|
||||
const prevYear = parseInt(ranges.prev.start.substring(0, 4));
|
||||
const currYear = parseInt(ranges.curr.start.substring(0, 4));
|
||||
const prevQ = getQuarterFromRange(ranges.prev.start, ranges.prev.end);
|
||||
const currQ = getQuarterFromRange(ranges.curr.start, ranges.curr.end);
|
||||
|
||||
if (!prevQ || !currQ) return null; // Only show for quarter comparisons
|
||||
|
||||
const prevPilgrims = umrahData[prevYear]?.[prevQ];
|
||||
const currPilgrims = umrahData[currYear]?.[currQ];
|
||||
|
||||
if (!prevPilgrims && !currPilgrims) return null;
|
||||
|
||||
const prevRate = prevPilgrims ? (prevMetrics.visitors / prevPilgrims * 100) : null;
|
||||
const currRate = currPilgrims ? (currMetrics.visitors / currPilgrims * 100) : null;
|
||||
|
||||
return {
|
||||
pilgrims: { prev: prevPilgrims, curr: currPilgrims },
|
||||
captureRate: { prev: prevRate, curr: currRate }
|
||||
};
|
||||
}, [ranges, prevMetrics.visitors, currMetrics.visitors]);
|
||||
|
||||
const captureRates = quarterData?.captureRate || null;
|
||||
const pilgrimCounts = quarterData?.pilgrims || null;
|
||||
|
||||
const changes = {
|
||||
revenue: calcChange(prevMetrics.revenue, currMetrics.revenue),
|
||||
visitors: calcChange(prevMetrics.visitors, currMetrics.visitors),
|
||||
tickets: calcChange(prevMetrics.tickets, currMetrics.tickets),
|
||||
avgRev: calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor),
|
||||
pilgrims: pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null,
|
||||
captureRate: captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null
|
||||
};
|
||||
|
||||
// Build cards array dynamically
|
||||
const metricCards = useMemo(() => {
|
||||
const cards = [
|
||||
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: changes.revenue, isCurrency: true },
|
||||
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: changes.visitors },
|
||||
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: changes.tickets },
|
||||
{ title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: changes.avgRev, isCurrency: true }
|
||||
];
|
||||
if (pilgrimCounts) {
|
||||
cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: changes.pilgrims, pendingMessage: 'Data not published yet' });
|
||||
}
|
||||
if (captureRates) {
|
||||
cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: changes.captureRate, isPercent: true, pendingMessage: 'Data not published yet' });
|
||||
}
|
||||
return cards;
|
||||
}, [prevMetrics, currMetrics, changes, pilgrimCounts, captureRates]);
|
||||
|
||||
const handleCardTouchStart = (e) => {
|
||||
touchStartCard.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleCardTouchEnd = (e) => {
|
||||
if (!touchStartCard.current) return;
|
||||
const diff = touchStartCard.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeCard < metricCards.length - 1) {
|
||||
setActiveCard(activeCard + 1);
|
||||
} else if (diff < 0 && activeCard > 0) {
|
||||
setActiveCard(activeCard - 1);
|
||||
}
|
||||
}
|
||||
touchStartCard.current = null;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const d = new Date(year, month - 1, day);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Time series chart (daily or weekly)
|
||||
const timeSeriesChart = useMemo(() => {
|
||||
const groupByPeriod = (periodData, periodStart, metric, granularity) => {
|
||||
const start = new Date(periodStart);
|
||||
const groupedRows = {};
|
||||
|
||||
periodData.forEach(row => {
|
||||
if (!row.date) return;
|
||||
const rowDate = new Date(row.date);
|
||||
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let key;
|
||||
if (granularity === 'week') {
|
||||
key = Math.floor(daysDiff / 7) + 1;
|
||||
} else {
|
||||
key = daysDiff + 1; // day number from start
|
||||
}
|
||||
|
||||
if (!groupedRows[key]) groupedRows[key] = [];
|
||||
groupedRows[key].push(row);
|
||||
});
|
||||
|
||||
const result = {};
|
||||
Object.keys(groupedRows).forEach(key => {
|
||||
result[key] = getMetricValue(groupedRows[key], metric);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const prevGrouped = groupByPeriod(prevData, ranges.prev.start, chartMetric, chartGranularity);
|
||||
const currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity);
|
||||
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
|
||||
|
||||
const labels = Array.from({ length: maxKey }, (_, i) =>
|
||||
chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}`
|
||||
);
|
||||
|
||||
const prevYear = ranges.prev.start.substring(0, 4);
|
||||
const currYear = ranges.curr.start.substring(0, 4);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: prevYear,
|
||||
data: labels.map((_, i) => prevGrouped[i + 1] || 0),
|
||||
borderColor: chartColors.muted,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointRadius: chartGranularity === 'week' ? 3 : 1,
|
||||
pointBackgroundColor: chartColors.muted
|
||||
},
|
||||
{
|
||||
label: currYear,
|
||||
data: labels.map((_, i) => currGrouped[i + 1] || 0),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: chartGranularity === 'week' ? 4 : 2,
|
||||
pointBackgroundColor: chartColors.primary
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [prevData, currData, ranges, chartMetric, chartGranularity, getMetricValue]);
|
||||
|
||||
// Museum chart - only show museums with data
|
||||
const museumChart = useMemo(() => {
|
||||
const prevYear = ranges.prev.start.substring(0, 4);
|
||||
const currYear = ranges.curr.start.substring(0, 4);
|
||||
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean);
|
||||
const prevByMuseum = {};
|
||||
const currByMuseum = {};
|
||||
allMuseums.forEach(m => {
|
||||
const prevRows = prevData.filter(r => r.museum_name === m);
|
||||
const currRows = currData.filter(r => r.museum_name === m);
|
||||
prevByMuseum[m] = getMetricValue(prevRows, chartMetric);
|
||||
currByMuseum[m] = getMetricValue(currRows, chartMetric);
|
||||
});
|
||||
// Only include museums that have data in either period
|
||||
const museums = allMuseums.filter(m => prevByMuseum[m] > 0 || currByMuseum[m] > 0);
|
||||
return {
|
||||
labels: museums,
|
||||
datasets: [
|
||||
{ label: prevYear, data: museums.map(m => prevByMuseum[m]), backgroundColor: chartColors.muted, borderRadius: 4 },
|
||||
{ label: currYear, data: museums.map(m => currByMuseum[m]), backgroundColor: chartColors.primary, borderRadius: 4 }
|
||||
]
|
||||
};
|
||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue]);
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
|
||||
tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8 },
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
offset: 4,
|
||||
padding: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
formatter: (value) => {
|
||||
if (value == null) return '';
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
||||
if (value < 100 && value > 0) return value.toFixed(2);
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
|
||||
y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="comparison">
|
||||
<div className="page-title">
|
||||
<h1>Period Comparison</h1>
|
||||
<p>Year-over-year analysis — same period, different years</p>
|
||||
</div>
|
||||
|
||||
<div className={`controls ${controlsExpanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div className="controls-header" onClick={() => setControlsExpanded(!controlsExpanded)}>
|
||||
<h3>Select Period</h3>
|
||||
<button className="controls-toggle">
|
||||
{controlsExpanded ? '▲ Hide' : '▼ Show'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="controls-body">
|
||||
<div className="control-row">
|
||||
<div className="control-group">
|
||||
<label>Preset</label>
|
||||
<select value={preset} onChange={e => handlePresetChange(e.target.value)}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="jan">January</option>
|
||||
<option value="feb">February</option>
|
||||
<option value="mar">March</option>
|
||||
<option value="apr">April</option>
|
||||
<option value="may">May</option>
|
||||
<option value="jun">June</option>
|
||||
<option value="jul">July</option>
|
||||
<option value="aug">August</option>
|
||||
<option value="sep">September</option>
|
||||
<option value="oct">October</option>
|
||||
<option value="nov">November</option>
|
||||
<option value="dec">December</option>
|
||||
<option value="q1">Q1</option>
|
||||
<option value="q2">Q2</option>
|
||||
<option value="q3">Q3</option>
|
||||
<option value="q4">Q4</option>
|
||||
<option value="h1">H1</option>
|
||||
<option value="h2">H2</option>
|
||||
<option value="full">Full Year</option>
|
||||
</select>
|
||||
</div>
|
||||
{preset === 'custom' && (
|
||||
<>
|
||||
<div className="control-group">
|
||||
<label>From</label>
|
||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>To</label>
|
||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="control-group">
|
||||
<label>District</label>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">All Districts</option>
|
||||
{districts.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Museum</label>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">All Museums</option>
|
||||
{availableMuseums.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="period-display">
|
||||
<div className="period-box">
|
||||
<div className="label">{ranges.prev.start.substring(0, 4)}</div>
|
||||
<div className="dates">{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}</div>
|
||||
</div>
|
||||
<div className="period-box">
|
||||
<div className="label">{ranges.curr.start.substring(0, 4)}</div>
|
||||
<div className="dates">{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Grid layout */}
|
||||
<div className="comparison-grid desktop-only">
|
||||
{metricCards.map((card, i) => (
|
||||
<MetricCard
|
||||
key={i}
|
||||
title={card.title}
|
||||
prev={card.prev}
|
||||
curr={card.curr}
|
||||
change={card.change}
|
||||
isCurrency={card.isCurrency}
|
||||
isPercent={card.isPercent}
|
||||
pendingMessage={card.pendingMessage}
|
||||
prevYear={ranges.prev.start.substring(0, 4)}
|
||||
currYear={ranges.curr.start.substring(0, 4)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Carousel layout */}
|
||||
<div className="cards-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeCard * 100}%)` }}
|
||||
onTouchStart={handleCardTouchStart}
|
||||
onTouchEnd={handleCardTouchEnd}
|
||||
>
|
||||
{metricCards.map((card, i) => (
|
||||
<div className="carousel-slide" key={i}>
|
||||
<MetricCard
|
||||
title={card.title}
|
||||
prev={card.prev}
|
||||
curr={card.curr}
|
||||
change={card.change}
|
||||
isCurrency={card.isCurrency}
|
||||
isPercent={card.isPercent}
|
||||
pendingMessage={card.pendingMessage}
|
||||
prevYear={ranges.prev.start.substring(0, 4)}
|
||||
currYear={ranges.curr.start.substring(0, 4)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-dots labeled">
|
||||
{metricCards.map((card, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`carousel-dot ${activeCard === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveCard(i)}
|
||||
>
|
||||
<span className="dot-label">{card.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Show both charts */}
|
||||
<div className="charts-grid desktop-only">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
||||
<div className="chart-selectors">
|
||||
<div className="toggle-switch">
|
||||
{granularityOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartGranularity === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartGranularity(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={timeSeriesChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
||||
<div className="chart-selectors">
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Carousel */}
|
||||
<div className="charts-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeChart * 100}%)` }}
|
||||
onTouchStart={handleChartTouchStart}
|
||||
onTouchEnd={handleChartTouchEnd}
|
||||
>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
||||
<div className="toggle-switch">
|
||||
{granularityOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartGranularity === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartGranularity(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-selectors-inline">
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={timeSeriesChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
||||
</div>
|
||||
<div className="chart-selectors-inline">
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-dots labeled">
|
||||
{charts.map((chart, i) => (
|
||||
<button
|
||||
key={chart.id}
|
||||
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveChart(i)}
|
||||
>
|
||||
<span className="dot-label">{chart.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }) {
|
||||
const hasPending = prev === null || curr === null;
|
||||
const isPositive = change >= 0;
|
||||
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
|
||||
|
||||
const formatValue = (val) => {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (isPercent) return val.toFixed(2) + '%';
|
||||
if (isCurrency) return formatCompactCurrency(val);
|
||||
return formatCompact(val);
|
||||
};
|
||||
|
||||
const diff = (curr || 0) - (prev || 0);
|
||||
const diffText = (hasPending && pendingMessage) ? pendingMessage : (isPercent
|
||||
? (diff >= 0 ? '+' : '') + diff.toFixed(2) + 'pp'
|
||||
: (isCurrency ? formatCompactCurrency(diff) : formatCompact(diff)));
|
||||
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<h4>{title}</h4>
|
||||
<div className="metric-values">
|
||||
<div className="metric-period previous">
|
||||
<div className="year">{prevYear}</div>
|
||||
<div className="value">{formatValue(prev)}</div>
|
||||
</div>
|
||||
<div className={`metric-change ${hasPending && pendingMessage ? 'pending' : (isPositive ? 'positive' : 'negative')}`}>
|
||||
{hasPending && pendingMessage ? (
|
||||
<div className="pending-msg">{pendingMessage}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pct">{changeText}</div>
|
||||
<div className="abs">{diff >= 0 ? '+' : ''}{diffText}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="metric-period current">
|
||||
<div className="year">{currYear}</div>
|
||||
<div className="value">{formatValue(curr)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Comparison;
|
||||
740
src/components/Dashboard.js
Normal file
740
src/components/Dashboard.js
Normal file
@@ -0,0 +1,740 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import {
|
||||
filterData,
|
||||
calculateMetrics,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
groupByWeek,
|
||||
groupByMuseum,
|
||||
groupByDistrict,
|
||||
umrahData,
|
||||
getUniqueYears,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
} from '../services/dataService';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale, LinearScale, PointElement, LineElement,
|
||||
BarElement, ArcElement, Title, Tooltip, Legend, Filler,
|
||||
ChartDataLabels
|
||||
);
|
||||
|
||||
const chartColors = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#7c3aed',
|
||||
tertiary: '#0891b2',
|
||||
muted: '#cbd5e1',
|
||||
grid: '#f1f5f9'
|
||||
};
|
||||
|
||||
function Dashboard({ data, showDataLabels }) {
|
||||
const [filters, setFilters] = useState({
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
museum: 'all',
|
||||
quarter: 'all'
|
||||
});
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(true);
|
||||
const [activeStatCard, setActiveStatCard] = useState(0);
|
||||
const [activeChart, setActiveChart] = useState(0);
|
||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||
|
||||
// Touch handlers for carousels
|
||||
const touchStartStat = useRef(null);
|
||||
const touchStartChart = useRef(null);
|
||||
|
||||
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
||||
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
|
||||
// Stat cards for carousel
|
||||
const statCards = useMemo(() => [
|
||||
{ title: 'Total Revenue', value: formatCurrency(metrics.revenue), hasYoy: true },
|
||||
{ title: 'Total Visitors', value: formatNumber(metrics.visitors) },
|
||||
{ title: 'Total Tickets', value: formatNumber(metrics.tickets) },
|
||||
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) }
|
||||
], [metrics]);
|
||||
|
||||
const handleStatTouchStart = (e) => { touchStartStat.current = e.touches[0].clientX; };
|
||||
const handleStatTouchEnd = (e) => {
|
||||
if (!touchStartStat.current) return;
|
||||
const diff = touchStartStat.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeStatCard < statCards.length - 1) setActiveStatCard(activeStatCard + 1);
|
||||
else if (diff < 0 && activeStatCard > 0) setActiveStatCard(activeStatCard - 1);
|
||||
}
|
||||
touchStartStat.current = null;
|
||||
};
|
||||
|
||||
// Chart carousel - define charts array
|
||||
const dashboardCharts = useMemo(() => [
|
||||
{ id: 'revenue-trend', label: 'Revenue Trend' },
|
||||
{ id: 'visitors-museum', label: 'Visitors' },
|
||||
{ id: 'revenue-museum', label: 'Revenue' },
|
||||
{ id: 'quarterly-yoy', label: 'Quarterly' },
|
||||
{ id: 'district', label: 'District' },
|
||||
{ id: 'capture-rate', label: 'Capture Rate' }
|
||||
], []);
|
||||
|
||||
const handleChartTouchStart = (e) => { touchStartChart.current = e.touches[0].clientX; };
|
||||
const handleChartTouchEnd = (e) => {
|
||||
if (!touchStartChart.current) return;
|
||||
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
||||
const maxCharts = filters.museum === 'all' ? dashboardCharts.length : dashboardCharts.length - 2;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeChart < maxCharts - 1) setActiveChart(activeChart + 1);
|
||||
else if (diff < 0 && activeChart > 0) setActiveChart(activeChart - 1);
|
||||
}
|
||||
touchStartChart.current = null;
|
||||
};
|
||||
|
||||
// Dynamic lists from data
|
||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
|
||||
const yoyChange = useMemo(() => {
|
||||
if (filters.year === 'all') return null;
|
||||
const prevYear = String(parseInt(filters.year) - 1);
|
||||
const prevData = data.filter(row => row.year === prevYear);
|
||||
if (prevData.length === 0) return null;
|
||||
const prevMetrics = calculateMetrics(prevData);
|
||||
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
||||
}, [data, filters.year, metrics.revenue]);
|
||||
|
||||
// Revenue trend data (weekly or daily)
|
||||
const trendData = useMemo(() => {
|
||||
const formatLabel = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const d = new Date(year, month - 1, day);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (trendGranularity === 'week') {
|
||||
const grouped = groupByWeek(filteredData);
|
||||
const weeks = Object.keys(grouped).filter(w => w).sort();
|
||||
return {
|
||||
labels: weeks.map(formatLabel),
|
||||
datasets: [{
|
||||
label: 'Revenue',
|
||||
data: weeks.map(w => grouped[w].revenue),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
}]
|
||||
};
|
||||
} else {
|
||||
// Daily granularity
|
||||
const dailyData = {};
|
||||
filteredData.forEach(row => {
|
||||
const date = row.date;
|
||||
if (!dailyData[date]) dailyData[date] = 0;
|
||||
dailyData[date] += parseFloat(row.revenue_incl_tax || 0);
|
||||
});
|
||||
const days = Object.keys(dailyData).sort();
|
||||
return {
|
||||
labels: days.map(formatLabel),
|
||||
datasets: [{
|
||||
label: 'Revenue',
|
||||
data: days.map(d => dailyData[d]),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
borderWidth: 1.5,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3
|
||||
}]
|
||||
};
|
||||
}
|
||||
}, [filteredData, trendGranularity]);
|
||||
|
||||
// Museum data
|
||||
const museumData = useMemo(() => {
|
||||
const grouped = groupByMuseum(filteredData);
|
||||
const museums = Object.keys(grouped);
|
||||
return {
|
||||
visitors: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
data: museums.map(m => grouped[m].visitors),
|
||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
revenue: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
data: museums.map(m => grouped[m].revenue),
|
||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderRadius: 4
|
||||
}]
|
||||
}
|
||||
};
|
||||
}, [filteredData]);
|
||||
|
||||
// District data
|
||||
const districtData = useMemo(() => {
|
||||
const grouped = groupByDistrict(filteredData);
|
||||
const districts = Object.keys(grouped);
|
||||
return {
|
||||
labels: districts,
|
||||
datasets: [{
|
||||
data: districts.map(d => grouped[d].revenue),
|
||||
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderRadius: 4
|
||||
}]
|
||||
};
|
||||
}, [filteredData]);
|
||||
|
||||
// Quarterly YoY
|
||||
const quarterlyYoYData = useMemo(() => {
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
return {
|
||||
labels: quarters,
|
||||
datasets: [
|
||||
{
|
||||
label: '2024',
|
||||
data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
|
||||
backgroundColor: chartColors.muted,
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: '2025',
|
||||
data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
|
||||
backgroundColor: chartColors.primary,
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// Capture rate
|
||||
const captureRateData = useMemo(() => {
|
||||
const labels = [];
|
||||
const rates = [];
|
||||
const pilgrimCounts = [];
|
||||
[2024, 2025].forEach(year => {
|
||||
[1, 2, 3, 4].forEach(q => {
|
||||
const pilgrims = umrahData[year]?.[q];
|
||||
if (!pilgrims) return;
|
||||
let qData = data.filter(r => r.year === String(year) && r.quarter === String(q));
|
||||
if (filters.district !== 'all') qData = qData.filter(r => r.district === filters.district);
|
||||
if (filters.museum !== 'all') qData = qData.filter(r => r.museum_name === filters.museum);
|
||||
const visitors = qData.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
labels.push(`Q${q} ${year}`);
|
||||
rates.push((visitors / pilgrims * 100));
|
||||
pilgrimCounts.push(pilgrims);
|
||||
});
|
||||
});
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Capture Rate (%)',
|
||||
data: rates,
|
||||
borderColor: chartColors.secondary,
|
||||
backgroundColor: chartColors.secondary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#fff',
|
||||
pointBorderColor: chartColors.secondary,
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: 'y',
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => value.toFixed(2) + '%',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
font: { size: 9, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'top',
|
||||
offset: 6
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Pilgrims',
|
||||
data: pilgrimCounts,
|
||||
borderColor: chartColors.tertiary,
|
||||
backgroundColor: chartColors.tertiary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#fff',
|
||||
pointBorderColor: chartColors.tertiary,
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: 'y1',
|
||||
order: 1,
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => (value / 1000000).toFixed(2) + 'M',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
font: { size: 9, weight: 600 },
|
||||
anchor: 'start',
|
||||
align: 'bottom',
|
||||
offset: 6
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, filters.district, filters.museum]);
|
||||
|
||||
// Quarterly table
|
||||
const quarterlyTable = useMemo(() => {
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
return [1, 2, 3, 4].map(q => {
|
||||
let q2024 = d2024.filter(r => r.quarter === String(q));
|
||||
let q2025 = d2025.filter(r => r.quarter === String(q));
|
||||
if (filters.district !== 'all') {
|
||||
q2024 = q2024.filter(r => r.district === filters.district);
|
||||
q2025 = q2025.filter(r => r.district === filters.district);
|
||||
}
|
||||
if (filters.museum !== 'all') {
|
||||
q2024 = q2024.filter(r => r.museum_name === filters.museum);
|
||||
q2025 = q2025.filter(r => r.museum_name === filters.museum);
|
||||
}
|
||||
const rev24 = q2024.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
||||
const rev25 = q2025.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
||||
const vis24 = q2024.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
const vis25 = q2025.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0;
|
||||
const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0;
|
||||
const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null;
|
||||
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
||||
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
||||
});
|
||||
}, [data, filters.district, filters.museum]);
|
||||
|
||||
const dataLabelDefaults = {
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
offset: 4,
|
||||
padding: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
formatter: (value) => {
|
||||
if (value == null) return '';
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
||||
if (value < 100 && value > 0) return value.toFixed(2);
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
};
|
||||
|
||||
const baseOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8, titleFont: { size: 12 }, bodyFont: { size: 11 } },
|
||||
datalabels: dataLabelDefaults
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
|
||||
y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="page-title">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Real-time museum analytics from Google Sheets</p>
|
||||
</div>
|
||||
|
||||
<div className={`controls ${filtersExpanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div className="controls-header" onClick={() => setFiltersExpanded(!filtersExpanded)}>
|
||||
<h3>Filters</h3>
|
||||
<button className="controls-toggle">{filtersExpanded ? '▲ Hide' : '▼ Show'}</button>
|
||||
</div>
|
||||
<div className="controls-body">
|
||||
<div className="control-row">
|
||||
<div className="control-group">
|
||||
<label>Year</label>
|
||||
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
|
||||
<option value="all">All Years</option>
|
||||
{years.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>District</label>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">All Districts</option>
|
||||
{districts.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Museum</label>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">All Museums</option>
|
||||
{availableMuseums.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Quarter</label>
|
||||
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
||||
<option value="all">All Quarters</option>
|
||||
<option value="1">Q1</option>
|
||||
<option value="2">Q2</option>
|
||||
<option value="3">Q3</option>
|
||||
<option value="4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Grid */}
|
||||
<div className="stats-grid desktop-only">
|
||||
<div className="stat-card">
|
||||
<h3>Total Revenue</h3>
|
||||
<div className="stat-value">{formatCurrency(metrics.revenue)}</div>
|
||||
{yoyChange !== null && (
|
||||
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
|
||||
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>Total Visitors</h3>
|
||||
<div className="stat-value">{formatNumber(metrics.visitors)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>Total Tickets</h3>
|
||||
<div className="stat-value">{formatNumber(metrics.tickets)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>Avg Revenue/Visitor</h3>
|
||||
<div className="stat-value">{formatCurrency(metrics.avgRevPerVisitor)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Stats Carousel */}
|
||||
<div className="stats-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeStatCard * 100}%)` }}
|
||||
onTouchStart={handleStatTouchStart}
|
||||
onTouchEnd={handleStatTouchEnd}
|
||||
>
|
||||
{statCards.map((card, i) => (
|
||||
<div className="carousel-slide" key={i}>
|
||||
<div className="stat-card">
|
||||
<h3>{card.title}</h3>
|
||||
<div className="stat-value">{card.value}</div>
|
||||
{card.hasYoy && yoyChange !== null && (
|
||||
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
|
||||
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carousel-dots labeled">
|
||||
{statCards.map((card, i) => (
|
||||
<button key={i} className={`carousel-dot ${activeStatCard === i ? 'active' : ''}`} onClick={() => setActiveStatCard(i)}>
|
||||
<span className="dot-label">{card.title.replace('Total ', '').replace('Avg ', '')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
|
||||
<h2>Quarterly Comparison: 2024 vs 2025</h2>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th>Rev 2024</th>
|
||||
<th>Rev 2025</th>
|
||||
<th>Change</th>
|
||||
<th>Visitors 2024</th>
|
||||
<th>Visitors 2025</th>
|
||||
<th>Change</th>
|
||||
<th>Capture 2024</th>
|
||||
<th>Capture 2025</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quarterlyTable.map(row => (
|
||||
<tr key={row.q}>
|
||||
<td className="bold">Q{row.q}</td>
|
||||
<td className="muted">{formatCurrency(row.rev24)}</td>
|
||||
<td className="bold">{formatCurrency(row.rev25)}</td>
|
||||
<td className={row.revChg >= 0 ? 'positive' : 'negative'}>
|
||||
{row.revChg >= 0 ? '+' : ''}{row.revChg.toFixed(1)}%
|
||||
</td>
|
||||
<td className="muted">{formatNumber(row.vis24)}</td>
|
||||
<td className="bold">{formatNumber(row.vis25)}</td>
|
||||
<td className={row.visChg >= 0 ? 'positive' : 'negative'}>
|
||||
{row.visChg >= 0 ? '+' : ''}{row.visChg.toFixed(1)}%
|
||||
</td>
|
||||
<td className="muted">{row.cap24 ? row.cap24.toFixed(2) + '%' : '—'}</td>
|
||||
<td className="purple bold">{row.cap25 ? row.cap25.toFixed(2) + '%' : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Charts Grid */}
|
||||
<div className="charts-grid desktop-only">
|
||||
<div className="chart-card full-width">
|
||||
<h2>Revenue Trends</h2>
|
||||
<div className="toggle-switch toggle-corner">
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<h2>Visitors by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 11}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<h2>Revenue by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<h2>Quarterly Revenue (YoY)</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 11}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<h2>District Performance</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card full-width">
|
||||
<h2>Capture Rate vs Umrah Pilgrims</h2>
|
||||
<div className="chart-container">
|
||||
<Line data={captureRateData} options={{
|
||||
...baseOptions,
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: baseOptions.scales.x,
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Capture Rate (%)', font: { size: 10 }, color: chartColors.secondary }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Pilgrims', font: { size: 10 }, color: chartColors.tertiary }
|
||||
}
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Charts Carousel */}
|
||||
<div className="charts-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeChart * 100}%)` }}
|
||||
onTouchStart={handleChartTouchStart}
|
||||
onTouchEnd={handleChartTouchEnd}
|
||||
>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Revenue Trends</h2>
|
||||
<div className="toggle-switch toggle-corner">
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Visitors by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 10}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Revenue by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Quarterly Revenue (YoY)</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 8, font: {size: 10}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>District Performance</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Capture Rate vs Umrah Pilgrims</h2>
|
||||
<div className="chart-container">
|
||||
<Line data={captureRateData} options={{
|
||||
...baseOptions,
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 10, padding: 8, font: { size: 9 } } },
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: baseOptions.scales.x,
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
border: { display: false }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false }
|
||||
}
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-dots">
|
||||
{(filters.museum === 'all' ? dashboardCharts : dashboardCharts.filter(c => !['visitors-museum', 'revenue-museum'].includes(c.id))).map((chart, i) => (
|
||||
<button
|
||||
key={chart.id}
|
||||
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveChart(i)}
|
||||
>
|
||||
<span className="dot-label">{chart.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
10
src/index.js
Normal file
10
src/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
324
src/services/dataService.js
Normal file
324
src/services/dataService.js
Normal file
@@ -0,0 +1,324 @@
|
||||
// Google Sheets configuration
|
||||
const SPREADSHEET_ID = '1rdK1e7jmfu-es4Ql0YwDYNBY2OvVihBjYaXTM-MHHqg';
|
||||
const SHEET_NAME = 'Consolidated Data';
|
||||
const SHEET_URL = `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`;
|
||||
|
||||
// NocoDB configuration
|
||||
// Use relative URL for dev proxy, full URL for production
|
||||
const NOCODB_URL = process.env.NODE_ENV === 'production' ? 'http://localhost:8090' : '';
|
||||
const NOCODB_TOKEN = 'By-wCdkUm6N9JdfmNpGH2jd6LqEejwOXER7FMkgr';
|
||||
|
||||
// Old flat table (for backwards compatibility)
|
||||
const NOCODB_TABLE_ID = 'mzcz8ktjybcjc79';
|
||||
|
||||
// New normalized tables (Samaya Museums Statistics base)
|
||||
const NOCODB_TABLES = {
|
||||
districts: 'm8cup7lesbet0sa',
|
||||
museums: 'm1c7od7mdirffvu',
|
||||
dailyStats: 'mc7qhbdh3mjjwl8'
|
||||
};
|
||||
|
||||
export const umrahData = {
|
||||
2024: { 1: 11574494, 2: 10521465, 3: 3364627, 4: 7435625 },
|
||||
2025: { 1: 15222497, 2: 5443393, 3: null, 4: null }
|
||||
};
|
||||
|
||||
// Convert Excel serial date to YYYY-MM-DD
|
||||
function excelDateToYMD(serial) {
|
||||
const num = parseInt(serial);
|
||||
if (isNaN(num) || num < 1) return null;
|
||||
|
||||
// Excel epoch is Dec 30, 1899
|
||||
const utcDays = Math.floor(num - 25569); // 25569 = days from 1899-12-30 to 1970-01-01
|
||||
const date = new Date(utcDays * 86400 * 1000);
|
||||
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const lines = normalizedText.trim().split('\n');
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
|
||||
|
||||
return lines.slice(1).map(line => {
|
||||
const values = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||
|
||||
const obj = {};
|
||||
headers.forEach((header, i) => {
|
||||
let val = values[i] || '';
|
||||
// Convert date serial to YYYY-MM-DD
|
||||
if (header === 'date' && /^\d+$/.test(val)) {
|
||||
val = excelDateToYMD(val);
|
||||
}
|
||||
obj[header] = val;
|
||||
});
|
||||
return obj;
|
||||
}).filter(row => row.date);
|
||||
}
|
||||
|
||||
export async function fetchSheetData() {
|
||||
try {
|
||||
console.log('Fetching from Google Sheets...');
|
||||
const response = await fetch(SHEET_URL);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const text = await response.text();
|
||||
if (text.includes('<!DOCTYPE') || text.includes('<html')) {
|
||||
throw new Error('Sheet is not public');
|
||||
}
|
||||
|
||||
const data = parseCSV(text);
|
||||
console.log(`Loaded ${data.length} rows from Google Sheets`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err);
|
||||
throw new Error(`Failed to load data: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNocoDBTable(tableId, limit = 1000) {
|
||||
let allRecords = [];
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
|
||||
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const json = await response.json();
|
||||
const records = json.list || [];
|
||||
allRecords = allRecords.concat(records);
|
||||
|
||||
if (records.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
export async function fetchNocoDBData() {
|
||||
try {
|
||||
console.log('Fetching from NocoDB (normalized)...');
|
||||
|
||||
// Fetch all three tables in parallel
|
||||
const [districts, museums, dailyStats] = await Promise.all([
|
||||
fetchNocoDBTable(NOCODB_TABLES.districts),
|
||||
fetchNocoDBTable(NOCODB_TABLES.museums),
|
||||
fetchNocoDBTable(NOCODB_TABLES.dailyStats)
|
||||
]);
|
||||
|
||||
// Build lookup maps
|
||||
const districtMap = {};
|
||||
districts.forEach(d => { districtMap[d.Id] = d.Name; });
|
||||
|
||||
const museumMap = {};
|
||||
museums.forEach(m => {
|
||||
museumMap[m.Id] = {
|
||||
code: m.Code,
|
||||
name: m.Name,
|
||||
district: districtMap[m['nc_epk____Districts_id']] || 'Unknown'
|
||||
};
|
||||
});
|
||||
|
||||
// Join data into flat structure for dashboard
|
||||
const data = dailyStats.map(row => {
|
||||
const museum = museumMap[row['nc_epk____Museums_id']] || {};
|
||||
const date = row.Date;
|
||||
const year = date ? date.substring(0, 4) : '';
|
||||
const month = date ? parseInt(date.substring(5, 7)) : 0;
|
||||
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
|
||||
|
||||
return {
|
||||
date: date,
|
||||
museum_code: museum.code,
|
||||
museum_name: museum.name,
|
||||
district: museum.district,
|
||||
visits: row.Visits,
|
||||
tickets: row.Tickets,
|
||||
revenue_incl_tax: row.Revenue,
|
||||
year: year,
|
||||
quarter: quarter
|
||||
};
|
||||
}).filter(r => r.date && r.museum_name);
|
||||
|
||||
console.log(`Loaded ${data.length} rows from NocoDB (joined from ${districts.length} districts, ${museums.length} museums, ${dailyStats.length} stats)`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('NocoDB fetch error:', err);
|
||||
throw new Error(`Failed to load from NocoDB: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchData(source = 'sheets') {
|
||||
return source === 'nocodb' ? fetchNocoDBData() : fetchSheetData();
|
||||
}
|
||||
|
||||
export function filterData(data, filters) {
|
||||
return data.filter(row => {
|
||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
||||
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterDataByDateRange(data, startDate, endDate, filters = {}) {
|
||||
return data.filter(row => {
|
||||
if (!row.date) return false;
|
||||
if (row.date < startDate || row.date > endDate) return false;
|
||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateMetrics(data) {
|
||||
const revenue = data.reduce((sum, row) => sum + parseFloat(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 avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
||||
return { revenue, visitors, tickets, avgRevPerVisitor };
|
||||
}
|
||||
|
||||
export function formatCurrency(num) {
|
||||
if (isNaN(num)) return 'SAR 0';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'SAR',
|
||||
maximumFractionDigits: 0
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export function formatNumber(num) {
|
||||
if (isNaN(num)) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(Math.round(num));
|
||||
}
|
||||
|
||||
export function formatCompact(num) {
|
||||
if (isNaN(num)) return '0';
|
||||
const absNum = Math.abs(num);
|
||||
if (absNum >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (absNum >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||
return formatNumber(num);
|
||||
}
|
||||
|
||||
export function formatCompactCurrency(num) {
|
||||
if (isNaN(num)) return 'SAR 0';
|
||||
const absNum = Math.abs(num);
|
||||
if (absNum >= 1000000) return 'SAR ' + (num / 1000000).toFixed(1) + 'M';
|
||||
if (absNum >= 1000) return 'SAR ' + (num / 1000).toFixed(0) + 'K';
|
||||
return formatCurrency(num);
|
||||
}
|
||||
|
||||
export function getWeekStart(dateStr) {
|
||||
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
||||
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
const dayOfWeek = date.getDay();
|
||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
|
||||
const monday = new Date(year, month - 1, day + diff);
|
||||
const y = monday.getFullYear();
|
||||
const m = String(monday.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(monday.getDate()).padStart(2, '0');
|
||||
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function groupByWeek(data) {
|
||||
const grouped = {};
|
||||
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.revenue_incl_tax || 0);
|
||||
grouped[weekStart].visitors += parseInt(row.visits || 0);
|
||||
grouped[weekStart].tickets += parseInt(row.tickets || 0);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function groupByMuseum(data) {
|
||||
const grouped = {};
|
||||
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.revenue_incl_tax || 0);
|
||||
grouped[row.museum_name].visitors += parseInt(row.visits || 0);
|
||||
grouped[row.museum_name].tickets += parseInt(row.tickets || 0);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function groupByDistrict(data) {
|
||||
const grouped = {};
|
||||
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.revenue_incl_tax || 0);
|
||||
grouped[row.district].visitors += parseInt(row.visits || 0);
|
||||
grouped[row.district].tickets += parseInt(row.tickets || 0);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Dynamic data extraction helpers
|
||||
export function getUniqueYears(data) {
|
||||
const years = [...new Set(data.map(r => r.year).filter(Boolean))];
|
||||
return years.sort((a, b) => parseInt(a) - parseInt(b));
|
||||
}
|
||||
|
||||
export function getUniqueDistricts(data) {
|
||||
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function getDistrictMuseumMap(data) {
|
||||
const map = {};
|
||||
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);
|
||||
});
|
||||
// Convert sets to sorted arrays
|
||||
Object.keys(map).forEach(d => {
|
||||
map[d] = [...map[d]].sort();
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getMuseumsForDistrict(districtMuseumMap, district) {
|
||||
if (district === 'all') {
|
||||
return Object.values(districtMuseumMap).flat().sort();
|
||||
}
|
||||
return districtMuseumMap[district] || [];
|
||||
}
|
||||
|
||||
export function getLatestYear(data) {
|
||||
const years = getUniqueYears(data);
|
||||
return years.length > 0 ? years[years.length - 1] : '2025';
|
||||
}
|
||||
11
src/setupProxy.js
Normal file
11
src/setupProxy.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: 'http://localhost:8090',
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user