Files
hihala-dashboard/src/components/Comparison.tsx
T
fahed 1070490ad2
Deploy HiHala Dashboard / deploy (push) Successful in 11s
feat(charts): show actual dates in trend chart tooltips
Replace opaque W1/D1/month abbreviation tooltip titles with human-readable
period labels (e.g. "Week 1 · 1 Apr – 7 Apr", "1 April 2025", "April 2025")
in both Dashboard and Comparison trend charts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:37:05 +03:00

323 lines
17 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, useRef, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import {
filterDataByDateRange, calculateMetrics,
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
umrahData
} from '../services/dataService';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import type { LC } from '../lib/locale';
import { EN, AR } from '../lib/locale';
import { currentMonth, shiftYear, periodNameL, dateRangeTextL } from '../lib/dateHelpers';
import { InlinePicker } from './shared/PeriodPicker';
import AltMultiSelect from './shared/AltMultiSelect';
import MetricCard from './shared/MetricCard';
interface Props {
data: MuseumRecord[];
seasons: Season[];
includeVAT: boolean;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
lang?: 'en' | 'ar';
}
// ─── period card ──────────────────────────────────────────────────
function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: {
role: string; hint: string; start: string; end: string;
variant: 'current'|'previous';
onChange: (s: string, e: string) => void;
availableYears: number[]; L: LC;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onM = (e: MouseEvent) => { if(ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onK = (e: KeyboardEvent) => { if(e.key==='Escape') setOpen(false); };
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
}, [open]);
return (
<div ref={ref} className={`alt-card alt-card--${variant}${open?' alt-card--open':''}`}>
<div className="alt-card-bar" />
<div className="alt-card-body">
<div className="alt-role-row">
<span className="alt-role">{role}</span>
<span className="alt-role-hint">{hint}</span>
</div>
<div className="alt-period-name">{periodNameL(start, end, L)}</div>
<div className="alt-date-range">{dateRangeTextL(start, end, L)}</div>
<button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
{open ? L.close : L.changePeriod}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform:open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
</div>
);
}
// ─── main page ────────────────────────────────────────────────────
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
const { lang: activeLang, setLanguage } = useLanguage();
const L = activeLang === 'ar' ? AR : EN;
const curr = currentMonth();
const [currStart, setCurrStart] = useState(curr.start);
const [currEnd, setCurrEnd] = useState(curr.end);
const [prevStart, setPrevStart] = useState(() => shiftYear(curr.start));
const [prevEnd, setPrevEnd] = useState(() => shiftYear(curr.end));
const [selDistricts, setSelDistricts] = useState<string[]>([]);
const [selChannels, setSelChannels] = useState<string[]>([]);
const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week');
const perm = useMemo(() => {
if (!allowedMuseums || !allowedChannels) return [];
let d = data;
if (allowedMuseums.length) d = d.filter(r => allowedMuseums.includes(r.museum_name));
if (allowedChannels.length) d = d.filter(r => allowedChannels.includes(r.channel));
return d;
}, [data, allowedMuseums, allowedChannels]);
const availableYears = useMemo(() => {
const s = new Set<number>(); perm.forEach(r => r.date && s.add(parseInt(r.date.slice(0,4))));
const a = Array.from(s).sort((a,b) => b-a); return a.length ? a : [new Date().getFullYear()];
}, [perm]);
const handleCurr = (s: string, e: string) => { setCurrStart(s); setCurrEnd(e); setPrevStart(shiftYear(s)); setPrevEnd(shiftYear(e)); };
const applyFilters = useCallback((rows: MuseumRecord[]) => {
let d = rows;
if (selChannels.length) d = d.filter(r => selChannels.includes(r.channel));
if (selMuseums.length) d = d.filter(r => selMuseums.includes(r.museum_name));
if (selDistricts.length) d = d.filter(r => selDistricts.includes(r.district));
return d;
}, [selDistricts, selChannels, selMuseums]);
const currData = useMemo(() => applyFilters(filterDataByDateRange(perm, currStart, currEnd, {})), [perm, currStart, currEnd, applyFilters]);
const prevData = useMemo(() => applyFilters(filterDataByDateRange(perm, prevStart, prevEnd, {})), [perm, prevStart, prevEnd, applyFilters]);
const currM = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
const prevM = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const getVal = useCallback((rows: MuseumRecord[], m: string) => {
if (m==='avgRevenue') {
const rev = rows.reduce((s,r) => s + parseFloat(String((r as any)[revenueField]||0)), 0);
const vis = rows.reduce((s,r) => s + parseInt(String(r.visits||0)), 0);
return vis>0 ? rev/vis : 0;
}
const f: Record<string,string> = { revenue:revenueField, visitors:'visits', tickets:'tickets' };
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
}, [revenueField]);
const channels = useMemo(() => getUniqueChannels(perm), [perm]);
const districts = useMemo(() => getUniqueDistricts(perm), [perm]);
const museums = useMemo(() => getUniqueMuseums(perm), [perm]);
const periodLabel = (s: string, e: string) => {
const sy=s.slice(0,4), ey=e.slice(0,4);
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
};
const trendResult = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => {
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
rows.forEach(r => {
if (!r.date) return;
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
const key = gran==='month' ? Math.floor(diff/30)+1 : gran==='week' ? Math.floor(diff/7)+1 : diff+1;
if (!acc[key]) acc[key]=[]; acc[key].push(r);
});
const res: Record<number,number> = {};
Object.entries(acc).forEach(([k,v]) => res[Number(k)] = getVal(v, metric)); return res;
};
const pg = group(prevData, prevStart), cg = group(currData, currStart);
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
const cs0 = new Date(currStart);
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const labels = Array.from({length:maxK}, (_,i) =>
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(cs0.getMonth()+i)%12] : `D${i+1}`
);
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
if (gran==='week') {
const ws = new Date(cs0.getTime() + i * 7 * 86400000);
const we = new Date(cs0.getTime() + (i+1) * 7 * 86400000 - 86400000);
return `Week ${i+1} · ${fmt(ws)} ${fmt(we)}`;
}
if (gran==='month') {
const ms = new Date(cs0.getFullYear(), cs0.getMonth() + i, 1);
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
const ds = new Date(cs0.getTime() + i * 86400000);
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
});
return {
tooltipLabels,
data: {
labels,
datasets: [
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
]
}
};
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
const trendData = trendResult.data;
const museumData = useMemo(() => {
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
const pb: Record<string,number>={}, cb: Record<string,number>={};
all.forEach(m => { pb[m]=getVal(prevData.filter(r => r.museum_name===m), metric); cb[m]=getVal(currData.filter(r => r.museum_name===m), metric); });
const active = all.filter(m => pb[m]>0 || cb[m]>0);
return {
labels: active,
datasets: [
{ label:periodLabel(prevStart,prevEnd), data:active.map(m => pb[m]), backgroundColor:chartColors.muted+'cc', borderRadius:4 },
{ label:periodLabel(currStart,currEnd), data:active.map(m => cb[m]), backgroundColor:chartColors.primary, borderRadius:4 },
]
};
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
const baseOpts = useMemo(() => createBaseOptions(false), []);
const { chartOpts } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
return { chartOpts };
}, [baseOpts]);
const trendOpts: any = useMemo(() => ({
...chartOpts,
plugins: {
...chartOpts.plugins,
tooltip: {
...chartOpts.plugins.tooltip,
callbacks: {
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
}
}
}
}), [chartOpts, trendResult.tooltipLabels]);
const metricOpts = [
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
{ value:'tickets', label:L.tickets }, { value:'avgRevenue', label:L.avgRev },
];
const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }];
const estimatePilgrims = useCallback((s: string, e: string) => {
const sd=new Date(s), ed=new Date(e); let total=0, has=false;
for (let y=sd.getFullYear(); y<=ed.getFullYear(); y++) {
for (let q=1; q<=4; q++) {
const qs=new Date(y,(q-1)*3,1), qe=new Date(y,q*3,0);
if (qe<sd||qs>ed) continue;
const p=umrahData[y]?.[q]; if(!p) continue;
const os=new Date(Math.max(qs.getTime(),sd.getTime())), oe=new Date(Math.min(qe.getTime(),ed.getTime()));
total+=p*((oe.getTime()-os.getTime())/86400000+1)/((qe.getTime()-qs.getTime())/86400000+1); has=true;
}
}
return has ? Math.round(total) : null;
}, []);
const currPilgrims = useMemo(() => estimatePilgrims(currStart, currEnd), [currStart, currEnd, estimatePilgrims]);
const prevPilgrims = useMemo(() => estimatePilgrims(prevStart, prevEnd), [prevStart, prevEnd, estimatePilgrims]);
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
const [filtersOpen, setFiltersOpen] = useState(false);
return (
<div
className="alt-page"
dir={L.dir}
style={{
'--alt-body-font': L.bodyFont,
'--alt-display-font': L.displayFont,
'--alt-mono-font': L.monoFont,
} as React.CSSProperties}
>
<Link to={L.backTo} className="alt-back">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ transform: L.dir==='rtl' ? 'scaleX(-1)' : undefined }}>
<path d="M9 2L4 7L9 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{L.backLink}
</Link>
<h1 className="alt-page-title">{L.pageTitle}</h1>
<p className="alt-page-sub">{L.pageSub}</p>
<div className="alt-period-row">
<PeriodCard role={L.currentRole} hint={L.currentHint} start={currStart} end={currEnd} variant="current"
onChange={handleCurr} availableYears={availableYears} L={L} />
<div className="alt-vs">
<div className="alt-vs-line" />
<span className="alt-vs-badge">{L.vs}</span>
</div>
<PeriodCard role={L.previousRole} hint={L.previousHint} start={prevStart} end={prevEnd} variant="previous"
onChange={(s,e) => { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} />
</div>
<div className={`alt-filter-bar${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
<div className="alt-filter-head">
<span className="alt-filter-label">{L.filter}</span>
{activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
<button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
<svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" viewBox="0 0 10 10" fill="none">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
<div className="alt-filter-body">
<div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
</div>
</div>
<div className="alt-section-heading"><h2>{L.keyMetrics}</h2></div>
<div className="alt-metrics">
<MetricCard title={L.revenue} curr={currM.revenue} prev={prevM.revenue} isCurrency newLabel={L.newLabel} />
<MetricCard title={L.visitors} curr={currM.visitors} prev={prevM.visitors} newLabel={L.newLabel} />
<MetricCard title={L.tickets} curr={currM.tickets} prev={prevM.tickets} newLabel={L.newLabel} />
<MetricCard title={L.avgRev} curr={currM.avgRevPerVisitor} prev={prevM.avgRevPerVisitor} isCurrency newLabel={L.newLabel} />
{currPilgrims!==null && prevPilgrims!==null &&
<MetricCard title={L.pilgrims} curr={currPilgrims} prev={prevPilgrims} newLabel={L.newLabel} />}
{currCapture!==null && prevCapture!==null &&
<MetricCard title={L.captureRate} curr={parseFloat(currCapture.toFixed(2))} prev={parseFloat((prevCapture??0).toFixed(2))} newLabel={L.newLabel} />}
</div>
<div className="alt-charts">
<div className="alt-chart-card">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.trendTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
</div>
</div>
<div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
</div>
<div className="alt-chart-card">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.museumTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
</div>
</div>
<div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div>
</div>
</div>
</div>
);
}