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}
`,