Skip to content

Commit 7e4b394

Browse files
deadlyjackAjit Kumar
andauthored
fix: prevent mobile admin chart crashes (#69)
Co-authored-by: Ajit Kumar <dellevenjack@gmail>
1 parent d38aeea commit 7e4b394

4 files changed

Lines changed: 261 additions & 49 deletions

File tree

client/lib/dashboardCharts.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
function hasFinitePosition(element) {
2+
return Number.isFinite(element?.x) && Number.isFinite(element?.y);
3+
}
4+
5+
export function drawDoughnutPercentLabels(chart, total) {
6+
const { ctx } = chart;
7+
const dataset = chart.data.datasets[0];
8+
const meta = chart.getDatasetMeta(0);
9+
10+
ctx.save();
11+
try {
12+
ctx.font = 'bold 14px sans-serif';
13+
ctx.textAlign = 'center';
14+
ctx.textBaseline = 'middle';
15+
16+
for (const [index, arc] of meta.data.entries()) {
17+
const value = Number(dataset.data[index]);
18+
const isVisible = typeof chart.getDataVisibility !== 'function' || chart.getDataVisibility(index);
19+
if (!Number.isFinite(value) || value === 0 || !isVisible || arc?.hidden || typeof arc?.tooltipPosition !== 'function') continue;
20+
21+
const position = arc.tooltipPosition(true);
22+
if (!hasFinitePosition(position)) continue;
23+
24+
const percentage = Math.round((value / total) * 100);
25+
ctx.fillStyle = '#ffffff';
26+
ctx.shadowColor = 'rgba(0,0,0,0.6)';
27+
ctx.shadowBlur = 3;
28+
ctx.fillText(`${percentage}%`, position.x, position.y);
29+
ctx.shadowBlur = 0;
30+
}
31+
} finally {
32+
ctx.restore();
33+
}
34+
}
35+
36+
export function drawBarValueLabels(chart, formatValue) {
37+
const { ctx } = chart;
38+
39+
ctx.save();
40+
try {
41+
ctx.font = 'bold 11px sans-serif';
42+
ctx.textAlign = 'center';
43+
ctx.textBaseline = 'bottom';
44+
ctx.fillStyle = '#ffffff';
45+
ctx.shadowColor = 'rgba(0,0,0,0.5)';
46+
ctx.shadowBlur = 2;
47+
48+
for (const [datasetIndex, dataset] of chart.data.datasets.entries()) {
49+
const meta = chart.getDatasetMeta(datasetIndex);
50+
const isVisible = typeof chart.isDatasetVisible !== 'function' || chart.isDatasetVisible(datasetIndex);
51+
if (!isVisible || meta.hidden) continue;
52+
53+
for (const [index, bar] of meta.data.entries()) {
54+
const value = Number(dataset.data[index]);
55+
if (!Number.isFinite(value) || value === 0 || bar?.hidden || !hasFinitePosition(bar)) continue;
56+
ctx.fillText(formatValue(value), bar.x, bar.y - 2);
57+
}
58+
}
59+
60+
ctx.shadowBlur = 0;
61+
} finally {
62+
ctx.restore();
63+
}
64+
}
65+
66+
export function createChartSafely({ createChart, previousChart, onError }) {
67+
try {
68+
previousChart?.destroy();
69+
return createChart();
70+
} catch (error) {
71+
onError(error);
72+
return null;
73+
}
74+
}

client/pages/admin/index.js

Lines changed: 31 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Input from 'components/input';
88
import Tabs from 'components/tabs';
99
import Reactive from 'html-tag-js/reactive';
1010
import Ref from 'html-tag-js/ref';
11+
import { createChartSafely, drawBarValueLabels, drawDoughnutPercentLabels } from 'lib/dashboardCharts';
1112
import { convertInrToUsd, formatCompactNumber, formatCompactUsd, formatExactNumber, formatExactUsd } from 'lib/formatNumber';
1213
import { getLoggedInUser } from 'lib/helpers';
1314
import moment from 'moment';
@@ -453,28 +454,46 @@ function Dashboard() {
453454
</div>,
454455
);
455456

456-
initChart(revenueCanvas, lineChartConfig(analytics.monthlyRevenue, 'Revenue (INR)', '#22c55e', true));
457-
initChart(paymentsCanvas, lineChartConfig(analytics.monthlyPayments, 'Payments (INR)', '#3b82f6', true));
458-
initChart(topDevCanvas, horizontalBarChartConfig(analytics.topDevelopers, 'name', 'total'));
459-
initChart(providerStatusCanvas, providerStatusChartConfig(analytics.providerStatus));
460-
initChart(paymentStatusCanvas, doughnutChartConfig(analytics.paymentStatus, 'status', 'count'));
461-
initChart(editorCanvas, doughnutChartConfig(analytics.editorDistribution, 'editor', 'count'));
462-
} catch {
457+
initChart(revenueCanvas, () => lineChartConfig(analytics.monthlyRevenue, 'Revenue (INR)', '#22c55e', true), 'Monthly Revenue');
458+
initChart(paymentsCanvas, () => lineChartConfig(analytics.monthlyPayments, 'Payments (INR)', '#3b82f6', true), 'Monthly Payments');
459+
initChart(topDevCanvas, () => horizontalBarChartConfig(analytics.topDevelopers, 'name', 'total'), 'Top Developers');
460+
initChart(providerStatusCanvas, () => providerStatusChartConfig(analytics.providerStatus), 'Orders by Provider and Status');
461+
initChart(paymentStatusCanvas, () => doughnutChartConfig(analytics.paymentStatus, 'status', 'count'), 'Payment Status');
462+
initChart(editorCanvas, () => doughnutChartConfig(analytics.editorDistribution, 'editor', 'count'), 'Editor Distribution');
463+
} catch (error) {
464+
console.error('Failed to load dashboard data', error);
463465
ref.innerHTML = '<div class="error">Failed to load dashboard data</div>';
464466
}
465467
})();
466468

