feat: add linear regression trendline to revenue charts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user