- 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>
887 lines
36 KiB
TypeScript
887 lines
36 KiB
TypeScript
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 YY–MMM 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;
|