diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 54c6b188a365..0fe7900eb04a 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -1,4 +1,5 @@ import ObjectId from 'bson-objectid'; +import { z } from 'zod'; import { Meta, createMutation, createQuery, createQueryWithId } from '../utils/api/hooks'; import type { ReadonlyDeep } from 'type-fest'; @@ -112,6 +113,52 @@ export const useReadAutomation = createQueryWithId path: (id) => `/automations/${id}/`, }); +export const AutomationPerformanceStatsSchema = z.object({ + automation_id: z.string(), + total_run_count: z.number().int().nonnegative(), + in_progress_run_count: z.number().int().nonnegative(), + completed_run_count: z.number().int().nonnegative(), + exited_early_run_count: z.number().int().nonnegative(), + entries: z + .array( + z.object({ + date: z.iso.date(), + count: z.number().int().nonnegative(), + }), + ) + .min(1), + entry_window: z.object({ + date_from: z.iso.date(), + date_to: z.iso.date(), + bucket: z.literal('day'), + timezone: z.string().min(1), + }), +}); + +const AutomationPerformanceStatsResponseSchema = z.object({ + automation_performance_stats: z.array(AutomationPerformanceStatsSchema).length(1), +}); + +export type AutomationPerformanceStats = z.infer; + +export const useReadAutomationPerformanceStats = ( + id: string, + options?: Parameters< + ReturnType>> + >[0], +) => { + const useQuery = createQuery>({ + dataType: 'AutomationPerformanceStatsResponseType', + path: `/automations/${id}/performance-stats/`, + parseResponse: (data) => + AutomationPerformanceStatsResponseSchema.refine( + (response) => response.automation_performance_stats[0].automation_id === id, + { message: 'Performance statistics do not match the requested automation.' }, + ).parse(data), + }); + return useQuery(options); +}; + const useBrowseAutomationActionLinksQuery = createQueryWithId({ dataType: 'AutomationActionLinksResponseType', path: (id) => `/automations/${id}/links/`, diff --git a/apps/admin-x-framework/test/unit/api/automation-performance-stats.test.ts b/apps/admin-x-framework/test/unit/api/automation-performance-stats.test.ts new file mode 100644 index 000000000000..bdd39af8b7b1 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/automation-performance-stats.test.ts @@ -0,0 +1,48 @@ +import { AutomationPerformanceStatsSchema } from '../../../src/api/automations'; + +const stats = { + automation_id: 'one', + total_run_count: 1, + in_progress_run_count: 0, + completed_run_count: 1, + exited_early_run_count: 0, + entries: [{ date: '2026-01-01', count: 1 }], + entry_window: { + date_from: '2026-01-01', + date_to: '2026-01-02', + bucket: 'day', + timezone: 'UTC', + }, +}; + +describe('AutomationPerformanceStatsSchema', () => { + it('accepts a valid response', () => { + expect(AutomationPerformanceStatsSchema.parse(stats)).toEqual(stats); + }); + + it('accepts statistics grouped in a non-UTC timezone', () => { + const localized = { + ...stats, + entry_window: { ...stats.entry_window, timezone: 'America/New_York' }, + }; + expect(AutomationPerformanceStatsSchema.parse(localized)).toEqual(localized); + }); + + it.each([ + { name: 'empty series', overrides: { entries: [] } }, + { name: 'invalid date', overrides: { entries: [{ date: '2026-02-30', count: 1 }] } }, + { name: 'negative total', overrides: { total_run_count: -1 } }, + { + name: 'fractional entry count', + overrides: { entries: [{ date: '2026-01-01', count: 1.5 }] }, + }, + ...[-1, null, 1.5, '4'].map((count) => ({ + name: `invalid status count (${JSON.stringify(count)})`, + overrides: { completed_run_count: count }, + })), + ])('rejects $name', ({ overrides }) => { + expect(AutomationPerformanceStatsSchema.safeParse({ ...stats, ...overrides }).success).toBe( + false, + ); + }); +}); diff --git a/apps/admin/src/automations/components/canvas/automation-canvas.tsx b/apps/admin/src/automations/components/canvas/automation-canvas.tsx index 5db428b8e960..6f5281fe52aa 100644 --- a/apps/admin/src/automations/components/canvas/automation-canvas.tsx +++ b/apps/admin/src/automations/components/canvas/automation-canvas.tsx @@ -568,6 +568,7 @@ const AutomationCanvas: React.FC = ({ const initialViewport = useRef(getInitialViewport(window.innerWidth)); const automationAnalyticsEnabled = useFeatureFlag('automationAnalytics'); const automationRunAnalyticsEnabled = useFeatureFlag('automationRunAnalytics'); + const automationsTinybirdSyncEnabled = useFeatureFlag('automationsTinybirdSync'); const graph = useMemo(() => { if (!automation) { @@ -679,11 +680,13 @@ const AutomationCanvas: React.FC = ({ return ( - {automationRunAnalyticsEnabled && } + {automationRunAnalyticsEnabled && automationsTinybirdSyncEnabled && ( + + )}
{ +export const PerformanceSidebar: React.FC<{ automationId: string }> = ({ automationId }) => { const [isOpen, setIsOpen] = useState(false); + const [hasOpened, setHasOpened] = useState(false); const panelId = useId(); const headingId = useId(); @@ -18,25 +20,39 @@ export const PerformanceSidebar: React.FC = () => { size="icon" type="button" variant="ghost" - onClick={() => setIsOpen((open) => !open)} + onClick={() => { + setHasOpened(true); + setIsOpen((open) => !open); + }} > diff --git a/apps/admin/src/automations/components/canvas/total-entries-chart.tsx b/apps/admin/src/automations/components/canvas/total-entries-chart.tsx new file mode 100644 index 000000000000..e44086e5445f --- /dev/null +++ b/apps/admin/src/automations/components/canvas/total-entries-chart.tsx @@ -0,0 +1,37 @@ +import React, { useId } from 'react'; +import { GhAreaChart } from '@tryghost/shade/patterns'; +import { Text } from '@tryghost/shade/primitives'; +import type { AutomationEntriesChartData } from '@/automations/utils/automation-entry-stats'; + +export const TotalEntriesChart: React.FC<{ data: AutomationEntriesChartData }> = ({ data }) => { + const id = useId(); + return ( + <> + + {data.total} + +
+
+ All-time automation entries from {data.startDate} to {data.endDate}, in {data.timezone}. +
+ + {data.empty && ( + + No entries yet + + )} +
+ + ); +}; diff --git a/apps/admin/src/automations/components/canvas/total-entries.tsx b/apps/admin/src/automations/components/canvas/total-entries.tsx new file mode 100644 index 000000000000..c2484f6c1b36 --- /dev/null +++ b/apps/admin/src/automations/components/canvas/total-entries.tsx @@ -0,0 +1,56 @@ +import React, { useId } from 'react'; +import { Button, Skeleton } from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; +import { useAutomationEntryStats } from '@/automations/hooks/use-automation-entry-stats'; +import { TotalEntriesChart } from './total-entries-chart'; + +export const TotalEntries: React.FC<{ automationId: string }> = ({ automationId }) => { + const { chart, isLoading, isError, retry } = useAutomationEntryStats(automationId); + const headingId = useId(); + + return ( + + + + + Total entries + + + + {isLoading + ? 'Loading total entries' + : !isError && chart + ? `Total entries loaded: ${chart.total}.` + : ''} + + {isLoading && ( + + )} + {chart && } + {isError && ( + + + Could not load entries. + + + + )} + + ); +}; diff --git a/apps/admin/src/automations/editor.test.tsx b/apps/admin/src/automations/editor.test.tsx index 0d7c10eceb09..4a696ebb5831 100644 --- a/apps/admin/src/automations/editor.test.tsx +++ b/apps/admin/src/automations/editor.test.tsx @@ -1040,22 +1040,29 @@ describe('AutomationEditor', () => { expect(mockUseBrowseAutomationActionLinks).not.toHaveBeenCalled(); }); - it('hides the performance toggle and panel when run analytics is disabled', () => { - mockLabs.current = { automationRunAnalytics: false }; - mockUseReadAutomation.mockReturnValue({ - data: { automations: [automationDetail] }, - isLoading: false, - isError: false, - }); + it.each(['automationRunAnalytics', 'automationsTinybirdSync'])( + 'hides the performance toggle and panel when %s is disabled', + (flag) => { + mockLabs.current = { + automationRunAnalytics: true, + automationsTinybirdSync: true, + [flag]: false, + }; + mockUseReadAutomation.mockReturnValue({ + data: { automations: [automationDetail] }, + isLoading: false, + isError: false, + }); - renderEditor(); + renderEditor(); - expect(screen.queryByRole('button', { name: /performance/i })).not.toBeInTheDocument(); - expect(screen.queryByText('Performance')).not.toBeInTheDocument(); - }); + expect(screen.queryByRole('button', { name: /performance/i })).not.toBeInTheDocument(); + expect(screen.queryByText('Performance')).not.toBeInTheDocument(); + }, + ); - it('renders the performance toggle and panel shell when run analytics is enabled', () => { - mockLabs.current = { automationRunAnalytics: true }; + it('renders the performance toggle and panel shell when both analytics flags are enabled', () => { + mockLabs.current = { automationRunAnalytics: true, automationsTinybirdSync: true }; mockUseReadAutomation.mockReturnValue({ data: { automations: [automationDetail] }, isLoading: false, diff --git a/apps/admin/src/automations/hooks/performance-query-options.ts b/apps/admin/src/automations/hooks/performance-query-options.ts new file mode 100644 index 000000000000..f093cdf8fa84 --- /dev/null +++ b/apps/admin/src/automations/hooks/performance-query-options.ts @@ -0,0 +1,11 @@ +// Sidebar content mounts on first open and stays mounted until navigation. +export const performanceQueryOptions = { + searchParams: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }, + defaultErrorHandler: false, + staleTime: Infinity, + gcTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, +} as const; diff --git a/apps/admin/src/automations/hooks/use-automation-entry-stats.ts b/apps/admin/src/automations/hooks/use-automation-entry-stats.ts new file mode 100644 index 000000000000..8ce74407e54b --- /dev/null +++ b/apps/admin/src/automations/hooks/use-automation-entry-stats.ts @@ -0,0 +1,20 @@ +import { performanceQueryOptions } from './performance-query-options'; +import { useMemo } from 'react'; +import { useReadAutomationPerformanceStats } from '@tryghost/admin-x-framework/api/automations'; +import { mapAutomationEntryStats } from '@/automations/utils/automation-entry-stats'; + +export const useAutomationEntryStats = (automationId: string) => { + const query = useReadAutomationPerformanceStats(automationId, performanceQueryOptions); + const stats = query.data?.automation_performance_stats[0]; + const chart = useMemo(() => (stats ? mapAutomationEntryStats(stats) : undefined), [stats]); + const failed = !query.isFetching && query.isError; + + return { + chart, + isLoading: !chart && !failed, + isError: failed, + retry: () => { + void query.refetch(); + }, + }; +}; diff --git a/apps/admin/src/automations/performance-sidebar.acceptance.test.tsx b/apps/admin/src/automations/performance-sidebar.acceptance.test.tsx new file mode 100644 index 000000000000..8bc607fa3713 --- /dev/null +++ b/apps/admin/src/automations/performance-sidebar.acceptance.test.tsx @@ -0,0 +1,237 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { fakeAdminEndpoint, renderAdminApp } from '@test-utils/acceptance'; +import type { + AutomationDetail, + AutomationPerformanceStats, +} from '@tryghost/admin-x-framework/api/automations'; + +// Production inherits this root sizing from Ember's patterns/global.css. +// This full-app test host does not load Ember's stylesheet. +let originalRootFontSize: string; +beforeAll(() => { + originalRootFontSize = document.documentElement.style.fontSize; + document.documentElement.style.fontSize = '62.5%'; +}); +afterAll(() => { + document.documentElement.style.fontSize = originalRootFontSize; +}); + +const flags = { + labs: { automations: true, automationRunAnalytics: true, automationsTinybirdSync: true }, +}; +const detail = (id: string): AutomationDetail => ({ + id, + name: 'Welcome series', + description: '', + slug: 'member-welcome-email-free', + status: 'active', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + actions: [{ id: `${id}-wait`, type: 'wait', data: { wait_hours: 24 } }], + edges: [], +}); +const stats = (id: string, total = 1432, empty = false): AutomationPerformanceStats => ({ + automation_id: id, + total_run_count: total, + in_progress_run_count: 0, + completed_run_count: total, + exited_early_run_count: 0, + entries: Array.from({ length: 30 }, (_, day) => ({ + date: new Date(Date.UTC(2026, 5, 22 + day)).toISOString().slice(0, 10), + count: empty + ? 0 + : [ + 18, 22, 24, 28, 26, 32, 36, 34, 40, 44, 42, 46, 48, 50, 48, 52, 54, 52, 50, 48, 44, 46, + 42, 40, 42, 38, 36, 38, 34, 36, + ][day], + })), + entry_window: { + date_from: '2026-06-22', + date_to: '2026-07-22', + bucket: 'day', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, +}); +const response = (id: string, total = 1432, empty = false) => { + const data = stats(id, total, empty); + if (!empty) { + const subtotal = data.entries.slice(0, -1).reduce((sum, entry) => sum + entry.count, 0); + data.entries[data.entries.length - 1].count = total - subtotal; + } + return { automation_performance_stats: [data] }; +}; +const read = (id: string) => { + return fakeAdminEndpoint('GET', `/automations/${id}/`, { automations: [detail(id)] }); +}; +const statsUrl = (id: string) => + `/automations/${id}/performance-stats/?${new URLSearchParams({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone })}`; +const entries = () => page.getByRole('region', { name: 'Total entries' }); +const open = () => page.getByRole('button', { name: 'Show performance' }).click(); +const close = () => page.getByRole('button', { name: 'Hide performance' }).click(); + +describe('Performance sidebar entry chart', () => { + it('fetches on opening and shows the total and chart date labels', async () => { + read('first'); + const request = fakeAdminEndpoint('GET', statsUrl('first'), response('first')); + await renderAdminApp('/automations/first', flags); + await expect.element(page.getByRole('button', { name: 'Show performance' })).toBeVisible(); + expect(request.requests).toHaveLength(0); + await open(); + await expect.element(entries()).toHaveTextContent('1,432'); + await expect + .element(entries().getByRole('figure', { name: 'Automation entries' })) + .toBeVisible(); + await expect.element(entries()).toHaveTextContent('22 Jun'); + await expect.element(entries()).toHaveTextContent('21 Jul'); + await expect.poll(() => request.requests.length).toBe(1); + expect(new URL(request.requests[0].url).searchParams.get('timezone')).toBe( + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + const content = entries().element(); + await close(); + expect(content.isConnected).toBe(true); + expect(content.closest('aside')?.inert).toBe(true); + expect(content.closest('aside')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('shows loading while fetching and a genuine zero when the request succeeds', async () => { + read('first'); + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + fakeAdminEndpoint('GET', statsUrl('first'), async () => { + await pending; + return response('first', 0, true); + }); + await renderAdminApp('/automations/first', flags); + await open(); + const announcement = entries().getByRole('status'); + await expect.element(announcement).toHaveTextContent('Loading total entries'); + const liveRegion = announcement.element(); + expect(liveRegion).toHaveAttribute('aria-atomic', 'true'); + finish(); + await expect.element(entries()).toHaveTextContent('No entries yet'); + await expect.element(entries()).toHaveTextContent('0'); + await expect.element(announcement).toHaveTextContent('Total entries loaded: 0.'); + expect(announcement.element()).toBe(liveRegion); + }); + + it('shows an inline error and retries the current automation', async () => { + read('first'); + fakeAdminEndpoint( + 'GET', + statsUrl('first'), + { errors: [{ message: 'Failed' }] }, + { status: 500 }, + ); + await renderAdminApp('/automations/first', flags); + await open(); + await expect.element(entries().getByRole('alert')).toHaveTextContent('Could not load entries.'); + fakeAdminEndpoint('GET', statsUrl('first'), response('first')); + await entries().getByRole('button', { name: 'Retry' }).click(); + await expect.element(entries()).toHaveTextContent('1,432'); + await expect.element(entries().getByRole('alert')).not.toBeInTheDocument(); + await expect + .element(entries().getByRole('status')) + .toHaveTextContent('Total entries loaded: 1,432.'); + }); + + it('handles a missing endpoint with the ordinary error state instead of displaying zero', async () => { + read('first'); + fakeAdminEndpoint( + 'GET', + statsUrl('first'), + { errors: [{ message: 'Not found' }] }, + { status: 404 }, + ); + await renderAdminApp('/automations/first', flags); + await open(); + await expect.element(entries()).toHaveTextContent('Could not load entries.'); + await expect.element(entries().getByRole('figure')).not.toBeInTheDocument(); + await expect.element(entries().getByRole('button', { name: 'Retry' })).toBeVisible(); + }); + + it.each([ + { name: 'malformed', body: {} }, + { name: 'wrong automation', body: response('other') }, + ])('shows an error for a $name response', async ({ body }) => { + read('first'); + fakeAdminEndpoint('GET', statsUrl('first'), body); + await renderAdminApp('/automations/first', flags); + await open(); + await expect.element(entries().getByRole('alert')).toHaveTextContent('Could not load entries.'); + await expect.element(entries().getByRole('figure')).not.toBeInTheDocument(); + }); + + it('caches the chart until the next page visit', async () => { + read('first'); + const request = fakeAdminEndpoint('GET', statsUrl('first'), response('first')); + await renderAdminApp('/automations/first', flags); + await open(); + await expect.element(entries()).toHaveTextContent('1,432'); + await close(); + await open(); + await expect.element(entries()).toHaveTextContent('1,432'); + await expect.element(entries()).not.toHaveTextContent('Updating'); + await expect + .poll(() => document.querySelector('aside')?.getBoundingClientRect().width) + .toBe(480); + expect(request.requests).toHaveLength(1); + read('second'); + window.location.hash = '#/automations/second'; + await expect.element(page.getByRole('button', { name: 'Show performance' })).toBeVisible(); + const revisit = fakeAdminEndpoint('GET', statsUrl('first'), response('first', 1500)); + window.location.hash = '#/automations/first'; + await expect.element(page.getByRole('button', { name: 'Show performance' })).toBeVisible(); + expect(revisit.requests).toHaveLength(0); + await open(); + await expect.element(entries()).toHaveTextContent('1,500'); + expect(revisit.requests).toHaveLength(1); + }); + + it('ignores a late response after switching automations and refreshes on return', async () => { + read('first'); + read('second'); + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const first = fakeAdminEndpoint('GET', statsUrl('first'), async () => { + await pending; + return response('first'); + }); + fakeAdminEndpoint('GET', statsUrl('second'), response('second', 2500)); + await renderAdminApp('/automations/first', flags); + await open(); + await expect.poll(() => first.requests.length).toBe(1); + window.location.hash = '#/automations/second'; + await expect.element(page.getByRole('button', { name: 'Show performance' })).toBeVisible(); + await open(); + await expect.element(entries()).toHaveTextContent('2,500'); + finish(); + // Return navigation gives the late response a chance to populate only its own cache. + const revisit = fakeAdminEndpoint('GET', statsUrl('first'), response('first', 1600)); + await expect.element(entries()).not.toHaveTextContent('1,432'); + window.location.hash = '#/automations/first'; + await expect.element(page.getByRole('button', { name: 'Show performance' })).toBeVisible(); + await open(); + await expect.element(entries()).toHaveTextContent('1,600'); + await expect.poll(() => revisit.requests.length).toBe(1); + }); + + it.each(['automationRunAnalytics', 'automationsTinybirdSync'])( + 'hides performance and does not fetch stats with %s disabled', + async (flag) => { + read('first'); + const request = fakeAdminEndpoint('GET', statsUrl('first'), response('first')); + await renderAdminApp('/automations/first', { labs: { ...flags.labs, [flag]: false } }); + await expect.element(page.getByRole('button', { name: 'Wait: 1 day' })).toBeVisible(); + await expect + .element(page.getByRole('button', { name: 'Show performance' })) + .not.toBeInTheDocument(); + expect(request.requests).toHaveLength(0); + }, + ); +}); diff --git a/apps/admin/src/automations/utils/automation-entry-stats.test.ts b/apps/admin/src/automations/utils/automation-entry-stats.test.ts new file mode 100644 index 000000000000..91a2bc857e31 --- /dev/null +++ b/apps/admin/src/automations/utils/automation-entry-stats.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import moment from 'moment-timezone'; +import type { AutomationPerformanceStats } from '@tryghost/admin-x-framework/api/automations'; +import { mapAutomationEntryStats } from './automation-entry-stats'; + +const history = (days: number): AutomationPerformanceStats => { + const entries = Array.from({ length: days }, (_, i) => ({ + date: new Date(Date.UTC(2020, 0, 1 + i)).toISOString().slice(0, 10), + count: 1, + })); + return { + automation_id: 'one', + total_run_count: days, + in_progress_run_count: 0, + completed_run_count: days, + exited_early_run_count: 0, + entries, + entry_window: { + date_from: entries[0].date, + date_to: new Date(Date.UTC(2020, 0, 1 + days)).toISOString().slice(0, 10), + bucket: 'day', + timezone: 'UTC', + }, + }; +}; + +describe('automation entry chart mapping', () => { + afterEach(() => moment.tz.setDefault()); + it.each([ + { days: 90, displayRange: 90 }, + { days: 91, displayRange: 91 }, + { days: 270, displayRange: 91 }, + { days: 271, displayRange: 366 }, + { days: 1500, displayRange: 366 }, + ])('groups $days days without losing entries', ({ days, displayRange }) => { + const mapped = mapAutomationEntryStats(history(days)); + expect(mapped.range).toBe(displayRange); + expect(mapped.points.reduce((sum, point) => sum + point.value, 0)).toBe(days); + expect(mapped.points[0].date).toBe('2020-01-01'); + if (days < 91) { + expect(mapped.points).toHaveLength(days); + } else { + expect(mapped.points.length).toBeLessThan(days); + } + expect(mapped.max).toBe(Math.max(...mapped.points.map((point) => point.value))); + }); + + it('sums monthly entries and formats the bucket total after aggregation', () => { + const mapped = mapAutomationEntryStats(history(366)); + expect(mapped.points).toHaveLength(12); + expect(mapped.points[0]).toMatchObject({ date: '2020-01-01', value: 31, formattedValue: '31' }); + expect(mapped.points[1]).toMatchObject({ date: '2020-02-01', value: 29, formattedValue: '29' }); + }); + + it.each(['America/Los_Angeles', 'Pacific/Auckland'])( + 'preserves calendar dates in %s', + (timezone) => { + moment.tz.setDefault(timezone); + const mapped = mapAutomationEntryStats(history(3)); + expect(mapped.points.map((point) => point.date)).toEqual([ + '2020-01-01', + '2020-01-02', + '2020-01-03', + ]); + }, + ); + + it('keeps an empty chart at zero with a usable axis', () => { + const stats = history(1); + stats.entries[0].count = 0; + stats.total_run_count = 0; + stats.completed_run_count = 0; + expect(mapAutomationEntryStats(stats)).toMatchObject({ total: '0', max: 1, empty: true }); + }); +}); diff --git a/apps/admin/src/automations/utils/automation-entry-stats.ts b/apps/admin/src/automations/utils/automation-entry-stats.ts new file mode 100644 index 000000000000..205d221fb7ce --- /dev/null +++ b/apps/admin/src/automations/utils/automation-entry-stats.ts @@ -0,0 +1,27 @@ +import type { AutomationPerformanceStats } from '@tryghost/admin-x-framework/api/automations'; +import { formatNumber } from '@tryghost/shade/utils'; +import { getEffectiveChartRange, sanitizeChartData } from '@/shared/analytics/chart-helpers'; +import { STATS_RANGES } from '@/shared/analytics/constants'; + +export const mapAutomationEntryStats = (stats: AutomationPerformanceStats) => { + // This sentinel selects display grouping only; the API supplies the actual full history. + const range = STATS_RANGES.allTime.value; + const points = sanitizeChartData(stats.entries, range, 'count', 'sum').map(({ date, count }) => ({ + date, + value: count, + formattedValue: formatNumber(count), + label: 'Entries', + })); + return { + total: formatNumber(stats.total_run_count), + points, + range: getEffectiveChartRange(range, stats.entries, { fieldName: 'count' }), + max: Math.max(1, ...points.map((point) => point.value)), + empty: stats.entries.every((entry) => entry.count === 0), + startDate: stats.entry_window.date_from, + endDate: stats.entries[stats.entries.length - 1].date, + timezone: stats.entry_window.timezone, + }; +}; + +export type AutomationEntriesChartData = ReturnType; diff --git a/apps/admin/src/shared/analytics/gh-area-chart.component.test.tsx b/apps/admin/src/shared/analytics/gh-area-chart.component.test.tsx new file mode 100644 index 000000000000..061deaab33f5 --- /dev/null +++ b/apps/admin/src/shared/analytics/gh-area-chart.component.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { renderInApp } from '@test-utils/acceptance'; +import { GhAreaChart } from '@tryghost/shade/patterns'; +import { formatNumber } from '@tryghost/shade/utils'; +import { Box } from '@tryghost/shade/primitives'; + +// Exercise the shared chart in Admin's browser harness, where real SVG layout and hover work. +const renderChart = (dates: string[], value: number) => + renderInApp( + + ({ + date, + value, + formattedValue: formatNumber(value), + label: 'Entries', + }))} + id="entries" + range={30} + showYAxisValues={false} + yAxisRange={[0, Math.max(1, value)]} + /> + , + ); +const chart = () => page.getByRole('figure', { name: 'Entries chart' }); +const ticks = () => + chart().element().querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick text'); + +describe('GhAreaChart dates and tooltip', () => { + it.each([0, 7])('shows one centered date for a single day with %i entries', async (count) => { + await renderChart(['2026-06-22'], count); + await expect.poll(() => ticks().length).toBe(1); + expect(ticks()[0].textContent).toBe('22 Jun'); + expect(ticks()[0].getAttribute('text-anchor')).toBe('middle'); + }); + + it('aligns distinct endpoints and shows the hovered value', async () => { + await renderChart(['2026-06-22', '2026-06-23'], 7); + await expect.poll(() => ticks().length).toBe(2); + expect( + [...ticks()].map((tick) => [tick.textContent, tick.getAttribute('text-anchor')]), + ).toEqual([ + ['22 Jun', 'start'], + ['23 Jun', 'end'], + ]); + await chart().hover(); + await expect.element(chart().getByText('Entries', { exact: true })).toBeVisible(); + await expect.element(chart().getByText('7', { exact: true })).toBeVisible(); + }); +}); diff --git a/apps/shade/src/components/patterns/gh-chart.stories.tsx b/apps/shade/src/components/patterns/gh-chart.stories.tsx index 1a2f7407fc9b..d54f5562b4ef 100644 --- a/apps/shade/src/components/patterns/gh-chart.stories.tsx +++ b/apps/shade/src/components/patterns/gh-chart.stories.tsx @@ -115,3 +115,30 @@ export const HourlyData: Story = { showHours: true, }, }; + +export const SingleDay: Story = { + args: { + data: [{ date: '2026-09-28', value: 7, formattedValue: '7', label: 'Entries' }], + range: 1, + id: 'single-day-chart', + className: 'h-[180px]', + showYAxisValues: false, + yAxisRange: [0, 7], + }, + parameters: { + docs: { description: { story: 'A first-day chart shows one centered date label.' } }, + }, +}; + +export const SingleDayEmpty: Story = { + ...SingleDay, + args: { + ...SingleDay.args, + data: [{ date: '2026-09-28', value: 0, formattedValue: '0', label: 'Entries' }], + id: 'single-day-empty-chart', + yAxisRange: [0, 1], + }, + parameters: { + docs: { description: { story: 'An empty first day keeps the same single date label.' } }, + }, +}; diff --git a/apps/shade/src/components/patterns/gh-chart.tsx b/apps/shade/src/components/patterns/gh-chart.tsx index ea987d21e034..5c6c39900965 100644 --- a/apps/shade/src/components/patterns/gh-chart.tsx +++ b/apps/shade/src/components/patterns/gh-chart.tsx @@ -140,6 +140,7 @@ const GhAreaChart: React.FC = ({ const yTicks = isWholeMid ? [yRange[0], midValue, yRange[1]] : yRange; const xTickHoursOnly = showHours && range === 1; + const xTicks = data.length > 0 ? [...new Set([data[0].date, data[data.length - 1].date])] : []; return ( @@ -163,12 +164,13 @@ const GhAreaChart: React.FC = ({ formatter={(value) => formatDisplayDateWithRange(String(value), range, showHours, xTickHoursOnly) } + textAnchor={xTicks.length === 1 ? 'middle' : undefined} /> )} tickFormatter={(value) => formatDisplayDateWithRange(String(value), range, showHours)} tickLine={false} tickMargin={10} - ticks={data && data.length > 0 ? [data[0].date, data[data.length - 1].date] : []} + ticks={xTicks} /> string; } @@ -332,9 +333,9 @@ const AlignedAxisTick: React.FC = ({ y, payload, index, + textAnchor = index === 0 ? 'start' : 'end', formatter = (v) => String(v), }) => { - const textAnchor = index === 0 ? 'start' : 'end'; return (