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 (

Period Comparison

Year-over-year analysis — same period, different years

setControlsExpanded(!controlsExpanded)}>

Select Period

{preset === 'custom' && ( <>
setStartDate(e.target.value)} />
setEndDate(e.target.value)} />
)}
{ranges.prev.start.substring(0, 4)}
{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}
{ranges.curr.start.substring(0, 4)}
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
{/* Desktop: Grid layout */}
{metricCards.map((card, i) => ( ))}
{/* Mobile: Carousel layout */}
{metricCards.map((card, i) => (
))}
{metricCards.map((card, i) => ( ))}
{/* Desktop: Show both charts */}

{metricOptions.find(m => m.value === chartMetric)?.label} Trend

{granularityOptions.map(opt => ( ))}
{metricOptions.map(opt => ( ))}

{metricOptions.find(m => m.value === chartMetric)?.label} by Museum

{metricOptions.map(opt => ( ))}
{/* Mobile: Carousel */}

{metricOptions.find(m => m.value === chartMetric)?.label} Trend

{granularityOptions.map(opt => ( ))}
{metricOptions.map(opt => ( ))}

{metricOptions.find(m => m.value === chartMetric)?.label} by Museum

{metricOptions.map(opt => ( ))}
{charts.map((chart, i) => ( ))}
); } 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 (

{title}

{prevYear}
{formatValue(prev)}
{hasPending && pendingMessage ? (
{pendingMessage}
) : ( <>
{changeText}
{diff >= 0 ? '+' : ''}{diffText}
)}
{currYear}
{formatValue(curr)}
); } export default Comparison;