Files
hihala-dashboard/src/components/Comparison.tsx
fahed aa9813aed4 feat: multi-select filters for events and channels
- New MultiSelect component with checkbox dropdown
- Event and channel filters now accept multiple selections
- Empty array = all selected (no filter applied)
- URL params store selections as comma-separated values
- District and quarter remain single-select

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:53:23 +03:00

887 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import { EmptyState, FilterControls, MultiSelect } from './shared';
import { ExportableChart } from './ChartExport';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import {
filterDataByDateRange,
calculateMetrics,
formatCompact,
formatCompactCurrency,
umrahData,
getUniqueChannels,
getUniqueMuseums,
getUniqueDistricts,
getMuseumsForDistrict,
getLatestYear
} from '../services/dataService';
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
interface PresetDateRange {
start: string;
end: string;
}
interface PresetDates {
[key: string]: PresetDateRange;
}
interface MetricCardProps {
title: string;
prev: number | null;
curr: number | null;
change: number | null;
isCurrency?: boolean;
isPercent?: boolean;
pendingMessage?: string;
prevYear: string;
currYear: string;
}
// Generate preset dates for a given year
const generatePresetDates = (year: number): PresetDates => ({
'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, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
// Get available years from data
const latestYear = useMemo(() => parseInt(getLatestYear(data)), [data]);
const availableYears = useMemo((): number[] => {
const yearsSet = new Set<number>();
data.forEach((r: MuseumRecord) => {
const d = r.date || (r as any).Date;
if (d) yearsSet.add(new Date(d).getFullYear());
});
const years = Array.from(yearsSet).sort((a, b) => b - a);
return years.length ? years : [new Date().getFullYear()];
}, [data]);
// Initialize state from URL or defaults
const [selectedYear, setSelectedYearState] = useState<number>(() => {
const urlYear = searchParams.get('year');
return urlYear ? parseInt(urlYear) : latestYear;
});
const presetDates = useMemo(() => generatePresetDates(selectedYear), [selectedYear]);
const [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan');
const [startDate, setStartDateState] = useState(() => {
const urlPreset = searchParams.get('preset');
const yearParam = searchParams.get('year');
const year = yearParam ? parseInt(yearParam) : latestYear;
const dates = generatePresetDates(year);
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].start;
}
return searchParams.get('from') || `${year}-01-01`;
});
const [endDate, setEndDateState] = useState(() => {
const urlPreset = searchParams.get('preset');
const yearParam = searchParams.get('year');
const year = yearParam ? parseInt(yearParam) : latestYear;
const dates = generatePresetDates(year);
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].end;
}
return searchParams.get('to') || `${year}-01-31`;
});
const [filters, setFiltersState] = useState(() => ({
district: searchParams.get('district') || 'all',
channel: searchParams.get('channel')?.split(',').filter(Boolean) || [],
museum: searchParams.get('museum')?.split(',').filter(Boolean) || []
}));
const [chartMetric, setChartMetric] = useState('revenue');
const [chartGranularity, setChartGranularity] = useState('week');
const [activeChart, setActiveChart] = useState(0);
const [activeCard, setActiveCard] = useState(0);
// Update URL with current state
const updateUrl = useCallback((newPreset: string, newFrom: string | null, newTo: string | null, newFilters: DateRangeFilters | null, newYear: number) => {
const params = new URLSearchParams();
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
if (newPreset === 'custom') {
if (newFrom) params.set('from', newFrom);
if (newTo) params.set('to', newTo);
}
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
if (newFilters?.channel && newFilters.channel.length > 0) params.set('channel', newFilters.channel.join(','));
if (newFilters?.museum && newFilters.museum.length > 0) params.set('museum', newFilters.museum.join(','));
setSearchParams(params, { replace: true });
}, [setSearchParams, latestYear]);
const setSelectedYear = (year: number) => {
setSelectedYearState(year);
const newDates = generatePresetDates(year);
if (preset !== 'custom' && newDates[preset]) {
setStartDateState(newDates[preset].start);
setEndDateState(newDates[preset].end);
}
updateUrl(preset, null, null, filters, year);
};
const setPreset = (value: string) => {
setPresetState(value);
if (value !== 'custom' && presetDates[value]) {
setStartDateState(presetDates[value].start);
setEndDateState(presetDates[value].end);
updateUrl(value, null, null, filters, selectedYear);
}
};
const setStartDate = (value: string) => {
setStartDateState(value);
setPresetState('custom');
updateUrl('custom', value, endDate, filters, selectedYear);
};
const setEndDate = (value: string) => {
setEndDateState(value);
setPresetState('custom');
updateUrl('custom', startDate, value, filters, selectedYear);
};
const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
setFiltersState(updated);
updateUrl(preset, startDate, endDate, updated, selectedYear);
};
const charts = [
{ id: 'timeseries', label: t('comparison.trend') },
{ id: 'museum', label: t('comparison.byMuseum') }
];
// Touch swipe handlers
const touchStartChart = useRef<number | null>(null);
const touchStartCard = useRef<number | null>(null);
const handleChartTouchStart = (e: React.TouchEvent) => {
touchStartChart.current = e.touches[0].clientX;
};
const handleChartTouchEnd = (e: React.TouchEvent) => {
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: t('time.daily') },
{ value: 'week', label: t('time.weekly') },
{ value: 'month', label: t('time.monthly') }
];
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const metricOptions = [
{ value: 'revenue', label: t('metrics.revenue'), field: revenueField, format: 'currency' },
{ value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' },
{ value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' },
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
];
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
if (metric === 'avgRevenue') {
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || 0)), 0);
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
return visitors > 0 ? revenue / visitors : 0;
}
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[metric];
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || 0)), 0);
}, [revenueField]);
// Dynamic lists from data
const channels = useMemo(() => getUniqueChannels(data), [data]);
const districts = useMemo(() => getUniqueDistricts(data), [data]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
// Year-over-year comparison: same dates, previous year
const ranges = useMemo(() => ({
curr: { start: startDate, end: endDate },
prev: {
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(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, includeVAT), [prevData, includeVAT]);
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
const hasData = prevData.length > 0 || currData.length > 0;
const resetFilters = () => setFilters({ district: 'all', channel: [], museum: [] });
const calcChange = (prev: number, curr: number) => 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: string, end: string) => {
const quarterRanges: Record<number, { start: string; end: string }> = {
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;
};
// Estimate pilgrims for any date range by prorating quarterly data
const estimatePilgrims = useCallback((start: string, end: string): number | null => {
const startDate = new Date(start);
const endDate = new Date(end);
let total = 0;
let hasData = false;
// Iterate through each quarter that overlaps with the range
const startYear = startDate.getFullYear();
const endYear = endDate.getFullYear();
for (let year = startYear; year <= endYear; year++) {
for (let q = 1; q <= 4; q++) {
const qStart = new Date(year, (q - 1) * 3, 1);
const qEnd = new Date(year, q * 3, 0); // last day of quarter
// Check overlap
if (qEnd < startDate || qStart > endDate) continue;
const pilgrims = umrahData[year]?.[q];
if (!pilgrims) continue;
// Calculate overlap fraction
const overlapStart = new Date(Math.max(qStart.getTime(), startDate.getTime()));
const overlapEnd = new Date(Math.min(qEnd.getTime(), endDate.getTime()));
const overlapDays = (overlapEnd.getTime() - overlapStart.getTime()) / (1000 * 60 * 60 * 24) + 1;
const quarterDays = (qEnd.getTime() - qStart.getTime()) / (1000 * 60 * 60 * 24) + 1;
total += pilgrims * (overlapDays / quarterDays);
hasData = true;
}
}
return hasData ? Math.round(total) : null;
}, []);
// Calculate capture rate and pilgrim data for any date range
const quarterData = useMemo(() => {
const prevPilgrims = estimatePilgrims(ranges.prev.start, ranges.prev.end);
const currPilgrims = estimatePilgrims(ranges.curr.start, ranges.curr.end);
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, estimatePilgrims]);
const captureRates = quarterData?.captureRate || null;
const pilgrimCounts = quarterData?.pilgrims || null;
// Build cards array dynamically
interface CardData {
title: string;
prev: number | null;
curr: number | null;
change: number | null;
isCurrency?: boolean;
isPercent?: boolean;
pendingMessage?: string;
}
const metricCards = useMemo((): CardData[] => {
const revenueChange = calcChange(prevMetrics.revenue, currMetrics.revenue);
const visitorsChange = calcChange(prevMetrics.visitors, currMetrics.visitors);
const ticketsChange = calcChange(prevMetrics.tickets, currMetrics.tickets);
const avgRevChange = calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor);
const pilgrimsChange = pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null;
const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
const cards: CardData[] = [
{ title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
{ title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
{ title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
{ title: t('metrics.avgRevenue'), prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
];
if (pilgrimCounts) {
cards.push({ title: t('metrics.pilgrims'), prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: t('comparison.pendingData') });
}
if (captureRates) {
cards.push({ title: t('metrics.captureRate'), prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: t('comparison.pendingData') });
}
return cards;
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
const handleCardTouchStart = (e: React.TouchEvent) => {
touchStartCard.current = e.touches[0].clientX;
};
const handleCardTouchEnd = (e: React.TouchEvent) => {
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: string) => {
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' });
};
// Generate period label - shows year if same year, or "MMM YYMMM YY" if spans years
const getPeriodLabel = useCallback((startDate: string, endDate: string) => {
if (!startDate || !endDate) return '';
const startYear = startDate.substring(0, 4);
const endYear = endDate.substring(0, 4);
if (startYear === endYear) {
return startYear;
}
// Spans multiple years - show abbreviated range
const [sy, sm] = startDate.split('-').map(Number);
const [ey, em] = endDate.split('-').map(Number);
const startMonth = new Date(sy, sm - 1, 1).toLocaleDateString('en-US', { month: 'short' });
const endMonth = new Date(ey, em - 1, 1).toLocaleDateString('en-US', { month: 'short' });
return `${startMonth} ${String(sy).slice(-2)}${endMonth} ${String(ey).slice(-2)}`;
}, []);
// Time series chart (daily or weekly)
const timeSeriesChart = useMemo(() => {
const groupByPeriod = (periodData: MuseumRecord[], periodStart: string, metric: string, granularity: string) => {
const start = new Date(periodStart);
const groupedRows: Record<number, MuseumRecord[]> = {};
periodData.forEach((row: MuseumRecord) => {
if (!row.date) return;
const rowDate = new Date(row.date);
const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
let key;
if (granularity === 'month') {
// Group by month number (relative to start)
const monthsDiff = (rowDate.getFullYear() - start.getFullYear()) * 12 + (rowDate.getMonth() - start.getMonth());
key = monthsDiff + 1;
} else 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: Record<number, number> = {};
Object.keys(groupedRows).forEach(key => {
result[Number(key)] = getMetricValue(groupedRows[Number(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) => {
if (chartGranularity === 'month') {
const startDate = new Date(ranges.curr.start);
const monthNum = ((startDate.getMonth() + i) % 12) + 1;
return String(monthNum);
}
if (chartGranularity === 'week') return `W${i + 1}`;
return `D${i + 1}`;
});
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
return {
labels,
datasets: [
{
label: prevLabel,
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: currLabel,
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, getPeriodLabel]);
// Museum chart - only show museums with data
const museumChart = useMemo(() => {
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
const allMuseums = [...new Set(data.map((r: MuseumRecord) => r.museum_name))].filter(Boolean) as string[];
const prevByMuseum: Record<string, number> = {};
const currByMuseum: Record<string, number> = {};
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: prevLabel, data: museums.map(m => prevByMuseum[m]), backgroundColor: chartColors.muted, borderRadius: 4 },
{ label: currLabel, data: museums.map(m => currByMuseum[m]), backgroundColor: chartColors.primary, borderRadius: 4 }
]
};
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
const chartOptions: any = {
...baseOptions,
plugins: {
...baseOptions.plugins,
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
}
};
return (
<div className="comparison" id="comparison-container">
<div className="page-title-with-actions">
<div className="page-title">
<h1>{t('comparison.title')}</h1>
<p>{t('comparison.subtitle')}</p>
</div>
<div className="header-toggles">
<div className="toggle-with-label">
<span className="toggle-text">{t('nav.vat') || 'VAT'}</span>
<div className="toggle-switch">
<button className={!includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(false)}>{t('toggle.excl') || 'Excl'}</button>
<button className={includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(true)}>{t('toggle.incl') || 'Incl'}</button>
</div>
</div>
<div className="toggle-with-label">
<span className="toggle-text">{t('nav.labels')}</span>
<div className="toggle-switch">
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
</div>
</div>
</div>
</div>
<FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
<FilterControls.Row>
<FilterControls.Group label={t('comparison.period')}>
<select value={preset} onChange={e => setPreset(e.target.value)}>
<option value="custom">{t('comparison.custom')}</option>
<option value="jan">{t('months.january')}</option>
<option value="feb">{t('months.february')}</option>
<option value="mar">{t('months.march')}</option>
<option value="apr">{t('months.april')}</option>
<option value="may">{t('months.may')}</option>
<option value="jun">{t('months.june')}</option>
<option value="jul">{t('months.july')}</option>
<option value="aug">{t('months.august')}</option>
<option value="sep">{t('months.september')}</option>
<option value="oct">{t('months.october')}</option>
<option value="nov">{t('months.november')}</option>
<option value="dec">{t('months.december')}</option>
<option value="q1">{t('time.q1')}</option>
<option value="q2">{t('time.q2')}</option>
<option value="q3">{t('time.q3')}</option>
<option value="q4">{t('time.q4')}</option>
<option value="h1">{t('time.h1')}</option>
<option value="h2">{t('time.h2')}</option>
<option value="full">{t('time.fullYear')}</option>
</select>
</FilterControls.Group>
{preset !== 'custom' && (
<FilterControls.Group label={t('filters.year')}>
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
{availableYears.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</FilterControls.Group>
)}
{preset === 'custom' && (
<>
<FilterControls.Group label={t('comparison.from')}>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</FilterControls.Group>
<FilterControls.Group label={t('comparison.to')}>
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</FilterControls.Group>
</>
)}
<FilterControls.Group label={t('filters.district')}>
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
<option value="all">{t('filters.allDistricts')}</option>
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.channel')}>
<MultiSelect
options={channels}
selected={filters.channel}
onChange={selected => setFilters({...filters, channel: selected})}
allLabel={t('filters.allChannels')}
/>
</FilterControls.Group>
<FilterControls.Group label={t('filters.museum')}>
<MultiSelect
options={availableMuseums}
selected={filters.museum}
onChange={selected => setFilters({...filters, museum: selected})}
allLabel={t('filters.allMuseums')}
/>
</FilterControls.Group>
</FilterControls.Row>
</FilterControls>
<div className="period-display-banner" id="comparison-period">
<div className="period-box prev">
<div className="period-label">{t('comparison.previousPeriod')}</div>
<div className="period-value">{getPeriodLabel(ranges.prev.start, ranges.prev.end)}</div>
<div className="period-dates">{formatDate(ranges.prev.start)} {formatDate(ranges.prev.end)}</div>
</div>
<div className="period-vs">{t('comparison.vs')}</div>
<div className="period-box curr">
<div className="period-label">{t('comparison.currentPeriod')}</div>
<div className="period-value">{getPeriodLabel(ranges.curr.start, ranges.curr.end)}</div>
<div className="period-dates">{formatDate(ranges.curr.start)} {formatDate(ranges.curr.end)}</div>
</div>
</div>
{!hasData ? (
<EmptyState
icon="📈"
title={t('comparison.noData')}
message={t('comparison.noDataMessage')}
action={resetFilters}
actionLabel={t('filters.reset')}
/>
) : (
<>
{/* Desktop: Grid layout */}
<div className="comparison-grid desktop-only" id="comparison-metrics">
{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={getPeriodLabel(ranges.prev.start, ranges.prev.end)}
currYear={getPeriodLabel(ranges.curr.start, ranges.curr.end)}
/>
))}
</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={getPeriodLabel(ranges.prev.start, ranges.prev.end)}
currYear={getPeriodLabel(ranges.curr.start, ranges.curr.end)}
/>
</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" id="comparison-charts">
<div className="chart-section">
<ExportableChart
filename="trend-comparison"
title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.trend')}`}
className="chart-container"
controls={
<>
<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>
</>
}
>
<Line data={timeSeriesChart} options={chartOptions} />
</ExportableChart>
</div>
<div className="chart-section">
<ExportableChart
filename="museum-comparison"
title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.byMuseum')}`}
className="chart-container"
controls={
<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>
}
>
<Bar data={museumChart} options={chartOptions} />
</ExportableChart>
</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} - {t('comparison.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} - {t('comparison.byMuseum')}</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 }: MetricCardProps) {
const hasPending = prev === null || curr === null;
const isPositive = (change ?? 0) >= 0;
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
const formatValue = (val: number | null | undefined) => {
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;