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
6 changes: 3 additions & 3 deletions .agents/skills/format-number/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ autoTrigger:

# Format Numbers

When editing `.tsx` files, ensure all user-facing numbers are formatted using the `formatNumber` utility from `@tryghost/shade`.
When editing `.tsx` files, ensure all user-facing numbers are formatted using the `formatNumber` utility from `@tryghost/shade/utils`.

## Import

```typescript
import {formatNumber} from '@tryghost/shade';
import { formatNumber } from '@tryghost/shade/utils';
```

## When to use formatNumber
Expand Down Expand Up @@ -58,4 +58,4 @@ Do NOT use any of these patterns for formatting numbers in TSX files:
- `abbreviateNumber()` - for compact notation (e.g., 1.2M, 50k)
- `centsToDollars()` - convert cents to dollars before passing to `formatNumber`

All are imported from `@tryghost/shade`.
All are imported from `@tryghost/shade/utils`.
175 changes: 175 additions & 0 deletions apps/admin/src/analytics/analytics.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
TINYBIRD_SITE_UUID,
currentRoute,
fakeAdminEndpoint,
fakeAdminStats,
fakeAnalyticsOverview,
fakeNewsletters,
Expand Down Expand Up @@ -88,6 +89,40 @@ function seedTopPostsViews() {
]);
}

/**
* The stats views' world with web analytics on but nothing recorded yet: a
* site with no members, no MRR, no posts and no visits — the empty states.
*/
function seedEmptyAnalyticsWorld() {
fakeAdminStats.memberCount();
fakeAdminStats.mrr();
fakeAdminStats.subscriptions();
fakeAdminStats.topPostViews();
fakePosts([]);
fakeTinybirdToken();
fakeTinybirdPipe('api_active_visitors', []);
fakeTinybirdPipe('api_kpis', []);
}

/** The web traffic view's own cards, all empty. */
function seedEmptyWebTraffic() {
fakeAdminStats.topContent([]);
fakeTinybirdPipe('api_top_sources', []);
fakeTinybirdPipe('api_top_locations', []);
}

/**
* A member-count history of zero rows, the shape the server serves for a
* site with no members — a fully empty history would collapse the growth
* view into its no-stats state instead of rendering the empty cards.
*/
function seedZeroMemberHistory() {
fakeAdminStats.memberCount({
stats: [{ date: daysAgo(1), free: 0, paid: 0, comped: 0 }],
totals: { free: 0, paid: 0, comped: 0, gift: 0 },
});
}

