From 30cdb5064a10f8a2a378dbbcf2fe3888645eda77 Mon Sep 17 00:00:00 2001 From: fahed Date: Sun, 26 Apr 2026 17:53:35 +0300 Subject: [PATCH] refactor: extract shared locale, date helpers, and components (H6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~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 --- src/components/Comparison.tsx | 288 +------------------- src/components/Dashboard.tsx | 329 +---------------------- src/components/shared/AltMultiSelect.tsx | 44 +++ src/components/shared/MetricCard.tsx | 23 ++ src/components/shared/PeriodPicker.tsx | 95 +++++++ src/lib/dateHelpers.ts | 63 +++++ src/lib/locale.ts | 139 ++++++++++ 7 files changed, 378 insertions(+), 603 deletions(-) create mode 100644 src/components/shared/AltMultiSelect.tsx create mode 100644 src/components/shared/MetricCard.tsx create mode 100644 src/components/shared/PeriodPicker.tsx create mode 100644 src/lib/dateHelpers.ts create mode 100644 src/lib/locale.ts diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index d819b9a..50c33cf 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -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; - 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 { - 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(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(nymaxY) return; setYear(ny); - if(active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); } - }; - - return ( -
-
- - {year} - -
-

{L.monthSection}

-
- {MONTH_KEYS.map((k,i) => ( - - ))} -
-

{L.periodSection}

-
- {['q1','q2','q3','q4','h1','h2'].map(k => ( - - ))} - -
-
-
-
{ setActive(null); setDraftStart(e.target.value); }} />
- {L.dateRangeSep} -
{ setActive(null); setDraftEnd(e.target.value); }} />
-
-
-
- - -
-
- ); -} - // ─── 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(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 ( -
- - {open && ( -
-
- {options.map(opt => ( - - ))} -
- {value.length>0 && } -
- )} -
- ); -} - -// ─── 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 ( -
-

{title}

-
{fmt(curr)}
-
- {isFinite(change) - ? {isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}% - : {newLabel??'New'}} - {fmt(prev)} -
-
- ); -} - // ─── main page ──────────────────────────────────────────────────── export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) { const { lang: activeLang, setLanguage } = useLanguage(); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 65ba794..2b732da 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,8 +1,7 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState, useMemo, useCallback } from 'react'; import { Line, Bar, Pie } from 'react-chartjs-2'; import { - filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber, + filterDataByDateRange, calculateMetrics, getUniqueChannels, getUniqueMuseums, getUniqueDistricts, groupByMuseum, groupByChannel, groupByDistrict, umrahData, @@ -10,6 +9,11 @@ import { import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig'; import type { MuseumRecord, Season } from '../types'; import { useLanguage } from '../contexts/LanguageContext'; +import { EN, AR } from '../lib/locale'; +import { currentMonth, shiftYear } from '../lib/dateHelpers'; +import PeriodHero from './shared/PeriodPicker'; +import AltMultiSelect from './shared/AltMultiSelect'; +import MetricCard from './shared/MetricCard'; interface Props { data: MuseumRecord[]; @@ -21,325 +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; - fullYearLabel: (y: number) => string; - dateRangeSep: string; - backLink: string; - backTo: string; - pageTitle: string; - pageSub: string; - changePeriod: string; - close: string; - apply: string; - filter: string; - allDistricts: string; allChannels: string; allMuseums: string; - countDistricts: (n: number) => string; - countChannels: (n: number) => string; - countMuseums: (n: number) => string; - reset: string; - exclVAT: string; inclVAT: string; - keyMetrics: string; - revenue: string; visitors: string; tickets: string; avgRev: string; - pilgrims: string; captureRate: string; - charts: string; - trendTitle: string; museumTitle: string; channelTitle: string; districtTitle: string; - daily: string; weekly: string; monthly: string; - newLabel: string; - clearSel: string; - monthSection: string; periodSection: string; - from: string; to: string; - vsLabel: string; - barLabel: string; pieLabel: string; - absLabel: string; pctLabel: 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: 'Back to Dashboard', backTo: '/', - pageTitle: 'Overview', pageSub: 'Museum performance at a glance.', - changePeriod: 'Change period', close: 'Cancel', apply: 'Apply', - 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', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT', - keyMetrics: 'Key Metrics', - revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets', - avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %', - charts: 'Charts', - trendTitle: 'Trend over time', museumTitle: 'By museum', - channelTitle: 'By channel', districtTitle: 'By district', - daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', - newLabel: 'New', clearSel: 'Clear selection', - monthSection: 'Month', periodSection: 'Quarter · Half · Year', - from: 'From', to: 'To', vsLabel: 'vs', - barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%', -}; - -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: 'أداء المتاحف في لمحة.', - changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق', - filter: 'تصفية', - allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف', - countDistricts: (n) => `${n} مناطق`, - countChannels: (n) => `${n} قنوات`, - countMuseums: (n) => `${n} متاحف`, - reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة', - keyMetrics: 'المؤشرات الرئيسية', - revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر', - avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %', - charts: 'المخططات', - trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف', - channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة', - daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري', - newLabel: 'جديد', clearSel: 'مسح التحديد', - monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة', - from: 'من', to: 'إلى', vsLabel: 'مقابل', - barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%', -}; - -// ─── 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 { - 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(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 ( -
-
- - {year} - -
-

