feat: replace year/quarter filters with free date range pickers
Deploy HiHala Dashboard / deploy (push) Successful in 8s

Dashboard: PeriodPicker replaces year + quarter dropdowns. Defaults to
current month. YoY stat card now compares same range vs previous year.

Comparison: two independent PeriodPicker blocks (Period A and Period B).
Changing Period A auto-updates Period B to same period previous year,
but Period B remains freely editable.

Both pages use filterDataByDateRange; Filters type drops year/quarter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-04-19 15:02:06 +03:00
parent 9064df82be
commit 0f6881309c
7 changed files with 270 additions and 253 deletions
+76 -200
View File
@@ -1,7 +1,7 @@
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 { EmptyState, FilterControls, MultiSelect, PeriodPicker } from './shared';
import { ExportableChart } from './ChartExport';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
@@ -14,19 +14,10 @@ import {
getUniqueChannels,
getUniqueMuseums,
getUniqueDistricts,
getMuseumsForDistrict,
getLatestYear
getMuseumsForDistrict
} from '../services/dataService';
import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
interface PresetDateRange {
start: string;
end: string;
}
interface PresetDates {
[key: string]: PresetDateRange;
}
interface MetricCardProps {
title: string;
@@ -40,28 +31,18 @@ interface MetricCardProps {
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 currentMonthRange() {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const pad = (n: number) => String(n).padStart(2, '0');
const lastDay = new Date(y, m, 0).getDate();
return { start: `${y}-${pad(m)}-01`, end: `${y}-${pad(m)}-${pad(lastDay)}` };
}
function shiftYearBack(dateStr: string): string {
return dateStr.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
}
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) {
const { t } = useLanguage();
@@ -76,52 +57,25 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
return d;
}, [data, allowedMuseums, allowedChannels]);
// Get available years from data
const latestYear = useMemo(() => parseInt(getLatestYear(permissionFilteredData)), [permissionFilteredData]);
const availableYears = useMemo((): number[] => {
const yearsSet = new Set<number>();
permissionFilteredData.forEach((r: MuseumRecord) => {
const d = r.date || (r as any).Date;
if (d) yearsSet.add(new Date(d).getFullYear());
if (r.date) yearsSet.add(parseInt(r.date.slice(0, 4)));
});
const years = Array.from(yearsSet).sort((a, b) => b - a);
return years.length ? years : [new Date().getFullYear()];
}, [data]);
}, [permissionFilteredData]);
// 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 defaultCurr = currentMonthRange();
// Period A (current) — user selects freely
const [currStart, setCurrStartState] = useState(() => searchParams.get('aStart') || defaultCurr.start);
const [currEnd, setCurrEndState] = useState(() => searchParams.get('aEnd') || defaultCurr.end);
// Period B (comparison) — defaults to same period previous year, freely editable
const [prevStart, setPrevStartState] = useState(() => searchParams.get('bStart') || shiftYearBack(defaultCurr.start));
const [prevEnd, setPrevEndState] = useState(() => searchParams.get('bEnd') || shiftYearBack(defaultCurr.end));
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;
}
// Season presets store from/to in URL
const fromParam = searchParams.get('from');
if (fromParam) return fromParam;
return `${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;
}
// Season presets store from/to in URL
const toParam = searchParams.get('to');
if (toParam) return toParam;
return `${year}-01-31`;
});
const [filters, setFiltersState] = useState(() => ({
district: searchParams.get('district') || 'all',
channel: searchParams.get('channel')?.split(',').filter(Boolean) || [],
@@ -133,64 +87,39 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
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 updateUrl = useCallback((aStart: string, aEnd: string, bStart: string, bEnd: string, f: DateRangeFilters) => {
const params = new URLSearchParams();
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
if (newPreset === 'custom' || newPreset.startsWith('season-')) {
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(','));
params.set('aStart', aStart);
params.set('aEnd', aEnd);
params.set('bStart', bStart);
params.set('bEnd', bEnd);
if (f.district !== 'all') params.set('district', f.district);
if (f.channel.length > 0) params.set('channel', f.channel.join(','));
if (f.museum.length > 0) params.set('museum', f.museum.join(','));
setSearchParams(params, { replace: true });
}, [setSearchParams, latestYear]);
}, [setSearchParams]);
const setSelectedYear = (year: number) => {
setSelectedYearState(year);
const newDates = generatePresetDates(year);
if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
setStartDateState(newDates[preset].start);
setEndDateState(newDates[preset].end);
}
updateUrl(preset, null, null, filters, year);
// When Period A changes, auto-update Period B to same period previous year
const handleCurrChange = (start: string, end: string) => {
const newPrevStart = shiftYearBack(start);
const newPrevEnd = shiftYearBack(end);
setCurrStartState(start);
setCurrEndState(end);
setPrevStartState(newPrevStart);
setPrevEndState(newPrevEnd);
updateUrl(start, end, newPrevStart, newPrevEnd, filters);
};
const setPreset = (value: string) => {
setPresetState(value);
if (value.startsWith('season-')) {
const seasonId = parseInt(value.replace('season-', ''));
const season = seasons.find(s => s.Id === seasonId);
if (season) {
setStartDateState(season.StartDate);
setEndDateState(season.EndDate);
updateUrl(value, season.StartDate, season.EndDate, filters, selectedYear);
}
} else 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 handlePrevChange = (start: string, end: string) => {
setPrevStartState(start);
setPrevEndState(end);
updateUrl(currStart, currEnd, start, end, filters);
};
const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
setFiltersState(updated);
updateUrl(preset, startDate, endDate, updated, selectedYear);
updateUrl(currStart, currEnd, prevStart, prevEnd, updated);
};
const charts = [
@@ -249,39 +178,19 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
// Year-over-year comparison: same dates, previous year
// For season presets, try to find the same season name from the previous hijri year
const ranges = useMemo(() => {
const curr = { start: startDate, end: endDate };
let 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))
};
if (preset.startsWith('season-')) {
const seasonId = parseInt(preset.replace('season-', ''));
const currentSeason = seasons.find(s => s.Id === seasonId);
if (currentSeason) {
const prevSeason = seasons.find(
s => s.Name === currentSeason.Name && s.HijriYear === currentSeason.HijriYear - 1
);
if (prevSeason) {
prev = { start: prevSeason.StartDate, end: prevSeason.EndDate };
}
}
}
return { curr, prev };
}, [startDate, endDate, preset, seasons]);
const ranges = useMemo(() => ({
curr: { start: currStart, end: currEnd },
prev: { start: prevStart, end: prevEnd },
}), [currStart, currEnd, prevStart, prevEnd]);
const prevData = useMemo(() =>
filterDataByDateRange(permissionFilteredData, ranges.prev.start, ranges.prev.end, filters),
[permissionFilteredData, ranges.prev, filters]
filterDataByDateRange(permissionFilteredData, prevStart, prevEnd, filters),
[permissionFilteredData, prevStart, prevEnd, filters]
);
const currData = useMemo(() =>
filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters),
[permissionFilteredData, ranges.curr, filters]
filterDataByDateRange(permissionFilteredData, currStart, currEnd, filters),
[permissionFilteredData, currStart, currEnd, filters]
);
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
@@ -616,60 +525,15 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
</div>
</div>
<FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
<FilterControls title={t('comparison.currentPeriod')} onReset={null}>
<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>
{seasons.length > 0 && (
<optgroup label={t('comparison.seasons') || 'Seasons'}>
{seasons.map(s => (
<option key={s.Id} value={`season-${s.Id}`}>
{s.Name} {s.HijriYear}
</option>
))}
</optgroup>
)}
</select>
</FilterControls.Group>
{preset !== 'custom' && !preset.startsWith('season-') && (
<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' || preset.startsWith('season-')) && (
<>
<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>
</>
)}
<PeriodPicker
startDate={currStart}
endDate={currEnd}
onChange={handleCurrChange}
availableYears={availableYears}
seasons={seasons}
/>
<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>
@@ -695,6 +559,18 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
</FilterControls.Row>
</FilterControls>
<FilterControls title={t('comparison.previousPeriod')} onReset={null}>
<FilterControls.Row>
<PeriodPicker
startDate={prevStart}
endDate={prevEnd}
onChange={handlePrevChange}
availableYears={availableYears}
seasons={seasons}
/>
</FilterControls.Row>
</FilterControls>
<div className="period-display-banner" id="comparison-period">
<div className="period-box prev">
<div className="period-label">{t('comparison.previousPeriod')}</div>