feat: add linear regression trendline to revenue charts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-02-09 10:56:49 +03:00
parent a71a741f76
commit e92f11241d

View File

@@ -106,14 +106,44 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}; };
// Linear regression helper
const linearRegression = (values: number[]) => {
const n = values.length;
if (n < 2) return values;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += values[i];
sumXY += i * values[i];
sumX2 += i * i;
}
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
return values.map((_, i) => slope * i + intercept);
};
const trendlineDataset = (values: number[]) => ({
label: 'Trend',
data: linearRegression(values),
borderColor: chartColors.secondary,
borderWidth: 2,
borderDash: [6, 4],
tension: 0,
fill: false,
pointRadius: 0,
pointHoverRadius: 0,
datalabels: { display: false }
});
if (trendGranularity === 'week') { if (trendGranularity === 'week') {
const grouped = groupByWeek(filteredData, includeVAT); const grouped = groupByWeek(filteredData, 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);
return { return {
labels: weeks.map(formatLabel), labels: weeks.map(formatLabel),
datasets: [{ datasets: [{
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: weeks.map(w => grouped[w].revenue), data: revenueValues,
borderColor: chartColors.primary, borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '10', backgroundColor: chartColors.primary + '10',
borderWidth: 2, borderWidth: 2,
@@ -121,7 +151,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
fill: true, fill: true,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 4 pointHoverRadius: 4
}] }, trendlineDataset(revenueValues)]
}; };
} else { } else {
// Daily granularity // Daily granularity
@@ -132,11 +162,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
dailyData[date] += Number(row[revenueField] || row.revenue_incl_tax || 0); dailyData[date] += Number(row[revenueField] || row.revenue_incl_tax || 0);
}); });
const days = Object.keys(dailyData).sort(); const days = Object.keys(dailyData).sort();
const revenueValues = days.map(d => dailyData[d]);
return { return {
labels: days.map(formatLabel), labels: days.map(formatLabel),
datasets: [{ datasets: [{
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: days.map(d => dailyData[d]), data: revenueValues,
borderColor: chartColors.primary, borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '10', backgroundColor: chartColors.primary + '10',
borderWidth: 1.5, borderWidth: 1.5,
@@ -144,7 +175,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
fill: true, fill: true,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 3 pointHoverRadius: 3
}] }, trendlineDataset(revenueValues)]
}; };
} }
}, [filteredData, trendGranularity, includeVAT]); }, [filteredData, trendGranularity, includeVAT]);
@@ -465,7 +496,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</div> </div>
} }
> >
<Line data={trendData} options={{...baseOptions, 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, 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>
@@ -556,7 +587,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, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} /> <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}}}}} />
</div> </div>
</div> </div>
</div> </div>