467469
return <div ref={ref} className='admin-dashboard' />;
468470
}
469471

470-
function initChart(canvasRef, config) {
472+
function initChart(canvasRef, createConfig, label) {
471473
let instance = null;
472474
canvasRef.onref = () => {
473-
if (instance) instance.destroy();
474-
instance = new Chart(canvasRef.el, config);
475+
instance = createChartSafely({
476+
previousChart: instance,
477+
createChart: () => new Chart(canvasRef.el, createConfig()),
478+
onError: (error) => {
479+
console.error(`Failed to render ${label} chart`, error);
480+
showChartFallback(canvasRef.el);
481+
},
482+
});
475483
};
476484
}
477485

486+
function showChartFallback(canvas) {
487+
const container = canvas?.parentElement;
488+
if (!container) return;
489+
490+
const message = document.createElement('div');
491+
message.className = 'chart-error';
492+
message.textContent = 'Chart unavailable';
493+
container.classList.add('chart-container--error');
494+
container.replaceChildren(message);
495+
}
496+
478497
function lineChartConfig(rows, label, color = '#3b82f6', currency = false) {
479498
const months = [];
480499
const now = new Date();
@@ -552,26 +571,7 @@ function doughnutChartConfig(rows, labelKey, valueKey) {
552571
{
553572
id: 'doughnutPercentLabels',
554573
afterDatasetsDraw(chart) {
555-
const { ctx, data: chartData } = chart;
556-
const dataset = chartData.datasets[0];
557-
const meta = chart.getDatasetMeta(0);
558-
ctx.save();
559-
ctx.font = 'bold 14px sans-serif';
560-
ctx.textAlign = 'center';
561-
ctx.textBaseline = 'middle';
562-
for (let i = 0; i < dataset.data.length; i++) {
563-
const value = dataset.data[i];
564-
if (value === 0) continue;
565-
const pct = Math.round((value / total) * 100);
566-
const arc = meta.data[i];
567-
const { x, y } = arc.tooltipPosition(true);
568-
ctx.fillStyle = '#ffffff';
569-
ctx.shadowColor = 'rgba(0,0,0,0.6)';
570-
ctx.shadowBlur = 3;
571-
ctx.fillText(`${pct}%`, x, y);
572-
ctx.shadowBlur = 0;
573-
}
574-
ctx.restore();
574+
drawDoughnutPercentLabels(chart, total);
575575
},
576576
},
577577
],
@@ -693,25 +693,7 @@ function providerStatusChartConfig(rows) {
693693
{
694694
id: 'barValueLabels',
695695
afterDatasetsDraw(chart) {
696-
const { ctx } = chart;
697-
ctx.save();
698-
ctx.font = 'bold 11px sans-serif';
699-
ctx.textAlign = 'center';
700-
ctx.textBaseline = 'bottom';
701-
ctx.fillStyle = '#ffffff';
702-
ctx.shadowColor = 'rgba(0,0,0,0.5)';
703-
ctx.shadowBlur = 2;
704-
for (const ds of chart.data.datasets) {
705-
const meta = chart.getDatasetMeta(chart.data.datasets.indexOf(ds));
706-
for (let i = 0; i < ds.data.length; i++) {
707-
const value = ds.data[i];
708-
if (!value) continue;
709-
const { x, y } = meta.data[i];
710-
ctx.fillText(formatCompactNumber(value), x, y - 2);
711-
}
712-
}
713-
ctx.shadowBlur = 0;
714-
ctx.restore();
696+
drawBarValueLabels(chart, formatCompactNumber);
715697
},
716698
},
717699
],

client/pages/admin/style.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@
125125
height: 280px;
126126
min-height: 0;
127127

128+
&.chart-container--error {
129+
display: flex;
130+
align-items: center;
131+
justify-content: center;
132+
}
133+
134+
.chart-error {
135+
color: rgba(255, 255, 255, 0.5);
136+
font-size: 0.85em;
137+
}
138+
128139
&.chart-container--small {
129140
height: 230px;
130141
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { createChartSafely, drawBarValueLabels, drawDoughnutPercentLabels } from '../../client/lib/dashboardCharts';
2+
3+
function createContext() {
4+
return {
5+
save: vi.fn(),
6+
restore: vi.fn(),
7+
fillText: vi.fn(),
8+
};
9+
}
10+
11+
function createArc(x, y, options = {}) {
12+
return {
13+
hidden: false,
14+
tooltipPosition: vi.fn(() => ({ x, y })),
15+
...options,
16+
};
17+
}
18+
19+
describe('dashboard chart labels', () => {
20+
it('draws percentage labels for complete doughnut metadata', () => {
21+
const ctx = createContext();
22+
const chart = {
23+
ctx,
24+
data: { datasets: [{ data: [25, 75] }] },
25+
getDatasetMeta: () => ({ data: [createArc(10, 20), createArc(30, 40)] }),
26+
getDataVisibility: () => true,
27+
};
28+
29+
drawDoughnutPercentLabels(chart, 100);
30+
31+
expect(ctx.fillText).toHaveBeenNthCalledWith(1, '25%', 10, 20);
32+
expect(ctx.fillText).toHaveBeenNthCalledWith(2, '75%', 30, 40);
33+
expect(ctx.restore).toHaveBeenCalledOnce();
34+
});
35+
36+
it('ignores missing, hidden, zero, and non-finite doughnut elements', () => {
37+
const ctx = createContext();
38+
const chart = {
39+
ctx,
40+
data: { datasets: [{ data: [20, 30, 0, Number.NaN, 50] }] },
41+
getDatasetMeta: () => ({
42+
data: [createArc(10, 20), undefined, createArc(30, 40), createArc(50, 60), createArc(70, 80, { hidden: true })],
43+
}),
44+
getDataVisibility: () => true,
45+
};
46+
47+
expect(() => drawDoughnutPercentLabels(chart, 100)).not.toThrow();
48+
expect(ctx.fillText).toHaveBeenCalledOnce();
49+
expect(ctx.fillText).toHaveBeenCalledWith('20%', 10, 20);
50+
expect(ctx.restore).toHaveBeenCalledOnce();
51+
});
52+
53+
it('handles empty rendered metadata without drawing labels', () => {
54+
const doughnutContext = createContext();
55+
const barContext = createContext();
56+
57+
expect(() =>
58+
drawDoughnutPercentLabels(
59+
{
60+
ctx: doughnutContext,
61+
data: { datasets: [{ data: [] }] },
62+
getDatasetMeta: () => ({ data: [] }),
63+
},
64+
1,
65+
),
66+
).not.toThrow();
67+
expect(() =>
68+
drawBarValueLabels(
69+
{
70+
ctx: barContext,
71+
data: { datasets: [] },
72+
getDatasetMeta: () => ({ data: [], hidden: false }),
73+
},
74+
String,
75+
),
76+
).not.toThrow();
77+
78+
expect(doughnutContext.fillText).not.toHaveBeenCalled();
79+
expect(barContext.fillText).not.toHaveBeenCalled();
80+
expect(doughnutContext.restore).toHaveBeenCalledOnce();
81+
expect(barContext.restore).toHaveBeenCalledOnce();
82+
});
83+
84+
it('handles empty and partially materialized bar metadata', () => {
85+
const ctx = createContext();
86+
const chart = {
87+
ctx,
88+
data: {
89+
datasets: [{ data: [1_000, 2_000] }, { data: [3_000] }],
90+
},
91+
getDatasetMeta: (index) => (index === 0 ? { data: [{ x: 12, y: 24 }], hidden: false } : { data: [], hidden: false }),
92+
isDatasetVisible: () => true,
93+
};
94+
95+
expect(() => drawBarValueLabels(chart, (value) => `${value / 1_000}K`)).not.toThrow();
96+
expect(ctx.fillText).toHaveBeenCalledOnce();
97+
expect(ctx.fillText).toHaveBeenCalledWith('1K', 12, 22);
98+
expect(ctx.restore).toHaveBeenCalledOnce();
99+
});
100+
101+
it('always restores canvas state when element positioning fails', () => {
102+
const ctx = createContext();
103+
const chart = {
104+
ctx,
105+
data: { datasets: [{ data: [100] }] },
106+
getDatasetMeta: () => ({
107+
data: [
108+
createArc(0, 0, {
109+
tooltipPosition: () => {
110+
throw new Error('position failed');
111+
},
112+
}),
113+
],
114+
}),
115+
getDataVisibility: () => true,
116+
};
117+
118+
expect(() => drawDoughnutPercentLabels(chart, 100)).toThrow('position failed');
119+
expect(ctx.restore).toHaveBeenCalledOnce();
120+
});
121+
});
122+
123+
describe('dashboard chart initialization', () => {
124+
it('contains one chart failure so another chart can still initialize', () => {
125+
const onError = vi.fn();
126+
const error = new Error('chart failed');
127+
128+
const failedChart = createChartSafely({
129+
createChart: () => {
130+
throw error;
131+
},
132+
onError,
133+
});
134+
const workingChart = { id: 'working-chart' };
135+
const initializedChart = createChartSafely({
136+
createChart: () => workingChart,
137+
onError,
138+
});
139+
140+
expect(failedChart).toBeNull();
141+
expect(initializedChart).toBe(workingChart);
142+
expect(onError).toHaveBeenCalledOnce();
143+
expect(onError).toHaveBeenCalledWith(error);
144+
});
145+
});

0 commit comments

Comments
 (0)