Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ All notable changes to this project will be documented in this file.

- **Feature**: The analytics API gives when each transactional notification last sent successfully, with the new `last_sent_at` measure of message history, and `messages.list` filters by `transactional_notification_id`.

- **Feature**: The API counts the recipients of a broadcast audience before it is sent, with the new `broadcasts.estimateAudience`. `broadcasts.list` gives the number of broadcasts in each status (`with_status_counts`) and keeps the ones started from a date (`started_after`), and the message history analytics take `opened_at` and `clicked_at` as time dimensions.
- **Feature**: The API counts the recipients of a broadcast audience before it is sent, with the new `broadcasts.estimateAudience`. `broadcasts.list` gives the number of broadcasts in each status (`with_status_counts`) and keeps the ones started from a date (`started_after`), and the message history analytics take `opened_at` and `clicked_at` as time dimensions. In the console, broadcasts get a new list with status tabs, a search and their 30-day results, a detail page with the funnel, opens and clicks over time, the A/B test and the clicked links, and a one-screen editor that saves as you type, previews the email and checks everything before sending.

- **Feature**: The API gives the statistics of each step of an automation, over a period or all time, with the new `automations.nodeStats`: visits, results, the path each split took, the journeys waiting or retrying there, and for email steps the messages sent, delivered, opened and clicked. `automations.list` gives each automation's journeys by status (`with_journey_counts`), `messages.list` filters by `automation_id`, and the message history analytics split by `automation_id`.