{L.monthSection}

-
- {MONTH_KEYS.map((k,i) => ( - - ))} -
-

{L.periodSection}

-
- {['q1','q2','q3','q4','h1','h2'].map(k => ( - - ))} - -
-
-
-
{ setActive(null); setDraftStart(e.target.value); }} />
- {L.dateRangeSep} -
{ setActive(null); setDraftEnd(e.target.value); }} />
-
-
-
- - -
-
- ); -} - -// ─── period hero ────────────────────────────────────────────────── -function PeriodHero({ start, end, onChange, availableYears, L }: { - start: string; end: string; onChange: (s: string, e: string) => void; - availableYears: number[]; L: LC; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(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 ( -
-
-
-
{periodNameL(start, end, L)}
-
{dateRangeTextL(start, end, L)}
-
- -
- {open && setOpen(false)} availableYears={availableYears} L={L} />} -
- ); -} - -// ─── 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(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 ( -
- - {open && ( -
-
- {options.map(opt => ( - - ))} -
- {value.length>0 && } -
- )} -
- ); -} - -// ─── 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 ( -
-

{title}

-
{fmt(curr)}
-
- {isFinite(change) - ? {isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}% - : {newLabel??'New'}} - {fmt(prev)} -
-
- ); -} - // ─── main page ──────────────────────────────────────────────────── export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) { const { lang: activeLang, setLanguage } = useLanguage(); diff --git a/src/components/shared/AltMultiSelect.tsx b/src/components/shared/AltMultiSelect.tsx new file mode 100644 index 0000000..43b7e1b --- /dev/null +++ b/src/components/shared/AltMultiSelect.tsx @@ -0,0 +1,44 @@ +import React, { useState, useRef, useEffect } from 'react'; + +// ─── multi-select ───────────────────────────────────────────────── +export default 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(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 ( +
+ + {open && ( +
+
+ {options.map(opt => ( + + ))} +
+ {value.length > 0 && } +
+ )} +
+ ); +} diff --git a/src/components/shared/MetricCard.tsx b/src/components/shared/MetricCard.tsx new file mode 100644 index 0000000..a32dded --- /dev/null +++ b/src/components/shared/MetricCard.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { formatCurrency, formatNumber } from '../../services/dataService'; + +// ─── metric card ────────────────────────────────────────────────── +export default 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 ( +
+

{title}

+
{fmt(curr)}
+
+ {isFinite(change) + ? {isPos ? '▲' : isNeg ? '▼' : '—'} {Math.abs(change).toFixed(1)}% + : {newLabel ?? 'New'}} + {fmt(prev)} +
+
+ ); +} diff --git a/src/components/shared/PeriodPicker.tsx b/src/components/shared/PeriodPicker.tsx new file mode 100644 index 0000000..a79691f --- /dev/null +++ b/src/components/shared/PeriodPicker.tsx @@ -0,0 +1,95 @@ +import React, { useState, useRef, useEffect } from 'react'; +import type { LC } from '../../lib/locale'; +import { MONTH_KEYS, makePresets, guessPreset, periodNameL, dateRangeTextL } from '../../lib/dateHelpers'; + +// ─── inline picker ──────────────────────────────────────────────── +export 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(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 ( +
+
+ + {year} + +
+

{L.monthSection}

+
+ {MONTH_KEYS.map((k, i) => ( + + ))} +
+

{L.periodSection}

