From 0f6881309ca0118200d64917c66563ee4fa56e84 Mon Sep 17 00:00:00 2001 From: fahed Date: Sun, 19 Apr 2026 15:02:06 +0300 Subject: [PATCH] feat: replace year/quarter filters with free date range pickers 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 --- src/components/Comparison.tsx | 276 +++++++------------------ src/components/Dashboard.tsx | 81 ++++---- src/components/Slides.tsx | 12 +- src/components/shared/PeriodPicker.tsx | 147 +++++++++++++ src/components/shared/index.tsx | 1 + src/services/dataService.ts | 2 - src/types/index.ts | 4 +- 7 files changed, 270 insertions(+), 253 deletions(-) create mode 100644 src/components/shared/PeriodPicker.tsx diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index 7404c8d..69f070a 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -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(); 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(() => { - 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 - + - - - - {preset !== 'custom' && !preset.startsWith('season-') && ( - - - - )} - {(preset === 'custom' || preset.startsWith('season-')) && ( - <> - - setStartDate(e.target.value)} /> - - - setEndDate(e.target.value)} /> - - - )} + setFilters({...filters, year: e.target.value})}> - - {years.map(y => )} - - + setFilters({ ...filters, startDate: start, endDate: end })} + availableYears={years.map(Number)} + seasons={seasons} + /> setFilters({...filters, quarter: e.target.value})}> - - - - - - - handlePreset(e.target.value)}> + + + + + + + + + + + + + + + + + + + + + {seasons.length > 0 && ( + + {seasons.map(s => ( + + ))} + + )} + + + + {preset !== 'custom' && !preset.startsWith('season-') && availableYears.length > 0 && ( +
+ + +
+ )} + + {(preset === 'custom' || preset.startsWith('season-')) && ( + <> +
+ + handleStart(e.target.value)} /> +
+
+ + handleEnd(e.target.value)} /> +
+ + )} + + ); +} diff --git a/src/components/shared/index.tsx b/src/components/shared/index.tsx index 164b82d..5772b01 100644 --- a/src/components/shared/index.tsx +++ b/src/components/shared/index.tsx @@ -5,3 +5,4 @@ export { default as FilterControls } from './FilterControls'; export { default as MultiSelect } from './MultiSelect'; export { default as StatCard } from './StatCard'; export { default as ToggleSwitch } from './ToggleSwitch'; +export { default as PeriodPicker } from './PeriodPicker'; diff --git a/src/services/dataService.ts b/src/services/dataService.ts index 25a8660..f10fbef 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -273,11 +273,9 @@ export async function refreshData(): Promise { export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] { return data.filter(row => { - if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false; if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false; if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false; if (filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false; - if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false; return true; }); } diff --git a/src/types/index.ts b/src/types/index.ts index c1f9249..a1220e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,11 +21,11 @@ export interface Metrics { } export interface Filters { - year: string; + startDate: string; + endDate: string; district: string; channel: string[]; museum: string[]; - quarter: string; } export interface DateRangeFilters {