feat: season filter + chart bands on Dashboard and Comparison
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user