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
48 changes: 48 additions & 0 deletions packages/shared/src/graphql/creatorAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
StaleTime,
} from '../lib/query';
import type { LoggedUser } from '../lib/user';
import type { UserPostsAnalytics } from './users';
import { USER_POSTS_ANALYTICS_QUERY } from './users';

export enum CreatorPerformancePeriod {
Last30Days = 'LAST_30_DAYS',
Expand Down Expand Up @@ -65,6 +67,20 @@ export interface CreatorPerformanceCoverage {
isPreviousPeriodComplete: boolean;
}

/**
* One day of the impressions chart.
*
* Only measured days are returned. Padding the axis is the client's job, and
* only between `coverage.coveredStartDate` and `coverage.endDate` — a zero
* drawn before that would claim a day nobody measured.
*/
export interface CreatorImpressionsPoint {
/** `YYYY-MM-DD`, UTC. */
date: string;
impressions: number;
impressionsAds: number;
}

export interface CreatorPerformance {
period: CreatorPerformancePeriod;
coverage: CreatorPerformanceCoverage;
Expand All @@ -73,6 +89,7 @@ export interface CreatorPerformance {
outboundVisits: CreatorMetric;
upvotes: CreatorMetric;
comments: CreatorMetric;
impressionsSeries: CreatorImpressionsPoint[];
}

export interface CreatorPostPerformance {
Expand Down Expand Up @@ -127,6 +144,11 @@ export const CREATOR_PERFORMANCE_QUERY = gql`
comments {
...CreatorMetricFragment
}
impressionsSeries {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (merge order, not code): daily-api#4287 is still open. Once this lands and deploys ahead of the API, creatorPerformance fails GraphQL validation on the unknown impressionsSeries field, so for every creator the overview tiles go to CreatorDashboardError, the chart section is hidden, and only the article table and the followers/reputation tiles survive. The PR description already calls this out; please hold the merge until #4287 is deployed, or split the impressionsSeries selection into a follow-up that lands after it.

Reviewed by AI.

date
impressions
impressionsAds
}
}
}
${CREATOR_METRIC_FRAGMENT}
Expand Down Expand Up @@ -250,3 +272,29 @@ export const creatorPostPerformanceQueryOptions = ({
enabled: !!user,
staleTime: StaleTime.Default,
});

/**
* Lifetime totals that the period-scoped contract deliberately does not carry.
*
* Followers and reputation have no daily grain and never will — they are
* running counters on the creator, not events inside a window. They are read
* from the existing `userPostsAnalytics` row rather than bolted onto
* `creatorPerformance`, so nothing in the period contract has to pretend they
* belong to the selected window.
*/
export const creatorLifetimeTotalsQueryOptions = ({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: with UserPostsAnalyticsTable and the old page gone, USER_POSTS_ANALYTICS_HISTORY_QUERY, USER_POSTS_WITH_ANALYTICS_QUERY, UserPostsAnalyticsHistoryNode, UserPostWithAnalytics and the RequestKey.UserPostsAnalyticsHistory / UserPostsWithAnalytics entries have no callers in the monorepo (the PR notes this). Convention here is to remove dead exports in the same PR rather than leave a follow-up; it is a pure deletion and keeps graphql/users.ts honest about what the client actually asks for. Separately, this options builder pulls the full userPostsAnalytics row for two fields; small enough to accept, just noting it.

Reviewed by AI.

user,
}: {
user: Pick<LoggedUser, 'id'> | null | undefined;
}) => ({
queryKey: generateQueryKey(RequestKey.UserPostsAnalytics, user ?? undefined),
queryFn: async () => {
const { userPostsAnalytics } = await gqlClient.request<{
userPostsAnalytics: UserPostsAnalytics;
}>(USER_POSTS_ANALYTICS_QUERY);

return userPostsAnalytics;
},
enabled: !!user,
staleTime: StaleTime.Default,
});
90 changes: 90 additions & 0 deletions packages/webapp/__tests__/CreatorMetricTile.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { CreatorMetric } from '@dailydotdev/shared/src/graphql/creatorAnalytics';
import {
CreatorMetricSemantics,
CreatorPerformancePeriod,
} from '@dailydotdev/shared/src/graphql/creatorAnalytics';
import { CreatorMetricTile } from '../components/analytics/creator/CreatorMetricTile';

const renderTile = (metric: CreatorMetric) =>
render(
<QueryClientProvider client={new QueryClient()}>
<CreatorMetricTile
label="Impressions"
info="How many times your posts appeared."
icon={null}
metric={metric}
period={CreatorPerformancePeriod.Last30Days}
unknownReason="Daily impressions history does not reach this period."
/>
</QueryClientProvider>,
);

describe('CreatorMetricTile', () => {
it('should show the reason instead of a zero when the value is unknown', () => {
renderTile({
value: null,
previous: null,
semantics: CreatorMetricSemantics.Period,
});

expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();
expect(
screen.getByLabelText(
'Daily impressions history does not reach this period.',
),
).toBeInTheDocument();
});

it('should show no comparison when the prior window is withheld', () => {
renderTile({
value: 120,
previous: null,
semantics: CreatorMetricSemantics.Period,
});

expect(screen.getByText('120')).toBeInTheDocument();
expect(screen.queryByText(/%/)).not.toBeInTheDocument();
expect(screen.queryByText('New')).not.toBeInTheDocument();
expect(screen.getByText('Last 30 days')).toBeInTheDocument();
});

it('should caption a lifetime metric as all time', () => {
renderTile({
value: 2100,
previous: null,
semantics: CreatorMetricSemantics.Lifetime,
});

expect(screen.getByText('All time')).toBeInTheDocument();
expect(screen.queryByText('Last 30 days')).not.toBeInTheDocument();
});

it('should show the reason when a lifetime counter failed to load', () => {
// Followers and reputation come from a separate query; if it fails the
// tile must not settle on zero, which would read as "nobody follows you".
renderTile({
value: null,
previous: null,
semantics: CreatorMetricSemantics.Lifetime,
});

expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();
expect(screen.getByText('All time')).toBeInTheDocument();
});

it('should render a signed comparison when one is honest', () => {
renderTile({
value: 150,
previous: 100,
semantics: CreatorMetricSemantics.Period,
});

expect(screen.getByText(/\+/)).toBeInTheDocument();
expect(screen.getByText(/50%/)).toBeInTheDocument();
});
});
138 changes: 138 additions & 0 deletions packages/webapp/__tests__/CreatorPostPerformanceTable.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { CreatorPostPerformance } from '@dailydotdev/shared/src/graphql/creatorAnalytics';
import {
CreatorPostSortBy,
CreatorPostSortOrder,
} from '@dailydotdev/shared/src/graphql/creatorAnalytics';
import { CreatorPostPerformanceTable } from '../components/analytics/creator/CreatorPostPerformanceTable';

const row = (
partial: Partial<CreatorPostPerformance> = {},
): CreatorPostPerformance => ({
id: 'p1',
post: {
id: 'p1',
title: 'A post that exists',
image: null,
createdAt: '2026-09-01T00:00:00.000Z',
commentsPermalink: 'https://app.daily.dev/posts/p1',
},
impressions: 1234,
upvotes: 12,
comments: 3,
outboundVisits: 45,
...partial,
});

const renderTable = (
props: Partial<React.ComponentProps<typeof CreatorPostPerformanceTable>> = {},
) => {
const onSortChange = jest.fn();

render(
// Tooltip reads the request protocol off the query client, so even a
// purely presentational render needs one.
<QueryClientProvider client={new QueryClient()}>
<CreatorPostPerformanceTable
posts={[row()]}
sort={{
sortBy: CreatorPostSortBy.PublishedAt,
order: CreatorPostSortOrder.Desc,
}}
onSortChange={onSortChange}
isPending={false}
hasNextPage={false}
isFetchingNextPage={false}
fetchNextPage={jest.fn()}
{...props}
/>
</QueryClientProvider>,
);

return { onSortChange };
};

describe('CreatorPostPerformanceTable', () => {
it('should render an em dash rather than a zero for unknown impressions', () => {
renderTable({ posts: [row({ impressions: null })] });

expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});

it('should distinguish a real zero from an unknown value', () => {
renderTable({ posts: [row({ impressions: 0 })] });

expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.queryByText('—')).not.toBeInTheDocument();
});

it('should link the comment count straight to the discussion', () => {
renderTable();

expect(
screen.getByRole('link', {
name: 'Open the discussion on A post that exists',
}),
).toHaveAttribute('href', 'https://app.daily.dev/posts/p1');
});

it('should announce which column is sorted and in which direction', () => {
renderTable({
sort: {
sortBy: CreatorPostSortBy.Impressions,
order: CreatorPostSortOrder.Desc,
},
});

expect(
screen.getByRole('columnheader', { name: /impressions/i }),
).toHaveAttribute('aria-sort', 'descending');
expect(
screen.getByRole('columnheader', { name: /upvotes/i }),
).toHaveAttribute('aria-sort', 'none');
});

it('should start a newly picked column descending', async () => {
const { onSortChange } = renderTable();

await userEvent.click(screen.getByRole('button', { name: /upvotes/i }));

expect(onSortChange).toHaveBeenCalledWith({
sortBy: CreatorPostSortBy.Upvotes,
order: CreatorPostSortOrder.Desc,
});
});

it('should flip the direction when the active column is picked again', async () => {
const { onSortChange } = renderTable({
sort: {
sortBy: CreatorPostSortBy.Upvotes,
order: CreatorPostSortOrder.Desc,
},
});

await userEvent.click(screen.getByRole('button', { name: /upvotes/i }));

expect(onSortChange).toHaveBeenCalledWith({
sortBy: CreatorPostSortBy.Upvotes,
order: CreatorPostSortOrder.Asc,
});
});

it('should be sortable from the keyboard', async () => {
const { onSortChange } = renderTable();

const header = screen.getByRole('button', { name: /comments/i });
header.focus();
await userEvent.keyboard('{Enter}');

expect(onSortChange).toHaveBeenCalledWith({
sortBy: CreatorPostSortBy.Comments,
order: CreatorPostSortOrder.Desc,
});
});
});
Loading
Loading