From db6a6ac609c174f72323ba245c23a111af45f69b Mon Sep 17 00:00:00 2001 From: fahed Date: Tue, 31 Mar 2026 16:10:49 +0300 Subject: [PATCH] feat: season filter + chart bands on Dashboard and Comparison Dashboard: - Season dropdown filter (filters data by season date range) - Revenue trend chart shows colored annotation bands for each season - All downstream memos use season-filtered data Comparison: - Season presets in period selector (optgroup) - Auto-compares with same season from previous hijri year if defined - Season preset persists start/end dates in URL Added chartjs-plugin-annotation for chart bands. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 10 +++++ package.json | 1 + src/components/Comparison.tsx | 65 +++++++++++++++++++++------ src/components/Dashboard.tsx | 85 ++++++++++++++++++++++++++++------- src/config/chartConfig.ts | 4 +- 5 files changed, 135 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index a310e26..e4574b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", "chart.js": "^4.5.1", + "chartjs-plugin-annotation": "^3.1.0", "chartjs-plugin-datalabels": "^2.2.0", "html2canvas": "^1.4.1", "jszip": "^3.10.1", @@ -1571,6 +1572,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-plugin-annotation": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz", + "integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=4.0.0" + } + }, "node_modules/chartjs-plugin-datalabels": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", diff --git a/package.json b/package.json index 3536db2..0e02a95 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", "chart.js": "^4.5.1", + "chartjs-plugin-annotation": "^3.1.0", "chartjs-plugin-datalabels": "^2.2.0", "html2canvas": "^1.4.1", "jszip": "^3.10.1", diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index 877fd85..6637868 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -17,7 +17,7 @@ import { getMuseumsForDistrict, getLatestYear } from '../services/dataService'; -import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types'; +import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types'; interface PresetDateRange { start: string; @@ -63,7 +63,7 @@ const generatePresetDates = (year: number): PresetDates => ({ 'full': { start: `${year}-01-01`, end: `${year}-12-31` } }); -function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) { +function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) { const { t } = useLanguage(); const [searchParams, setSearchParams] = useSearchParams(); @@ -95,7 +95,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { return dates[urlPreset].start; } - return searchParams.get('from') || `${year}-01-01`; + // 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'); @@ -105,7 +108,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { return dates[urlPreset].end; } - return searchParams.get('to') || `${year}-01-31`; + // 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', @@ -123,7 +129,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const params = new URLSearchParams(); if (newPreset && newPreset !== 'jan') params.set('preset', newPreset); if (newYear && newYear !== latestYear) params.set('year', newYear.toString()); - if (newPreset === 'custom') { + if (newPreset === 'custom' || newPreset.startsWith('season-')) { if (newFrom) params.set('from', newFrom); if (newTo) params.set('to', newTo); } @@ -136,7 +142,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const setSelectedYear = (year: number) => { setSelectedYearState(year); const newDates = generatePresetDates(year); - if (preset !== 'custom' && newDates[preset]) { + if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) { setStartDateState(newDates[preset].start); setEndDateState(newDates[preset].end); } @@ -145,7 +151,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const setPreset = (value: string) => { setPresetState(value); - if (value !== 'custom' && presetDates[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); @@ -227,13 +241,29 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]); // Year-over-year comparison: same dates, previous year - const ranges = useMemo(() => ({ - curr: { start: startDate, end: endDate }, - prev: { + // 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 }; + } + } } - }), [startDate, endDate]); + + return { curr, prev }; + }, [startDate, endDate, preset, seasons]); const prevData = useMemo(() => filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters), @@ -559,9 +589,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn + {seasons.length > 0 && ( + + {seasons.map(s => ( + + ))} + + )} - {preset !== 'custom' && ( + {preset !== 'custom' && !preset.startsWith('season-') && ( )} - {preset === 'custom' && ( + {(preset === 'custom' || preset.startsWith('season-')) && ( <> setStartDate(e.target.value)} /> diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 65ba74f..4c22e2a 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -22,7 +22,7 @@ import { getMuseumsForDistrict, groupByDistrict } from '../services/dataService'; -import type { DashboardProps, Filters, MuseumRecord } from '../types'; +import type { DashboardProps, Filters, MuseumRecord, Season } from '../types'; const defaultFilters: Filters = { year: 'all', @@ -34,7 +34,7 @@ const defaultFilters: Filters = { const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter']; -function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) { +function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) { const { t } = useLanguage(); const [searchParams, setSearchParams] = useSearchParams(); const [pilgrimLoaded, setPilgrimLoaded] = useState(false); @@ -78,12 +78,24 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc const [activeStatCard, setActiveStatCard] = useState(0); const [activeChart, setActiveChart] = useState(0); const [trendGranularity, setTrendGranularity] = useState('week'); + const [selectedSeason, setSelectedSeason] = useState(''); const filteredData = useMemo(() => filterData(data, filters), [data, filters]); - const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]); - const hasData = filteredData.length > 0; - const resetFilters = () => setFilters(defaultFilters); + const seasonFilteredData = useMemo(() => { + if (!selectedSeason) return filteredData; + const season = seasons.find(s => String(s.Id) === selectedSeason); + if (!season) return filteredData; + return filteredData.filter(row => row.date >= season.StartDate && row.date <= season.EndDate); + }, [filteredData, selectedSeason, seasons]); + + const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]); + const hasData = seasonFilteredData.length > 0; + + const resetFilters = () => { + setFilters(defaultFilters); + setSelectedSeason(''); + }; // Stat cards for carousel const statCards = useMemo(() => [ @@ -153,11 +165,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc }); if (trendGranularity === 'week') { - const grouped = groupByWeek(filteredData, includeVAT); + const grouped = groupByWeek(seasonFilteredData, includeVAT); const weeks = Object.keys(grouped).filter(w => w).sort(); const revenueValues = weeks.map(w => grouped[w].revenue); return { labels: weeks.map(formatLabel), + rawDates: weeks, datasets: [{ label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', data: revenueValues, @@ -173,7 +186,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc } else { // Daily granularity const dailyData: Record = {}; - filteredData.forEach(row => { + seasonFilteredData.forEach(row => { const date = row.date; if (!dailyData[date]) dailyData[date] = 0; dailyData[date] += Number((row as unknown as Record)[revenueField] || 0); @@ -182,6 +195,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc const revenueValues = days.map(d => dailyData[d]); return { labels: days.map(formatLabel), + rawDates: days, datasets: [{ label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', data: revenueValues, @@ -195,11 +209,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc }, trendlineDataset(revenueValues)] }; } - }, [filteredData, trendGranularity, includeVAT]); + }, [seasonFilteredData, trendGranularity, includeVAT]); // Museum data const museumData = useMemo(() => { - const grouped = groupByMuseum(filteredData, includeVAT); + const grouped = groupByMuseum(seasonFilteredData, includeVAT); const museums = Object.keys(grouped); return { visitors: { @@ -220,11 +234,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc }] } }; - }, [filteredData, includeVAT]); + }, [seasonFilteredData, includeVAT]); // Channel data const channelData = useMemo(() => { - const grouped = groupByChannel(filteredData, includeVAT); + const grouped = groupByChannel(seasonFilteredData, includeVAT); const channels = Object.keys(grouped); return { labels: channels, @@ -234,11 +248,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc borderRadius: 4 }] }; - }, [filteredData, includeVAT]); + }, [seasonFilteredData, includeVAT]); // District data const districtData = useMemo(() => { - const grouped = groupByDistrict(filteredData, includeVAT); + const grouped = groupByDistrict(seasonFilteredData, includeVAT); const districtNames = Object.keys(grouped); return { labels: districtNames, @@ -248,7 +262,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc borderRadius: 4 }] }; - }, [filteredData, includeVAT]); + }, [seasonFilteredData, includeVAT]); // Quarterly YoY const quarterlyYoYData = useMemo(() => { @@ -386,6 +400,35 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); + // Season annotation bands for revenue trend chart + const seasonAnnotations = useMemo(() => { + const raw = trendData.rawDates; + if (!seasons.length || !raw?.length) return {}; + const annotations: Record = {}; + seasons.forEach((s, i) => { + const startIdx = raw.findIndex(d => d >= s.StartDate); + const endIdx = raw.length - 1 - [...raw].reverse().findIndex(d => d <= s.EndDate); + if (startIdx === -1 || endIdx < startIdx) return; + annotations[`season${i}`] = { + type: 'box', + xMin: startIdx - 0.5, + xMax: endIdx + 0.5, + backgroundColor: s.Color + '20', + borderColor: s.Color + '40', + borderWidth: 1, + label: { + display: true, + content: `${s.Name} ${s.HijriYear}`, + position: 'start', + color: s.Color, + font: { size: 10, weight: '600' }, + padding: 4 + } + }; + }); + return annotations; + }, [seasons, trendData.rawDates]); + return (
@@ -450,6 +493,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc + + + @@ -543,7 +596,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
} > - +
@@ -636,7 +689,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
- +
diff --git a/src/config/chartConfig.ts b/src/config/chartConfig.ts index ce75675..86b6cb2 100644 --- a/src/config/chartConfig.ts +++ b/src/config/chartConfig.ts @@ -12,6 +12,7 @@ import { Filler } from 'chart.js'; import ChartDataLabels from 'chartjs-plugin-datalabels'; +import Annotation from 'chartjs-plugin-annotation'; // Register ChartJS components once ChartJS.register( @@ -25,7 +26,8 @@ ChartJS.register( Tooltip, Legend, Filler, - ChartDataLabels + ChartDataLabels, + Annotation ); export const chartColors = {