+
+ {['q1','q2','q3','q4','h1','h2'].map(k => ( + + ))} + +
+
+
+
{ setActive(null); setDraftStart(e.target.value); }} />
+ {L.dateRangeSep} +
{ setActive(null); setDraftEnd(e.target.value); }} />
+
+
+
+ + +
+
+ ); +} + +// ─── period hero ────────────────────────────────────────────────── +export default function PeriodHero({ start, end, onChange, availableYears, L }: { + start: string; end: string; onChange: (s: string, e: string) => void; + availableYears: number[]; L: LC; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+
+
+
{periodNameL(start, end, L)}
+
{dateRangeTextL(start, end, L)}
+
+ +
+ {open && setOpen(false)} availableYears={availableYears} L={L} />} +
+ ); +} diff --git a/src/lib/dateHelpers.ts b/src/lib/dateHelpers.ts new file mode 100644 index 0000000..d26e529 --- /dev/null +++ b/src/lib/dateHelpers.ts @@ -0,0 +1,63 @@ +import type { LC } from './locale'; + +// ─── date helpers ───────────────────────────────────────────────── + +export const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; + +export function isLeap(y: number): boolean { + return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; +} + +export function makePresets(y: number): Record { + 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`}, + }; +} + +export function guessPreset(start: string, end: string): { key: string; year: number } | null { + 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; +} + +export 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}`; +} + +export 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)}`; +} + +export function currentMonth(): { start: string; end: string } { + 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())}` }; +} + +export function shiftYear(s: string): string { + return s.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1)); +} diff --git a/src/lib/locale.ts b/src/lib/locale.ts new file mode 100644 index 0000000..ca908bf --- /dev/null +++ b/src/lib/locale.ts @@ -0,0 +1,139 @@ +// ─── language config ────────────────────────────────────────────── +// Shared LC interface used by Dashboard and Comparison. +// Fields marked with a comment are only consumed by one page but kept +// here so both components share a single type. +export interface LC { + dir: 'ltr' | 'rtl'; + /** @deprecated Fonts are now loaded from index.html; kept for compatibility */ + fontImport: string; + bodyFont: string; + displayFont: string; + monoFont: string; + monthFull: string[]; + monthShort: string[]; + periods: Record; + fullYearLabel: (y: number) => string; + dateRangeSep: string; + backLink: string; + backTo: string; + pageTitle: string; + pageSub: string; + // Dashboard + changePeriod: string; + close: string; + apply: string; + filter: string; + allDistricts: string; + allChannels: string; + allMuseums: string; + countDistricts: (n: number) => string; + countChannels: (n: number) => string; + countMuseums: (n: number) => string; + reset: string; + exclVAT: string; + inclVAT: string; + keyMetrics: string; + revenue: string; + visitors: string; + tickets: string; + avgRev: string; + pilgrims: string; + captureRate: string; + charts: string; + trendTitle: string; + museumTitle: string; + channelTitle: string; + districtTitle: string; + daily: string; + weekly: string; + monthly: string; + newLabel: string; + clearSel: string; + monthSection: string; + periodSection: string; + from: string; + to: string; + vsLabel: string; + barLabel: string; + pieLabel: string; + absLabel: string; + pctLabel: string; + // Comparison-specific + currentRole: string; + previousRole: string; + currentHint: string; + previousHint: string; + vs: string; +} + +export 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: 'Back to Dashboard', backTo: '/', + pageTitle: 'Overview', pageSub: 'Museum performance at a glance.', + changePeriod: 'Change period', close: 'Cancel', apply: 'Apply', + 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', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT', + keyMetrics: 'Key Metrics', + revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets', + avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %', + charts: 'Charts', + trendTitle: 'Trend over time', museumTitle: 'By museum', + channelTitle: 'By channel', districtTitle: 'By district', + daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', + newLabel: 'New', clearSel: 'Clear selection', + monthSection: 'Month', periodSection: 'Quarter · Half · Year', + from: 'From', to: 'To', vsLabel: 'vs', + barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%', + currentRole: 'This period', previousRole: 'Compared to', + currentHint: 'primary', previousHint: 'auto year −1', + vs: 'vs', +}; + +export 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: 'أداء المتاحف في لمحة.', + changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق', + filter: 'تصفية', + allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف', + countDistricts: (n) => `${n} مناطق`, + countChannels: (n) => `${n} قنوات`, + countMuseums: (n) => `${n} متاحف`, + reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة', + keyMetrics: 'المؤشرات الرئيسية', + revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر', + avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %', + charts: 'المخططات', + trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف', + channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة', + daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري', + newLabel: 'جديد', clearSel: 'مسح التحديد', + monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة', + from: 'من', to: 'إلى', vsLabel: 'مقابل', + barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%', + currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ', + currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة', + vs: 'مقابل', +};