describe('Analytics overview', () => {
it('renders zero KPIs when growth history is empty', async () => {
fakeAnalyticsOverview();
Expand Down Expand Up @@ -138,6 +173,66 @@ describe('Analytics overview', () => {
await expect.element(analyticsScreen.dateRangeSelect()).toHaveTextContent('Last 7 days');
await expect.poll(() => kpisApi.lastRequest?.params.get('date_from')).not.toBe(initialDateFrom);
});

it('renders the latest and top posts with zeroed stats when nothing is recorded', async () => {
seedEmptyAnalyticsWorld();
fakePosts([
post({
id: LATEST_POST_ID,
title: 'Attack of the Clones',
status: 'published',
published_at: `${daysAgo(3)}T10:00:00.000Z`,
url: 'https://example.com/attack-of-the-clones/',
}),
]);
fakeAdminStats.post(LATEST_POST_ID);
// The server lists recently published posts in top posts even before
// they record any views; the row must render its zeros as zeros.
fakeAdminStats.topPostViews([
{
post_id: LATEST_POST_ID,
title: 'Attack of the Clones',
published_at: `${daysAgo(3)}T10:00:00.000Z`,
},
]);
await renderAdminApp('/analytics', { boot: webAnalyticsBootOverrides() });

await expect.element(analyticsScreen.latestPost()).toHaveTextContent('Attack of the Clones');
await expect.element(analyticsScreen.latestPostVisitors()).toHaveTextContent('0');
await expect.element(analyticsScreen.latestPostMembers()).toHaveTextContent('0');

await expect.element(analyticsScreen.topPostsCard()).toHaveTextContent('Attack of the Clones');
const visitorsStatistics = analyticsScreen.topPostsVisitorsStatistics();
await expect.element(visitorsStatistics).toHaveTextContent('Unique visitors');
await expect.element(visitorsStatistics).toHaveTextContent('0');
const membersStatistics = analyticsScreen.topPostsMembersStatistics();
await expect.element(membersStatistics).toHaveTextContent('New members');
await expect.element(membersStatistics).toHaveTextContent('Free');
await expect.element(membersStatistics).toHaveTextContent('0');
});

it('navigates to the web traffic view from the visitors KPI', async () => {
seedEmptyAnalyticsWorld();
seedEmptyWebTraffic();
await renderAdminApp('/analytics', { boot: webAnalyticsBootOverrides() });

await analyticsScreen.uniqueVisitorsViewMoreButton().click();

await expect.poll(currentRoute).toMatch(/^\/analytics\/web\/?$/);
await expect.element(analyticsScreen.uniqueVisitorsTab()).toBeVisible();
});

it('navigates to the growth view from the members KPI', async () => {
seedEmptyAnalyticsWorld();
seedZeroMemberHistory();
fakeAdminStats.topPosts();
await renderAdminApp('/analytics', { boot: webAnalyticsBootOverrides() });

await analyticsScreen.membersViewMoreButton().click();

await expect.poll(currentRoute).toMatch(/^\/analytics\/growth\//);
await expect.element(analyticsScreen.totalMembersCard()).toBeVisible();
});
});

describe('Analytics web traffic', () => {
Expand Down Expand Up @@ -184,6 +279,30 @@ describe('Analytics web traffic', () => {
await expect.element(analyticsScreen.sourceRow('google.com')).toHaveTextContent('170');
await expect.element(analyticsScreen.locationRow('US')).toHaveTextContent('United States');
});

it('renders zeroed KPIs and empty cards when there are no visits', async () => {
seedEmptyAnalyticsWorld();
seedEmptyWebTraffic();
await renderAdminApp('/analytics/web', { boot: webAnalyticsBootOverrides() });

await expect.element(analyticsScreen.uniqueVisitorsTab()).toHaveTextContent('0');
await expect.element(analyticsScreen.totalViewsTab()).toHaveTextContent('0');
await expect.element(analyticsScreen.topContentCard()).toHaveTextContent('No visitors');
await expect.element(analyticsScreen.topSourcesCard()).toHaveTextContent('No visitors');
await expect.element(analyticsScreen.locationsCard()).toHaveTextContent('No visitors');
});

it('keeps the empty state across the top content tabs', async () => {
seedEmptyAnalyticsWorld();
seedEmptyWebTraffic();
await renderAdminApp('/analytics/web', { boot: webAnalyticsBootOverrides() });

await analyticsScreen.topContentTab('Posts').click();
await expect.element(analyticsScreen.topContentCard()).toHaveTextContent('No visitors');

await analyticsScreen.topContentTab('Pages').click();
await expect.element(analyticsScreen.topContentCard()).toHaveTextContent('No visitors');
});
});

describe('Analytics growth', () => {
Expand Down Expand Up @@ -212,6 +331,38 @@ describe('Analytics growth', () => {
.toHaveTextContent('Attack of the Clones');
await expect.element(analyticsScreen.topContentCard()).toHaveTextContent('+30');
});

it('shows No conversions across the top content tabs when nothing converted', async () => {
seedEmptyAnalyticsWorld();
seedZeroMemberHistory();
fakeAdminStats.topPosts();
fakeAdminEndpoint('GET', /^\/stats\/top-sources-growth/, { stats: [], meta: {} });
await renderAdminApp('/analytics/growth', { boot: webAnalyticsBootOverrides() });

const contentCard = analyticsScreen.topContentCard();
await expect
.element(contentCard)
.toHaveTextContent('Which posts or pages drove the most growth in the last 30 days');
await expect.element(contentCard).toHaveTextContent('No conversions');

await analyticsScreen.topContentTab('Posts').click();
await expect
.element(contentCard)
.toHaveTextContent('Which posts drove the most growth in the last 30 days');
await expect.element(contentCard).toHaveTextContent('No conversions');

await analyticsScreen.topContentTab('Pages').click();
await expect
.element(contentCard)
.toHaveTextContent('Which pages drove the most growth in the last 30 days');
await expect.element(contentCard).toHaveTextContent('No conversions');

await analyticsScreen.topContentTab('Sources').click();
await expect
.element(contentCard)
.toHaveTextContent('Which sources drove the most growth in the last 30 days');
await expect.element(contentCard).toHaveTextContent('No conversions');
});
});

describe('Analytics newsletters', () => {
Expand Down Expand Up @@ -255,4 +406,28 @@ describe('Analytics newsletters', () => {
.element(analyticsScreen.topNewslettersCard())
.toHaveTextContent('Weekly Digest Issue #1');
});

it('shows the empty state on every newsletter card when none were sent', async () => {
seedEmptyAnalyticsWorld();
fakeNewsletters([newsletter({ name: 'Weekly Digest', status: 'active', sort_order: 0 })]);
fakeAdminStats.newsletterSubscribers();
fakeAdminStats.newsletterBasic();
fakeAdminStats.newsletterClicks();
await renderAdminApp('/analytics/newsletters', { boot: webAnalyticsBootOverrides() });

await expect.element(analyticsScreen.newslettersCard()).toBeVisible();
await expect
.element(analyticsScreen.topNewslettersCard())
.toHaveTextContent('newsletters in the last 30 days');

await analyticsScreen.newslettersCardTab('Avg. open rate').click();
await expect
.element(analyticsScreen.newslettersCard())
.toHaveTextContent('No newsletters in the last 30 days');

await analyticsScreen.newslettersCardTab('Avg. click rate').click();
await expect
.element(analyticsScreen.newslettersCard())
.toHaveTextContent('No newsletters in the last 30 days');
});
});
17 changes: 17 additions & 0 deletions apps/admin/src/analytics/analytics.screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,28 @@ export const analyticsScreen = {
page.getByTestId(sel.uniqueVisitors).getByTestId(sel.kpiCardHeaderValue),
membersCard: () => page.getByTestId(sel.members),
membersValue: () => page.getByTestId(sel.members).getByTestId(sel.kpiCardHeaderValue),
membersViewMoreButton: () =>
page.getByTestId(sel.members).getByRole('button', { name: 'View more' }),
mrrValue: () => page.getByTestId(sel.mrr).getByTestId(sel.kpiCardHeaderValue),
uniqueVisitorsViewMoreButton: () =>
page.getByTestId(sel.uniqueVisitors).getByRole('button', { name: 'View more' }),
latestPost: () => page.getByTestId(sel.latestPost),
latestPostVisitors: () => page.getByTestId(sel.latestPost).getByTestId(sel.latestPostVisitors),
latestPostMembers: () => page.getByTestId(sel.latestPost).getByTestId(sel.latestPostMembers),
topPostsCard: () => page.getByTestId(sel.topPostsCard),
topPostsVisitorsStatistics: () =>
page.getByTestId(sel.topPostsCard).getByTestId(sel.statisticsVisitors),
topPostsMembersStatistics: () =>
page.getByTestId(sel.topPostsCard).getByTestId(sel.statisticsMembers),

// Web
webGraph: () => page.getByTestId(sel.webGraph),
uniqueVisitorsTab: () =>
page.getByTestId(sel.webGraph).getByRole('tab', { name: 'Unique visitors' }),
totalViewsTab: () => page.getByTestId(sel.webGraph).getByRole('tab', { name: 'Total views' }),
topContentCard: () => page.getByTestId(sel.topContentCard),
topContentTab: (name: string) =>
page.getByTestId(sel.topContentCard).getByRole('tab', { name, exact: true }),
topSourcesCard: () => page.getByTestId(sel.topSourcesCard),
sourceRow: (source: string) => page.getByTestId(`${sel.sourceRowPrefix}${source}`),
locationsCard: () => page.getByTestId(sel.visitorsCard),
Expand All @@ -26,6 +41,8 @@ export const analyticsScreen = {

// Newsletters
newslettersCard: () => page.getByTestId(sel.newslettersCard),
newslettersCardTab: (name: string) =>
page.getByTestId(sel.newslettersCard).getByRole('tab', { name }),
totalSubscribersValue: () => page.getByTestId(sel.totalSubscribersValue),
topNewslettersCard: () => page.getByTestId(sel.topNewslettersCard),

Expand Down
4 changes: 3 additions & 1 deletion apps/admin/src/layout/sidebar.screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export const sidebarScreen = {
shellNav: () => page.getByTestId(adminSidebar),
/** The shell's content area, rendered by AdminLayout alongside the sidebar. */
shellMain: () => page.getByRole('main').first(),
navLink: (name: string) => page.getByRole('navigation').getByRole('link', { name, exact: true }),
// Scoped to the shell sidebar: in-content navigation (e.g. the tag
// detail breadcrumb) repeats the same link names.
navLink: (name: string) => sidebarScreen.shellNav().getByRole('link', { name, exact: true }),
postsToggle: () => page.getByRole('button', { name: postsToggle }),
networkBadge: () => page.getByTestId(networkNotificationBadge),
userMenuTrigger: () => page.getByRole('button', { name: userMenuTrigger }),
Expand Down
Loading
Loading