refactor: extract shared locale, date helpers, and components (H6)
~300 lines of code that were independently duplicated in Dashboard.tsx and Comparison.tsx are now in shared modules: - src/lib/locale.ts — LC interface, EN and AR language configs (merged fields from both pages into one unified interface) - src/lib/dateHelpers.ts — MONTH_KEYS, isLeap, makePresets, guessPreset, periodNameL, dateRangeTextL, currentMonth, shiftYear - src/components/shared/PeriodPicker.tsx — InlinePicker + PeriodHero - src/components/shared/AltMultiSelect.tsx — AltMultiSelect - src/components/shared/MetricCard.tsx — MetricCard Dashboard.tsx and Comparison.tsx now import from these shared modules. Zero behavioral changes — all props, ARIA, and render output unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,19 @@ 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, formatCurrency, formatNumber,
|
||||
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[];
|
||||
@@ -19,222 +25,6 @@ interface Props {
|
||||
lang?: 'en' | 'ar';
|
||||
}
|
||||
|
||||
// ─── language config ──────────────────────────────────────────────
|
||||
interface LC {
|
||||
dir: 'ltr' | 'rtl';
|
||||
fontImport: string;
|
||||
bodyFont: string;
|
||||
displayFont: string;
|
||||
monoFont: string;
|
||||
monthFull: string[];
|
||||
monthShort: string[];
|
||||
periods: Record<string, string>;
|
||||
fullYearLabel: (y: number) => string;
|
||||
dateRangeSep: string;
|
||||
backLink: string;
|
||||
backTo: string;
|
||||
pageTitle: string;
|
||||
pageSub: string;
|
||||
currentRole: string; previousRole: string;
|
||||
currentHint: string; previousHint: string;
|
||||
changePeriod: string; close: string; apply: string;
|
||||
vs: string;
|
||||
filter: string;
|
||||
allDistricts: string; allChannels: string; allMuseums: string;
|
||||
countDistricts: (n: number) => string;
|
||||
countChannels: (n: number) => string;
|
||||
countMuseums: (n: number) => string;
|
||||
reset: string;
|
||||
keyMetrics: string;
|
||||
revenue: string; visitors: string; tickets: string; avgRev: string;
|
||||
pilgrims: string; captureRate: string;
|
||||
trendTitle: string; museumTitle: string;
|
||||
daily: string; weekly: string; monthly: string;
|
||||
newLabel: string; clearSel: string;
|
||||
monthSection: string; periodSection: string;
|
||||
from: string; to: string;
|
||||
}
|
||||
|
||||
const EN: LC = {
|
||||
dir: 'ltr',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'Outfit', sans-serif",
|
||||
displayFont: "'DM Serif Display', serif",
|
||||
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
||||
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
||||
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
||||
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
||||
fullYearLabel: (y) => String(y),
|
||||
dateRangeSep: '→',
|
||||
backLink: '← Overview', backTo: '/',
|
||||
pageTitle: 'Period Comparison', pageSub: 'Compare any two periods side by side.',
|
||||
currentRole: 'This period', previousRole: 'Compared to',
|
||||
currentHint: 'primary', previousHint: 'auto year −1',
|
||||
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
||||
vs: 'vs',
|
||||
filter: 'Filter',
|
||||
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
||||
countDistricts: (n) => `${n} districts`,
|
||||
countChannels: (n) => `${n} channels`,
|
||||
countMuseums: (n) => `${n} museums`,
|
||||
reset: 'Reset',
|
||||
keyMetrics: 'Key Metrics',
|
||||
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
||||
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
||||
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
||||
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||
newLabel: 'New', clearSel: 'Clear selection',
|
||||
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
||||
from: 'From', to: 'To',
|
||||
};
|
||||
|
||||
const AR: LC = {
|
||||
dir: 'rtl',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
||||
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
||||
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
||||
fullYearLabel: (y) => `${y} كاملاً`,
|
||||
dateRangeSep: '–',
|
||||
backLink: '← نظرة عامة', backTo: '/ar',
|
||||
pageTitle: 'مقارنة الفترات', pageSub: 'قارن بين فترتين زمنيتين.',
|
||||
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
|
||||
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
|
||||
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
||||
vs: 'مقابل',
|
||||
filter: 'تصفية',
|
||||
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
||||
countDistricts: (n) => `${n} مناطق`,
|
||||
countChannels: (n) => `${n} قنوات`,
|
||||
countMuseums: (n) => `${n} متاحف`,
|
||||
reset: 'إعادة ضبط',
|
||||
keyMetrics: 'المؤشرات الرئيسية',
|
||||
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
||||
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
||||
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
||||
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
||||
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
||||
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
||||
from: 'من', to: 'إلى',
|
||||
};
|
||||
|
||||
// ─── date helpers ─────────────────────────────────────────────────
|
||||
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||
|
||||
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
|
||||
|
||||
function makePresets(y: number): Record<string, { start: string; end: string }> {
|
||||
const feb = isLeap(y) ? 29 : 28;
|
||||
return {
|
||||
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
||||
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
||||
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
||||
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
||||
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
||||
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
||||
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
||||
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
||||
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
||||
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
||||
};
|
||||
}
|
||||
|
||||
function guessPreset(start: string, end: string) {
|
||||
const year = parseInt(start.slice(0,4));
|
||||
const presets = makePresets(year);
|
||||
for (const [key, r] of Object.entries(presets)) {
|
||||
if (r.start===start && r.end===end) return { key, year };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function periodNameL(start: string, end: string, L: LC): string {
|
||||
const year = parseInt(start.slice(0,4));
|
||||
const g = guessPreset(start, end);
|
||||
if (!g) {
|
||||
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
||||
const ey = parseInt(end.slice(0,4));
|
||||
return year===ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
||||
}
|
||||
const mi = MONTH_KEYS.indexOf(g.key);
|
||||
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
||||
if (g.key==='full') return L.fullYearLabel(g.year);
|
||||
return `${L.periods[g.key]??g.key.toUpperCase()} ${g.year}`;
|
||||
}
|
||||
|
||||
function dateRangeTextL(start: string, end: string, L: LC): string {
|
||||
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
||||
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
||||
}
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date(); const y=now.getFullYear(), m=now.getMonth()+1;
|
||||
const p = (n: number) => String(n).padStart(2,'0');
|
||||
return { start:`${y}-${p(m)}-01`, end:`${y}-${p(m)}-${p(new Date(y,m,0).getDate())}` };
|
||||
}
|
||||
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
|
||||
|
||||
// ─── inline picker ────────────────────────────────────────────────
|
||||
function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
onClose: () => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const g = guessPreset(start, end);
|
||||
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
|
||||
const [active, setActive] = useState<string|null>(g?.key ?? null);
|
||||
const [draftStart, setDraftStart] = useState(start);
|
||||
const [draftEnd, setDraftEnd] = useState(end);
|
||||
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
||||
|
||||
const pick = (key: string) => { const r=makePresets(year)[key]; if(!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
||||
const shift = (d: number) => {
|
||||
const ny=year+d; if(ny<minY||ny>maxY) return; setYear(ny);
|
||||
if(active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="alt-picker" id="period-picker-panel">
|
||||
<div className="alt-picker-year">
|
||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<span className="alt-yr-val">{year}</span>
|
||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.monthSection}</p>
|
||||
<div className="alt-chips">
|
||||
{MONTH_KEYS.map((k,i) => (
|
||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.periodSection}</p>
|
||||
<div className="alt-chips">
|
||||
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
||||
))}
|
||||
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-custom">
|
||||
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
||||
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
||||
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-footer">
|
||||
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
||||
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── period card ──────────────────────────────────────────────────
|
||||
function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: {
|
||||
role: string; hint: string; start: string; end: string;
|
||||
@@ -274,70 +64,6 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── multi-select ─────────────────────────────────────────────────
|
||||
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
||||
value: string[]; options: string[];
|
||||
onChange: (vals: string[]) => void;
|
||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if(ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
|
||||
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="altms">
|
||||
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
||||
<span className="altms-label">{label}</span>
|
||||
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" 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>
|
||||
{open && (
|
||||
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
||||
<div className="altms-list">
|
||||
{options.map(opt => (
|
||||
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
|
||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||
<span className="altms-opt-label">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── metric card ──────────────────────────────────────────────────
|
||||
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
||||
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
||||
}) {
|
||||
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
||||
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
|
||||
const isPos = change>0, isNeg = change<0;
|
||||
return (
|
||||
<div className="alt-metric">
|
||||
<p className="alt-metric-title">{title}</p>
|
||||
<div className="alt-metric-value">{fmt(curr)}</div>
|
||||
<div className="alt-metric-footer">
|
||||
{isFinite(change)
|
||||
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
|
||||
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
|
||||
<span className="alt-metric-prev">{fmt(prev)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main page ────────────────────────────────────────────────────
|
||||
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
|
||||
const { lang: activeLang, setLanguage } = useLanguage();
|
||||
|
||||
Reference in New Issue
Block a user