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

View File

@@ -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<string>('');
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<string, number> = {};
filteredData.forEach(row => {
seasonFilteredData.forEach(row => {
const date = row.date;
if (!dailyData[date]) dailyData[date] = 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]);
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<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 (
<div className="dashboard" id="dashboard-container">
<div className="page-title-with-actions">
@@ -450,6 +493,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<option value="4">{t('time.q4')}</option>
</select>
</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>
@@ -543,7 +596,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</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>
</div>
@@ -636,7 +689,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
</div>
<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>