Expand Down
422 changes: 76 additions & 346 deletions console/e2e/features/broadcasts.spec.ts

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion console/e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,11 @@ async function setupApiMocks(page: Page, config: MockConfig = {}) {
return route.fulfill(jsonResponse(withData ? mockBroadcastsResponse : mockEmptyBroadcasts))
}
if (url.includes('/api/broadcast.get') || url.includes('/api/broadcasts.get')) {
return route.fulfill(jsonResponse({ broadcast: mockBroadcastsResponse.broadcasts[0] }))
const id = new URL(url).searchParams.get('id')
const broadcast = mockBroadcastsResponse.broadcasts.find((b) => b.id === id)
return broadcast
? route.fulfill(jsonResponse({ broadcast }))
: route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'Broadcast not found' }) })
}
if (url.includes('/api/broadcast.create') || url.includes('/api/broadcasts.create')) {
return route.fulfill(jsonResponse(mockBroadcastCreateResponse))
Expand Down
4 changes: 2 additions & 2 deletions console/e2e/protected-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ test.describe('Protected Pages Load', () => {
await waitForPageLoad(page)

// The fixture serves no broadcasts, so the list shows its empty state
await expect(page.getByRole('heading', { name: 'No broadcasts found' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Create Broadcast' })).toBeVisible()
await expect(page.getByText('No broadcasts yet')).toBeVisible()
await expect(page.getByRole('button', { name: 'New broadcast' })).toBeVisible()

// Check for critical console errors
expect(errors).toHaveLength(0)
Expand Down
32 changes: 32 additions & 0 deletions console/src/__tests__/broadcastsRoute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { validateBroadcastsSearch } from '../router'

describe('validateBroadcastsSearch', () => {
it('normalises broadcast and compose like status and q', () => {
expect(
validateBroadcastsSearch({ status: ' sent ', q: ['news', 'other'], broadcast: ' b1 ', compose: ['new', 'x'] })
).toEqual({ status: 'sent', q: 'news', broadcast: 'b1', compose: 'new' })
})

it('drops empty values', () => {
expect(validateBroadcastsSearch({ status: '', q: ' ', broadcast: '', compose: [] })).toEqual({
status: undefined,
q: undefined,
broadcast: undefined,
compose: undefined
})
})

it('reads an identifier the router parsed as a number back as text', () => {
expect(validateBroadcastsSearch({ broadcast: 1234, compose: 99 })).toMatchObject({ broadcast: '1234', compose: '99' })
})

it('drops unknown keys', () => {
expect(validateBroadcastsSearch({ status: 'draft', page: '2', foo: 'bar' })).toEqual({
status: 'draft',
q: undefined,
broadcast: undefined,
compose: undefined
})
})
})
7 changes: 3 additions & 4 deletions console/src/components/broadcasts/BroadcastAnalyticsLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { faBullseye, faChartLine } from '@fortawesome/free-solid-svg-icons'
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useLingui } from '@lingui/react/macro'
import { useRouter } from '@tanstack/react-router'
import { getBrowserTimezone } from '../../lib/timezoneNormalizer'
import { canReadWebAnalytics, workspaceTimezone } from './access'
import { Broadcast } from '../../services/api/broadcast'
import { UserPermissions, Workspace } from '../../services/api/workspace'
import {
Expand Down Expand Up @@ -47,8 +47,7 @@ export function BroadcastAnalyticsLinks({
const { t } = useLingui()
const router = useRouter()

if (!workspace?.settings?.web_analytics?.enabled) return null
if (!permissions?.web_analytics?.read) return null
if (!canReadWebAnalytics(workspace, permissions)) return null
if (!broadcast.started_at) return null

const isVariation = variationTemplateId !== undefined
Expand All @@ -72,7 +71,7 @@ export function BroadcastAnalyticsLinks({
content,
startedAt: broadcast.started_at,
completedAt: broadcast.completed_at,
timezone: workspace.settings?.timezone || getBrowserTimezone() || 'UTC'
timezone: workspaceTimezone(workspace)
})
: null

Expand Down
111 changes: 111 additions & 0 deletions console/src/components/broadcasts/BroadcastKpis.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import '../../__tests__/resizeObserverMock'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from 'antd'
import { BroadcastKpis } from './BroadcastKpis'
import { runBroadcastQuery } from './broadcastQueryClient'
import { broadcastApi } from '../../services/api/broadcast'
import type { Workspace } from '../../services/api/types'
import type { UserPermissions } from '../../services/api/workspace'
import type { AnalyticsQuery } from '../../services/api/analytics'

vi.mock('./broadcastQueryClient', () => ({ runBroadcastQuery: vi.fn() }))
vi.mock('../../services/api/broadcast', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/api/broadcast')>()
return { ...actual, broadcastApi: { ...actual.broadcastApi, list: vi.fn() } }
})

const workspace = (web = true) =>
({ id: 'ws', settings: { timezone: 'UTC', web_analytics: { enabled: web } } }) as unknown as Workspace
const permissions = (over: Partial<UserPermissions> = {}) =>
({
message_history: { read: true, write: false },
web_analytics: { read: true, write: false },
...over
}) as UserPermissions

let current: Record<string, number> = {}
let previous: Record<string, number> = {}

const renderKpis = (ws = workspace(), perms: UserPermissions | null = permissions()) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<App>
<BroadcastKpis workspace={ws} permissions={perms} />
</App>
</QueryClientProvider>
)

describe('BroadcastKpis', () => {
beforeEach(() => {
current = {
count_sent: 1000,
count_opened: 300,
count_opens_tracked: 1000,
count_clicked: 50,
count_clicks_tracked: 1000,
count_unsubscribed: 5,
count_bounced: 20
}
previous = { ...current, count_opened: 280, count_clicked: 60 }
let call = 0
vi.mocked(runBroadcastQuery).mockReset().mockImplementation(async (query: AnalyticsQuery) => {
if (query.schema === 'web_sessions') {
return { data: [{ utm_campaign: 'news', year: 2000, month: 1, day: 1, sessions: 9 }], meta: { total: 1, query: '', params: [] } }
}
// L4 is asked before L5.
return { data: [call++ === 0 ? current : previous], meta: { total: 1, query: '', params: [] } }
})
vi.mocked(broadcastApi.list).mockReset().mockResolvedValue({ broadcasts: [], total_count: 0 })
})

it('shows rates and point deltas with their tone', async () => {
renderKpis()
expect(await screen.findByText('30.0%')).toBeInTheDocument()
expect(screen.getByText('5.0%')).toBeInTheDocument()
expect(screen.getByText('+2.0 pts')).toBeInTheDocument()
expect(screen.getByText('+2.0 pts').className).toContain('text-success-fg')
expect(screen.getByText('−1.0 pt').className).toContain('text-danger-fg')
expect(screen.getByText('5 contacts')).toBeInTheDocument()
expect(screen.getByText('20 addresses')).toBeInTheDocument()
})

it('shows "—" for a rate over nothing', async () => {
current = { count_sent: 0 }
renderKpis()
await waitFor(() => expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(4))
})

it('has no traffic cell without web analytics, nor without the right to read it', async () => {
const { unmount } = renderKpis(workspace(false))
await screen.findByText('30.0%')
expect(screen.queryByText('Website traffic')).not.toBeInTheDocument()
unmount()
renderKpis(workspace(true), permissions({ web_analytics: { read: false, write: false } }))
await screen.findByText('Open rate')
await waitFor(() => expect(screen.getAllByText(/%$/).length).toBeGreaterThan(0))
expect(screen.queryByText('Website traffic')).not.toBeInTheDocument()
})

it('counts traffic as 0 without asking web sessions when no broadcast has a campaign', async () => {
renderKpis()
expect(await screen.findByText('Website traffic')).toBeInTheDocument()
await waitFor(() => expect(broadcastApi.list).toHaveBeenCalled())
expect(vi.mocked(broadcastApi.list).mock.calls[0][0]).toMatchObject({ limit: 100, offset: 0 })
expect(vi.mocked(broadcastApi.list).mock.calls[0][0].started_after).toMatch(/Z$/)
expect(vi.mocked(runBroadcastQuery).mock.calls.some(([q]) => q.schema === 'web_sessions')).toBe(false)
})

it('is hidden without message_history read', () => {
const { container } = renderKpis(workspace(), permissions({ message_history: { read: false, write: false } }))
expect(container.querySelector('[data-kpi-strip]')).toBeNull()
expect(runBroadcastQuery).not.toHaveBeenCalled()
})

it('says it could not load when the totals fail', async () => {
vi.mocked(runBroadcastQuery).mockRejectedValue(new Error('boom'))
renderKpis(workspace(false))
await waitFor(() => expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(5))
})
})
181 changes: 181 additions & 0 deletions console/src/components/broadcasts/BroadcastKpis.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLingui } from '@lingui/react/macro'
import { plural } from '@lingui/core/macro'
import { KpiStrip, useNumberFormat, type KpiItem, type KpiTone } from '../layout'
import dayjs from '../../lib/dayjs'
import { broadcastApi, type Broadcast } from '../../services/api/broadcast'
import type { Workspace } from '../../services/api/types'
import type { UserPermissions } from '../../services/api/workspace'
import { computeComparisonRange, computeDateRange, resolveRange } from '../web_analytics/lib/dates'
import { formatValue } from '../web_analytics/lib/format'
import { BROADCAST_CONVERSION_WINDOW_DAYS } from '../web_analytics/lib/broadcastLinks'
import { TrackedTooltipTitle } from '../common/TrackedTooltipTitle'
import { campaignDayRows, campaignVisitsQuery, periodTotalsQuery, readPeriodTotals } from './broadcastAnalytics'
import { runBroadcastQuery } from './broadcastQueryClient'
import { campaignsOf, kpiTraffic } from './attribution'
import { canReadWebAnalytics, workspaceTimezone } from './access'
import { CouldntLoad } from './CouldntLoad'

const FIVE_MINUTES = 5 * 60_000
const TEN_MINUTES = 10 * 60_000
const ONE_HOUR = 60 * 60_000
const CAMPAIGNS_PAGE = 100

/** The rate of a numerator over a denominator, or null when the denominator is 0. */
export const rate = (numerator: number, denominator: number) => (denominator > 0 ? numerator / denominator : null)

Check warning on line 26 in console/src/components/broadcasts/BroadcastKpis.tsx

View workflow job for this annotation

GitHub Actions / Type-check, unit tests, lint, build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

/** Every broadcast started since `startedAfter` (L6), 100 at a time. */
async function listStartedSince(workspaceId: string, startedAfter: string): Promise<Broadcast[]> {
const all: Broadcast[] = []
for (let offset = 0; ; offset += CAMPAIGNS_PAGE) {
const page = await broadcastApi.list({
workspace_id: workspaceId,
started_after: startedAfter,
limit: CAMPAIGNS_PAGE,
offset
})
all.push(...(page.broadcasts ?? []))
if (offset + CAMPAIGNS_PAGE >= page.total_count) return all
}
}

export interface BroadcastKpisProps {
workspace: Workspace
permissions: UserPermissions | null
}

/** L4 to L7: the broadcast emails of the last 30 days against the 30 before, and their traffic. */
export function BroadcastKpis({ workspace, permissions }: BroadcastKpisProps) {
const { t } = useLingui()
const { int, percent, points } = useNumberFormat()
const timezone = workspaceTimezone(workspace)
const canReadHistory = Boolean(permissions?.message_history?.read)
const canReadWeb = canReadWebAnalytics(workspace, permissions)

const { current, previous, campaignsSince } = useMemo(() => {
const range = computeDateRange('previous_30_days', timezone)
const comparison = computeComparisonRange(range, 'previous_period')!
return {
current: resolveRange(range, timezone),
previous: resolveRange(comparison, timezone),
campaignsSince: range.start.subtract(BROADCAST_CONVERSION_WINDOW_DAYS, 'day').toISOString()
}
}, [timezone])

const currentQuery = useQuery({
queryKey: ['broadcast-kpis', workspace.id, current.startUtc, current.endUtc],
queryFn: () => runBroadcastQuery(periodTotalsQuery(current, timezone), workspace.id),
enabled: canReadHistory,
staleTime: FIVE_MINUTES
})
const previousQuery = useQuery({
queryKey: ['broadcast-kpis', workspace.id, previous.startUtc, previous.endUtc],
queryFn: () => runBroadcastQuery(periodTotalsQuery(previous, timezone), workspace.id),
enabled: canReadHistory,
staleTime: ONE_HOUR
})
const campaignsQuery = useQuery({
queryKey: ['broadcast-campaigns', workspace.id, campaignsSince],
queryFn: () => listStartedSince(workspace.id, campaignsSince),
enabled: canReadHistory && canReadWeb,
staleTime: TEN_MINUTES
})
const campaigns = campaignsOf(campaignsQuery.data ?? [])
const trafficQuery = useQuery({
queryKey: ['broadcast-traffic', workspace.id, current.startUtc, [...campaigns].sort()],
queryFn: () =>
runBroadcastQuery(
campaignVisitsQuery({
campaigns,
start: dayjs(current.startUtc),
end: dayjs(current.endUtc),
timezone,
withGoals: true
}),
workspace.id
),
enabled: canReadHistory && canReadWeb && campaigns.length > 0,
staleTime: TEN_MINUTES
})

if (!canReadHistory) return null

const failed = <CouldntLoad />
const now = readPeriodTotals(currentQuery.data?.data ?? [])
const before = previousQuery.data ? readPeriodTotals(previousQuery.data.data ?? []) : null

const rateCell = (
key: string,
label: string,
numerator: number,
tracked: number,
beforeNumerator?: number,
beforeTracked?: number
): KpiItem => {
const value = rate(numerator, tracked)
const prior = beforeNumerator !== undefined && beforeTracked !== undefined ? rate(beforeNumerator, beforeTracked) : null
const delta = value !== null && prior !== null ? (value - prior) * 100 : null
const tone: KpiTone = delta === null || Math.abs(delta) < 0.05 ? 'neutral' : delta > 0 ? 'positive' : 'negative'
return {
key,
label,
value: currentQuery.isError ? failed : value === null ? '—' : percent(value),
sub: delta === null ? undefined : points(delta),
tone,
hint:
tracked < now.sent && tracked > 0 ? <TrackedTooltipTitle title={label} tracked={tracked} /> : undefined
}
}

const unsubscribed = now.unsubscribed
const bounced = now.bounced
const items: KpiItem[] = [
{
key: 'sent',
label: t`Emails sent`,
value: currentQuery.isError ? failed : int(now.sent),
sub: t`Last 30 days`
},
rateCell('opens', t`Open rate`, now.opened, now.opensTracked, before?.opened, before?.opensTracked),
rateCell('clicks', t`Click rate`, now.clicked, now.clicksTracked, before?.clicked, before?.clicksTracked),
{
key: 'unsubscribes',
label: t`Unsubscribes`,
value: currentQuery.isError ? failed : rate(unsubscribed, now.sent) === null ? '—' : percent(rate(unsubscribed, now.sent)!),
sub: t`${plural(unsubscribed, { one: '# contact', other: '# contacts' })}`
},
{
key: 'bounces',
label: t`Bounces`,
value: currentQuery.isError ? failed : rate(bounced, now.sent) === null ? '—' : percent(rate(bounced, now.sent)!),
sub: t`${plural(bounced, { one: '# address', other: '# addresses' })}`
}
]

if (canReadWeb) {
const traffic = kpiTraffic(
campaignsQuery.data ?? [],
campaignDayRows(trafficQuery.data?.data ?? []),
{ startDay: current.startDay, endDay: current.endDay },
timezone
)
const conversions = traffic.conversions
const valueText = formatValue(traffic.value, 'currency')
const trafficFailed = campaignsQuery.isError || trafficQuery.isError
items.push({
key: 'traffic',
label: t`Website traffic`,
value: trafficFailed ? failed : int(traffic.sessions),
sub: t`${plural(conversions, { one: '# conversion', other: '# conversions' })} · ${valueText}`,
hint: t`Sessions from the UTM campaign of a broadcast, during the 7 days after it was sent. Broadcasts sharing a campaign count a session once here, and once each in the list.`
})
}

return (
<KpiStrip
items={items}
loading={currentQuery.isLoading || (canReadWeb && (campaignsQuery.isLoading || trafficQuery.isLoading) && campaigns.length > 0)}
/>
)
}
Loading
Loading