Files
hihala-dashboard/src/components/Comparison.tsx
fahed 3c19dee236
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
feat: add season annotation bands to Comparison trend chart
Seasons that overlap the current comparison period appear as
colored bands on the Revenue Trend chart, same as Dashboard.

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

968 lines
40 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, Season } 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, seasons, 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;
}
// Season presets store from/to in URL
const fromParam = searchParams.get('from');
if (fromParam) return fromParam;
return `${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;
}
// Season presets store from/to in URL
const toParam = searchParams.get('to');
if (toParam) return toParam;
return `${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' || newPreset.startsWith('season-')) {
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' && !preset.startsWith('season-') && newDates[preset]) {
setStartDateState(newDates[preset].start);
setEndDateState(newDates[preset].end);
}
updateUrl(preset, null, null, filters, year);
};
const setPreset = (value: string) => {
setPresetState(value);
if (value.startsWith('season-')) {
const seasonId = parseInt(value.replace('season-', ''));
const season = seasons.find(s => s.Id === seasonId);
if (season) {
setStartDateState(season.StartDate);
setEndDateState(season.EndDate);
updateUrl(value, season.StartDate, season.EndDate, filters, selectedYear);
}
} else 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
// For season presets, try to find the same season name from the previous hijri year
const ranges = useMemo(() => {
const curr = { start: startDate, end: endDate };
let 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))
};
if (preset.startsWith('season-')) {
const seasonId = parseInt(preset.replace('season-', ''));
const currentSeason = seasons.find(s => s.Id === seasonId);
if (currentSeason) {
const prevSeason = seasons.find(
s => s.Name === currentSeason.Name && s.HijriYear === currentSeason.HijriYear - 1
);
if (prevSeason) {
prev = { start: prevSeason.StartDate, end: prevSeason.EndDate };
}
}
}
return { curr, prev };
}, [startDate, endDate, preset, seasons]);
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]);
// Map seasons to annotation bands on the current period's timeline
const seasonAnnotations = useMemo(() => {
if (!seasons.length) return {};
const currStart = new Date(ranges.curr.start);
const currEnd = new Date(ranges.curr.end);
const annotations: Record<string, unknown> = {};
const msPerDay = 1000 * 60 * 60 * 24;
const granDivisor = chartGranularity === 'month' ? 30 : chartGranularity === 'week' ? 7 : 1;
seasons.forEach((s, i) => {
const sStart = new Date(s.StartDate);
const sEnd = new Date(s.EndDate);
// Check overlap with current period
if (sEnd < currStart || sStart > currEnd) return;
const clampedStart = sStart < currStart ? currStart : sStart;
const clampedEnd = sEnd > currEnd ? currEnd : sEnd;
const startIdx = Math.floor((clampedStart.getTime() - currStart.getTime()) / msPerDay / granDivisor);
const endIdx = Math.floor((clampedEnd.getTime() - currStart.getTime()) / msPerDay / granDivisor);
annotations[`season${i}`] = {
type: 'box',
xMin: startIdx - 0.5,
xMax: endIdx + 0.5,
backgroundColor: s.Color + '20',
borderColor: s.Color + '40',
borderWidth: 1,
label: {
display: true,
content: `${s.Name} ${s.HijriYear}`,
position: 'start',
color: s.Color,
font: { size: 10, weight: '600' },
padding: 4
}
};
});
return annotations;
}, [seasons, ranges.curr, chartGranularity]);
const chartOptions: any = {
...baseOptions,
plugins: {
...baseOptions.plugins,
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
annotation: { annotations: seasonAnnotations }
}
};
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>
{seasons.length > 0 && (
<optgroup label={t('comparison.seasons') || 'Seasons'}>
{seasons.map(s => (
<option key={s.Id} value={`season-${s.Id}`}>
{s.Name} {s.HijriYear}
</option>
))}
</optgroup>
)}
</select>
</FilterControls.Group>
{preset !== 'custom' && !preset.startsWith('season-') && (
<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' || preset.startsWith('season-')) && (
<>
<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;