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:
@@ -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<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`},
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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: 'مقابل',
|
||||
};
|
||||
Reference in New Issue
Block a user