diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx index bc9eb3227..5d26cade2 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx @@ -8,21 +8,23 @@ import { import Highcharts from 'highcharts' import { + ChallengeParticipationDashboard, MemberPaymentByCustomerDashboard, NewSignupsDashboard, } from '../../lib/services' import { DashboardChart } from './DashboardChart' +let chartOptions: Highcharts.Options + jest.mock('highcharts-react-official', () => ({ __esModule: true, - default: (props: { - options: { - series?: Array<{ data?: number[]; name?: string }> - tooltip?: { pointFormat?: string } - } - }): JSX.Element => { - const series = props.options.series || [] + default: (props: { options: Highcharts.Options }): JSX.Element => { + chartOptions = props.options + const series = props.options.series as Array<{ + data?: number[] + name?: string + }> || [] return (
({ data-series-names={series.map(item => item.name) .join('|')} data-testid='dashboard-chart' - data-tooltip={props.options.tooltip?.pointFormat} /> ) }, @@ -96,10 +97,61 @@ const signupResponse: NewSignupsDashboard = { }, } +const challengeParticipationResponse: ChallengeParticipationDashboard = { + dashboard: 'challenge-participation', + endDate: '2026-02-01T00:00:00.000Z', + months: [{ + month: '2026-01-01', + registrants: 120, + submitters: 75, + }], + startDate: '2026-01-01T00:00:00.000Z', + summary: { + peakMonth: '2026-01-01', + peakMonthRegistrants: 120, + submissionRate: 62.5, + totalUniqueRegistrants: 120, + totalUniqueSubmitters: 75, + }, +} const pointValueToken = '{point.y:,.0f}' const countTooltipValue = `${pointValueToken}` const currencyTooltipValue = `$${pointValueToken}` +/** + * Invokes the tooltip formatter captured from the rendered chart. + * + * Tests use this helper to verify totals independently of Highcharts' DOM renderer. + * + * @param values Hovered series values for one month. + * @returns The formatter's tooltip HTML as one string. + * @throws Error when the chart has no formatter or it returns no HTML. + */ +function formatTooltip(values: number[]): string { + const formatter = chartOptions.tooltip?.formatter + if (!formatter) { + throw new Error('Tooltip formatter is missing') + } + + const context = { + points: values.map(y => ({ y })), + } as unknown as Highcharts.TooltipFormatterContextObject + const tooltip = { + defaultFormatter: () => ['Jan ’26
', 'series rows'], + } as unknown as Highcharts.Tooltip + const result = formatter.call(context, tooltip) + + if (typeof result === 'string') { + return result + } + + if (Array.isArray(result)) { + return result.join('') + } + + throw new Error('Tooltip formatter did not return HTML') +} + describe('DashboardChart', () => { it('formats tooltip thousands with commas', () => { expect(Highcharts.getOptions().lang?.thousandsSep) @@ -126,8 +178,10 @@ describe('DashboardChart', () => { 'data-series-data', '[[125000,140000],[80000,0],[20000,25000]]', ) - expect(chart.getAttribute('data-tooltip')) + expect(chartOptions.tooltip?.pointFormat) .toContain(currencyTooltipValue) + expect(formatTooltip([125_000, 80_000, 20_000])) + .toContain('Total: $225,000') expect(within(table) .getByRole('columnheader', { name: 'Customer A' })) .toBeInTheDocument() @@ -142,20 +196,30 @@ describe('DashboardChart', () => { .toBeInTheDocument() }) - it('keeps existing count dashboards unit-free', () => { + it('keeps count dashboard tooltips unit-free and adds their total', () => { render() - const chart = screen.getByTestId('dashboard-chart') const table = screen.getByRole('table', { name: 'New Signups by Month monthly data', }) - expect(chart.getAttribute('data-tooltip')) + expect(chartOptions.tooltip?.pointFormat) .toContain(countTooltipValue) - expect(chart.getAttribute('data-tooltip')) + expect(chartOptions.tooltip?.pointFormat) .not.toContain(currencyTooltipValue) + expect(formatTooltip([90, 10])) + .toContain('Total: 100') + expect(formatTooltip([90, 10])) + .not.toContain('$100') expect(within(table) .getByRole('cell', { name: '90' })) .toBeInTheDocument() }) + + it('adds a total to grouped report tooltips', () => { + render() + + expect(formatTooltip([120, 75])) + .toContain('Total: 195') + }) }) diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx index 54182e43a..eeeab09db 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx @@ -50,7 +50,7 @@ function getSeriesValue(month: DashboardMonth, key: string): number { * * @param props Dashboard response and compact-card presentation flag. * @returns A stacked or grouped column chart with month categories along the - * bottom axis and an accessible monthly data table. + * bottom axis, monthly tooltip totals, and an accessible monthly data table. * @throws Does not throw. Invalid or absent point values are rendered as zero. */ export const DashboardChart: FC = props => { @@ -119,6 +119,29 @@ export const DashboardChart: FC = props => { text: undefined, }, tooltip: { + /** + * Appends the hovered month's total to Highcharts' shared tooltip. + * + * Highcharts invokes this callback for card and detail charts. + * + * @param tooltip Highcharts tooltip used to render the existing rows. + * @returns The default tooltip content followed by the formatted total. + * @throws Does not throw for the normalized chart-series values. + */ + formatter(tooltip: Highcharts.Tooltip) { + // Highcharts supplies the shared tooltip context through `this`. + // eslint-disable-next-line react/no-this-in-sfc + const points = this.points || [this] + const total = points.reduce((sum, point) => sum + (point.y ?? 0), 0) + // eslint-disable-next-line react/no-this-in-sfc + const content = tooltip.defaultFormatter.call(this, tooltip) + const totalRow = `Total: ${isCurrency ? '$' : ''}` + + `${Highcharts.numberFormat(total, 0)}` + + return Array.isArray(content) + ? [...content, totalRow] + : `${content}${totalRow}` + }, headerFormat: '{point.key}
', pointFormat: ' ' + `{series.name}: ${isCurrency ? '$' : ''}{point.y:,.0f}
`,