Use this page as the landing view that summarizes how a workspace is performing over a recent period, such as its messaging over the last 30 days. It leads with a row of headline metrics compared with the previous period, follows with charts of the daily trends, and ends with a table of the latest activity. Use it when people need to check performance at a glance before drilling into a specific campaign or report.
Messaging in this workspace over the last 30 days, compared with the 30 days before.
Messages sent each day, by channel.
Opens and clicks per delivered message, each day.
The latest campaigns and broadcasts to finish sending.
| Message | Channel | Sent | Recipients |
|---|---|---|---|
| October product update | Oct 8, 2026, 3:00:00 PM (UTC) | 48,213 | |
| Flash sale ends tonight | Push | Oct 8, 2026, 1:30:00 PM (UTC) | 21,907 |
| Appointment reminders | SMS | Oct 8, 2026, 9:00:00 AM (UTC) | 1,386 |
| Weekly activity digest | Oct 7, 2026, 2:00:00 PM (UTC) | 63,540 | |
| New dashboard announcement | Push | Oct 6, 2026, 5:45:00 PM (UTC) | 18,322 |
import { useMemo } from 'react';
import {
Badge,
FormattedDateTime,
Grid,
InlineStack,
Page,
PageBody,
PageHeader,
PageHeaderDescription,
PageHeaderTitle,
Panel,
PanelDescription,
PanelHeader,
PanelInset,
PanelSection,
PanelTitle,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
} from '@customerio/pluma-components/react';
import type { BadgeSemanticColor } from '@customerio/pluma-components/react';
import { Chart, bar, line } from '@customerio/pluma-components/charts/react';
import type { ChartAxis } from '@customerio/pluma-components/charts/react';
type MessageChannel = 'email' | 'push' | 'sms';
export interface MessagingStat {
id: string;
label: string;
/** The value over the last 30 days. */
value: number;
/** The value over the 30 days before that. */
previousValue: number;
/**
* `count` for a number of things, such as messages sent; `rate` for a
* fraction between 0 and 1, such as an open rate. A count compares as a
* percentage change, a rate as a change in percentage points.
*/
kind: 'count' | 'rate';
}
export interface DailySends {
/** ISO 8601 timestamp of the day's start, in UTC. */
date: string;
channel: MessageChannel;
sent: number;
}
export interface DailyEngagement {
/** ISO 8601 timestamp of the day's start, in UTC. */
date: string;
/** Opens per delivered message, between 0 and 1. */
openRate: number;
/** Clicks per delivered message, between 0 and 1. */
clickRate: number;
}
export interface RecentSend {
id: string;
name: string;
channel: MessageChannel;
/** ISO 8601 timestamp. */
sentAt: string;
recipients: number;
}
const channelLabels: Record<MessageChannel, string> = {
email: 'Email',
push: 'Push',
sms: 'SMS',
};
const countFormatter = new Intl.NumberFormat();
const rateFormatter = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 1 });
const countChangeFormatter = new Intl.NumberFormat(undefined, {
style: 'percent',
maximumFractionDigits: 1,
signDisplay: 'exceptZero',
});
const pointChangeFormatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
signDisplay: 'exceptZero',
});
function formatStatValue(stat: MessagingStat) {
return stat.kind === 'rate' ? rateFormatter.format(stat.value) : countFormatter.format(stat.value);
}
function formatStatChange(stat: MessagingStat) {
return stat.kind === 'rate'
? `${pointChangeFormatter.format((stat.value - stat.previousValue) * 100)} pts`
: countChangeFormatter.format((stat.value - stat.previousValue) / stat.previousValue);
}
// Every stat on this page is better when it goes up.
function statChangeColor(stat: MessagingStat): BadgeSemanticColor {
if (stat.value === stat.previousValue) return 'neutral';
return stat.value > stat.previousValue ? 'success' : 'critical';
}
const sendsYAxis: ChartAxis = { notation: 'compact' };
const engagementYAxis: ChartAxis = { format: (value) => rateFormatter.format(Number(value)) };
export interface DashboardOverviewProps {
/** The headline numbers, shown as a row of tiles in this order. */
stats: MessagingStat[];
/** Messages sent per day and channel over the last 30 days. */
dailySends: DailySends[];
/** Open and click rates per day over the last 30 days. */
dailyEngagement: DailyEngagement[];
/** The latest messages to finish sending, newest first. */
recentSends: RecentSend[];
/** Whether the numbers for the page are loading. */
isLoading: boolean;
}
export function DashboardOverview({
stats,
dailySends,
dailyEngagement,
recentSends,
isLoading,
}: DashboardOverviewProps) {
const sendsMarks = useMemo(
() => [
bar({
data: dailySends.map((row) => ({ ...row, date: new Date(row.date) })),
x: 'date',
y: 'sent',
series: 'channel',
seriesDisplayName: (row) => channelLabels[row.channel],
}),
],
[dailySends],
);
const engagementMarks = useMemo(() => {
const rows = dailyEngagement.map((row) => ({ ...row, date: new Date(row.date) }));
return [
line({ data: rows, x: 'date', y: 'openRate', seriesName: 'Open rate' }),
line({ data: rows, x: 'date', y: 'clickRate', seriesName: 'Click rate' }),
];
}, [dailyEngagement]);
return (
<Page isFullScreen>
<PageHeader>
<PageHeaderTitle>Overview</PageHeaderTitle>
<PageHeaderDescription>
Messaging in this workspace over the last 30 days, compared with the 30 days before.
</PageHeaderDescription>
</PageHeader>
<PageBody isLoading={isLoading}>
<Stack gap="300">
<Grid columns={4} gap="300">
{stats.map((stat) => (
<Panel key={stat.id}>
<PanelSection>
<Stack gap="100">
<Text color="secondary">{stat.label}</Text>
<Text size="xxl" weight="semibold">
{formatStatValue(stat)}
</Text>
<InlineStack gap="100" alignBlock="center">
<Badge color={statChangeColor(stat)}>{formatStatChange(stat)}</Badge>
<Text color="secondary" size="sm">
vs previous 30 days
</Text>
</InlineStack>
</Stack>
</PanelSection>
</Panel>
))}
</Grid>
<Grid columns={2} gap="300">
<Panel>
<PanelHeader>
<PanelTitle level="2">Messages sent</PanelTitle>
<PanelDescription>Messages sent each day, by channel.</PanelDescription>
</PanelHeader>
<PanelSection>
<Chart
ariaLabel="Messages sent per day by channel, last 30 days"
height={280}
marks={sendsMarks}
withLegend={true}
yAxis={sendsYAxis}
/>
</PanelSection>
</Panel>
<Panel>
<PanelHeader>
<PanelTitle level="2">Engagement</PanelTitle>
<PanelDescription>Opens and clicks per delivered message, each day.</PanelDescription>
</PanelHeader>
<PanelSection>
<Chart
ariaLabel="Open rate and click rate per day, last 30 days"
height={280}
marks={engagementMarks}
withLegend={true}
yAxis={engagementYAxis}
/>
</PanelSection>
</Panel>
</Grid>
<Panel>
<PanelHeader>
<PanelTitle level="2">Recent sends</PanelTitle>
<PanelDescription>The latest campaigns and broadcasts to finish sending.</PanelDescription>
</PanelHeader>
<PanelInset>
<Table caption="Recent sends">
<TableThead>
<TableTr>
<TableTh>Message</TableTh>
<TableTh>Channel</TableTh>
<TableTh>Sent</TableTh>
<TableTh textAlign="right">Recipients</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{recentSends.map((send) => (
<TableTr key={send.id}>
<TableTd>{send.name}</TableTd>
<TableTd>{channelLabels[send.channel]}</TableTd>
<TableTd>
<FormattedDateTime date={send.sentAt} format="medium_with_time" />
</TableTd>
<TableTd textAlign="right">{countFormatter.format(send.recipients)}</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</PanelInset>
</Panel>
</Stack>
</PageBody>
</Page>
);
}
Copy this recipe into your app with the Pluma CLI. Replace <dest-dir> with the folder the file goes in, and add --name <Name> to rename the component and its file.
pluma-cli scaffold dashboard-overview <dest-dir> --framework reactThis recipe uses these components. Each one's docs page lists its props.