feat: season filter + chart bands on Dashboard and Comparison
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s

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) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-31 16:10:49 +03:00
parent ef48372033
commit db6a6ac609
5 changed files with 135 additions and 30 deletions

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -1571,6 +1572,15 @@
"pnpm": ">=8" "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": { "node_modules/chartjs-plugin-datalabels": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",

View File

@@ -8,6 +8,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",

View File

@@ -17,7 +17,7 @@ import {
getMuseumsForDistrict, getMuseumsForDistrict,
getLatestYear getLatestYear
} from '../services/dataService'; } from '../services/dataService';
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types'; import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
interface PresetDateRange { interface PresetDateRange {
start: string; start: string;
@@ -63,7 +63,7 @@ const generatePresetDates = (year: number): PresetDates => ({
'full': { start: `${year}-01-01`, end: `${year}-12-31` } '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 { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -95,7 +95,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].start; 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 [endDate, setEndDateState] = useState(() => {
const urlPreset = searchParams.get('preset'); const urlPreset = searchParams.get('preset');
@@ -105,7 +108,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].end; 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(() => ({ const [filters, setFiltersState] = useState(() => ({
district: searchParams.get('district') || 'all', district: searchParams.get('district') || 'all',
@@ -123,7 +129,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const params = new URLSearchParams(); const params = new URLSearchParams();
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset); if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
if (newYear && newYear !== latestYear) params.set('year', newYear.toString()); 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 (newFrom) params.set('from', newFrom);
if (newTo) params.set('to', newTo); if (newTo) params.set('to', newTo);
} }
@@ -136,7 +142,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const setSelectedYear = (year: number) => { const setSelectedYear = (year: number) => {
setSelectedYearState(year); setSelectedYearState(year);
const newDates = generatePresetDates(year); const newDates = generatePresetDates(year);
if (preset !== 'custom' && newDates[preset]) { if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
setStartDateState(newDates[preset].start); setStartDateState(newDates[preset].start);
setEndDateState(newDates[preset].end); setEndDateState(newDates[preset].end);
} }
@@ -145,7 +151,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const setPreset = (value: string) => { const setPreset = (value: string) => {
setPresetState(value); 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); setStartDateState(presetDates[value].start);
setEndDateState(presetDates[value].end); setEndDateState(presetDates[value].end);
updateUrl(value, null, null, filters, selectedYear); 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]); const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
// Year-over-year comparison: same dates, previous year // Year-over-year comparison: same dates, previous year
const ranges = useMemo(() => ({ // For season presets, try to find the same season name from the previous hijri year
curr: { start: startDate, end: endDate }, const ranges = useMemo(() => {
prev: { const curr = { start: startDate, end: endDate };
let prev = {
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)), start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
end: endDate.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(() => const prevData = useMemo(() =>
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters), filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
@@ -559,9 +589,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
<option value="h1">{t('time.h1')}</option> <option value="h1">{t('time.h1')}</option>
<option value="h2">{t('time.h2')}</option> <option value="h2">{t('time.h2')}</option>
<option value="full">{t('time.fullYear')}</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> </select>
</FilterControls.Group> </FilterControls.Group>
{preset !== 'custom' && ( {preset !== 'custom' && !preset.startsWith('season-') && (
<FilterControls.Group label={t('filters.year')}> <FilterControls.Group label={t('filters.year')}>
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}> <select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
{availableYears.map(y => ( {availableYears.map(y => (
@@ -570,7 +609,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
</select> </select>
</FilterControls.Group> </FilterControls.Group>
)} )}
{preset === 'custom' && ( {(preset === 'custom' || preset.startsWith('season-')) && (
<> <>
<FilterControls.Group label={t('comparison.from')}> <FilterControls.Group label={t('comparison.from')}>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} /> <input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />

View File

@@ -22,7 +22,7 @@ import {
getMuseumsForDistrict, getMuseumsForDistrict,
groupByDistrict groupByDistrict
} from '../services/dataService'; } from '../services/dataService';
import type { DashboardProps, Filters, MuseumRecord } from '../types'; import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
const defaultFilters: Filters = { const defaultFilters: Filters = {
year: 'all', year: 'all',
@@ -34,7 +34,7 @@ const defaultFilters: Filters = {
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter']; 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 { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [pilgrimLoaded, setPilgrimLoaded] = useState(false); const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
@@ -78,12 +78,24 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const [activeStatCard, setActiveStatCard] = useState(0); const [activeStatCard, setActiveStatCard] = useState(0);
const [activeChart, setActiveChart] = useState(0); const [activeChart, setActiveChart] = useState(0);
const [trendGranularity, setTrendGranularity] = useState('week'); const [trendGranularity, setTrendGranularity] = useState('week');
const [selectedSeason, setSelectedSeason] = useState<string>('');
const filteredData = useMemo(() => filterData(data, filters), [data, filters]); 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 // Stat cards for carousel
const statCards = useMemo(() => [ const statCards = useMemo(() => [
@@ -153,11 +165,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}); });
if (trendGranularity === 'week') { if (trendGranularity === 'week') {
const grouped = groupByWeek(filteredData, includeVAT); const grouped = groupByWeek(seasonFilteredData, includeVAT);
const weeks = Object.keys(grouped).filter(w => w).sort(); const weeks = Object.keys(grouped).filter(w => w).sort();
const revenueValues = weeks.map(w => grouped[w].revenue); const revenueValues = weeks.map(w => grouped[w].revenue);
return { return {
labels: weeks.map(formatLabel), labels: weeks.map(formatLabel),
rawDates: weeks,
datasets: [{ datasets: [{
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: revenueValues, data: revenueValues,
@@ -173,7 +186,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
} else { } else {
// Daily granularity // Daily granularity
const dailyData: Record<string, number> = {}; const dailyData: Record<string, number> = {};
filteredData.forEach(row => { seasonFilteredData.forEach(row => {
const date = row.date; const date = row.date;
if (!dailyData[date]) dailyData[date] = 0; if (!dailyData[date]) dailyData[date] = 0;
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0); dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
@@ -182,6 +195,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const revenueValues = days.map(d => dailyData[d]); const revenueValues = days.map(d => dailyData[d]);
return { return {
labels: days.map(formatLabel), labels: days.map(formatLabel),
rawDates: days,
datasets: [{ datasets: [{
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: revenueValues, data: revenueValues,
@@ -195,11 +209,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}, trendlineDataset(revenueValues)] }, trendlineDataset(revenueValues)]
}; };
} }
}, [filteredData, trendGranularity, includeVAT]); }, [seasonFilteredData, trendGranularity, includeVAT]);
// Museum data // Museum data
const museumData = useMemo(() => { const museumData = useMemo(() => {
const grouped = groupByMuseum(filteredData, includeVAT); const grouped = groupByMuseum(seasonFilteredData, includeVAT);
const museums = Object.keys(grouped); const museums = Object.keys(grouped);
return { return {
visitors: { visitors: {
@@ -220,11 +234,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}] }]
} }
}; };
}, [filteredData, includeVAT]); }, [seasonFilteredData, includeVAT]);
// Channel data // Channel data
const channelData = useMemo(() => { const channelData = useMemo(() => {
const grouped = groupByChannel(filteredData, includeVAT); const grouped = groupByChannel(seasonFilteredData, includeVAT);
const channels = Object.keys(grouped); const channels = Object.keys(grouped);
return { return {
labels: channels, labels: channels,
@@ -234,11 +248,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
borderRadius: 4 borderRadius: 4
}] }]
}; };
}, [filteredData, includeVAT]); }, [seasonFilteredData, includeVAT]);
// District data // District data
const districtData = useMemo(() => { const districtData = useMemo(() => {
const grouped = groupByDistrict(filteredData, includeVAT); const grouped = groupByDistrict(seasonFilteredData, includeVAT);
const districtNames = Object.keys(grouped); const districtNames = Object.keys(grouped);
return { return {
labels: districtNames, labels: districtNames,
@@ -248,7 +262,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
borderRadius: 4 borderRadius: 4
}] }]
}; };
}, [filteredData, includeVAT]); }, [seasonFilteredData, includeVAT]);
// Quarterly YoY // Quarterly YoY
const quarterlyYoYData = useMemo(() => { const quarterlyYoYData = useMemo(() => {
@@ -386,6 +400,35 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); 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<string, unknown> = {};
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 ( return (
<div className="dashboard" id="dashboard-container"> <div className="dashboard" id="dashboard-container">
<div className="page-title-with-actions"> <div className="page-title-with-actions">
@@ -450,6 +493,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<option value="4">{t('time.q4')}</option> <option value="4">{t('time.q4')}</option>
</select> </select>
</FilterControls.Group> </FilterControls.Group>
<FilterControls.Group label={t('filters.season')}>
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
<option value="">{t('filters.allSeasons')}</option>
{seasons.map(s => (
<option key={s.Id} value={String(s.Id)}>
{s.Name} {s.HijriYear}
</option>
))}
</select>
</FilterControls.Group>
</FilterControls.Row> </FilterControls.Row>
</FilterControls> </FilterControls>
@@ -543,7 +596,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</div> </div>
} }
> >
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} /> <Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
</ExportableChart> </ExportableChart>
</div> </div>
@@ -636,7 +689,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button> <button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} /> <Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ import {
Filler Filler
} from 'chart.js'; } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels'; import ChartDataLabels from 'chartjs-plugin-datalabels';
import Annotation from 'chartjs-plugin-annotation';
// Register ChartJS components once // Register ChartJS components once
ChartJS.register( ChartJS.register(
@@ -25,7 +26,8 @@ ChartJS.register(
Tooltip, Tooltip,
Legend, Legend,
Filler, Filler,
ChartDataLabels ChartDataLabels,
Annotation
); );
export const chartColors = { export const chartColors = {