diff --git a/packages/shared/src/graphql/creatorAnalytics.ts b/packages/shared/src/graphql/creatorAnalytics.ts index 3a659e01db2..ea5395c038c 100644 --- a/packages/shared/src/graphql/creatorAnalytics.ts +++ b/packages/shared/src/graphql/creatorAnalytics.ts @@ -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', @@ -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; @@ -73,6 +89,7 @@ export interface CreatorPerformance { outboundVisits: CreatorMetric; upvotes: CreatorMetric; comments: CreatorMetric; + impressionsSeries: CreatorImpressionsPoint[]; } export interface CreatorPostPerformance { @@ -127,6 +144,11 @@ export const CREATOR_PERFORMANCE_QUERY = gql` comments { ...CreatorMetricFragment } + impressionsSeries { + date + impressions + impressionsAds + } } } ${CREATOR_METRIC_FRAGMENT} @@ -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 = ({ + user, +}: { + user: Pick | 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, +}); diff --git a/packages/webapp/__tests__/CreatorMetricTile.spec.tsx b/packages/webapp/__tests__/CreatorMetricTile.spec.tsx new file mode 100644 index 00000000000..5f0c88e1ac6 --- /dev/null +++ b/packages/webapp/__tests__/CreatorMetricTile.spec.tsx @@ -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( + + + , + ); + +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(); + }); +}); diff --git a/packages/webapp/__tests__/CreatorPostPerformanceTable.spec.tsx b/packages/webapp/__tests__/CreatorPostPerformanceTable.spec.tsx new file mode 100644 index 00000000000..c3087907f8c --- /dev/null +++ b/packages/webapp/__tests__/CreatorPostPerformanceTable.spec.tsx @@ -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 => ({ + 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> = {}, +) => { + const onSortChange = jest.fn(); + + render( + // Tooltip reads the request protocol off the query client, so even a + // purely presentational render needs one. + + + , + ); + + 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, + }); + }); +}); diff --git a/packages/webapp/__tests__/creatorAnalytics.spec.ts b/packages/webapp/__tests__/creatorAnalytics.spec.ts new file mode 100644 index 00000000000..5744132f72c --- /dev/null +++ b/packages/webapp/__tests__/creatorAnalytics.spec.ts @@ -0,0 +1,195 @@ +import { + CreatorMetricSemantics, + CreatorPerformancePeriod, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import type { + CreatorMetric, + CreatorPerformanceCoverage, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { + buildImpressionsChartData, + getCoverageNote, + getMetricDelta, + metricScopeLabel, +} from '../components/analytics/creator/common'; + +const metric = (partial: Partial = {}): CreatorMetric => ({ + value: 100, + previous: 50, + semantics: CreatorMetricSemantics.Period, + ...partial, +}); + +const coverage = ( + partial: Partial = {}, +): CreatorPerformanceCoverage => ({ + requestedStartDate: '2026-08-23', + endDate: '2026-09-21', + coveredStartDate: '2026-08-23', + firstAvailableDate: '2026-08-23', + lastAvailableDate: '2026-09-21', + requestedDays: 30, + coveredDays: 30, + isComplete: true, + isPreviousPeriodComplete: true, + ...partial, +}); + +describe('getMetricDelta', () => { + it('should report a signed percentage change', () => { + expect(getMetricDelta(metric({ value: 150, previous: 100 }))).toEqual({ + kind: 'change', + percentage: 50, + }); + expect(getMetricDelta(metric({ value: 50, previous: 100 }))).toEqual({ + kind: 'change', + percentage: -50, + }); + }); + + it('should not compare when the prior window is withheld', () => { + // The server nulls `previous` when history does not cover the prior + // window; drawing any comparison from that would be invented. + expect(getMetricDelta(metric({ previous: null }))).toEqual({ + kind: 'none', + }); + }); + + it('should not compare an unknown value', () => { + expect(getMetricDelta(metric({ value: null }))).toEqual({ kind: 'none' }); + }); + + it('should say New instead of a percentage off a zero baseline', () => { + expect(getMetricDelta(metric({ value: 10, previous: 0 }))).toEqual({ + kind: 'new', + }); + }); + + it('should say nothing when both windows are zero', () => { + expect(getMetricDelta(metric({ value: 0, previous: 0 }))).toEqual({ + kind: 'none', + }); + }); +}); + +describe('metricScopeLabel', () => { + it('should caption a lifetime metric as all time even under a period', () => { + expect( + metricScopeLabel( + metric({ semantics: CreatorMetricSemantics.Lifetime }), + CreatorPerformancePeriod.Last30Days, + ), + ).toEqual('All time'); + }); + + it('should caption a period metric with the selected window', () => { + expect( + metricScopeLabel(metric(), CreatorPerformancePeriod.Last90Days), + ).toEqual('Last 90 days'); + }); +}); + +describe('buildImpressionsChartData', () => { + it('should pad a measured day that has no row with a zero', () => { + const data = buildImpressionsChartData({ + coverage: coverage({ + coveredStartDate: '2026-09-19', + endDate: '2026-09-21', + }), + impressionsSeries: [ + { date: '2026-09-19', impressions: 10, impressionsAds: 0 }, + { date: '2026-09-21', impressions: 5, impressionsAds: 2 }, + ], + }); + + expect(data.map(({ value }) => value)).toEqual([10, 0, 7]); + }); + + it('should start the axis at coverage, not at the requested window', () => { + // The unmeasured days before `coveredStartDate` are left off entirely + // rather than drawn as zeros that look like a collapse in reach. + const data = buildImpressionsChartData({ + coverage: coverage({ + requestedStartDate: '2026-09-01', + coveredStartDate: '2026-09-20', + endDate: '2026-09-21', + isComplete: false, + coveredDays: 2, + }), + impressionsSeries: [ + { date: '2026-09-20', impressions: 1, impressionsAds: 0 }, + ], + }); + + expect(data).toHaveLength(2); + expect(data[0].name).toEqual('Sep 20'); + }); + + it('should mark a day as boosted only when it carries ad impressions', () => { + const data = buildImpressionsChartData({ + coverage: coverage({ + coveredStartDate: '2026-09-20', + endDate: '2026-09-21', + }), + impressionsSeries: [ + { date: '2026-09-20', impressions: 10, impressionsAds: 0 }, + { date: '2026-09-21', impressions: 10, impressionsAds: 3 }, + ], + }); + + expect(data.map(({ isBoosted }) => isBoosted)).toEqual([false, true]); + }); + + it('should render nothing when no day of the window was measured', () => { + expect( + buildImpressionsChartData({ + coverage: coverage({ coveredStartDate: null, isComplete: false }), + impressionsSeries: [], + }), + ).toEqual([]); + }); + + it('should label days in UTC regardless of the viewer timezone', () => { + // A UTC-dated row must not shift a day for a viewer behind UTC. + const data = buildImpressionsChartData({ + coverage: coverage({ + coveredStartDate: '2026-09-21', + endDate: '2026-09-21', + }), + impressionsSeries: [ + { date: '2026-09-21', impressions: 1, impressionsAds: 0 }, + ], + }); + + expect(data[0].name).toEqual('Sep 21'); + }); +}); + +describe('getCoverageNote', () => { + it('should say nothing when the window is fully covered', () => { + expect(getCoverageNote(coverage())).toBeNull(); + }); + + it('should explain a window history only partly reaches', () => { + expect( + getCoverageNote( + coverage({ + isComplete: false, + coveredStartDate: '2026-09-01', + coveredDays: 21, + requestedDays: 30, + }), + ), + ).toEqual( + 'Daily impressions history starts Sep 1, 2026, so impressions cover 21 of the 30 days.', + ); + }); + + it('should explain a window history does not reach at all', () => { + expect( + getCoverageNote( + coverage({ isComplete: false, coveredStartDate: null, coveredDays: 0 }), + ), + ).toContain('does not reach this period yet'); + }); +}); diff --git a/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx b/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx deleted file mode 100644 index 6216d7a37df..00000000000 --- a/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React from 'react'; -import type { ReactElement } from 'react'; -import Link from '@dailydotdev/shared/src/components/utilities/Link'; -import { - Typography, - TypographyColor, - TypographyType, -} from '@dailydotdev/shared/src/components/typography/Typography'; -import { - Button, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; -import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import { largeNumberFormat } from '@dailydotdev/shared/src/lib'; -import { - TimeFormatType, - formatDate, -} from '@dailydotdev/shared/src/lib/dateFormat'; -import type { UserPostWithAnalytics } from '@dailydotdev/shared/src/graphql/users'; -import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; -import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; -import { cloudinaryPostImageCoverPlaceholder } from '@dailydotdev/shared/src/lib/image'; - -export interface UserPostsAnalyticsTableProps { - posts: UserPostWithAnalytics[]; - isLoading: boolean; - hasNextPage: boolean | undefined; - isFetchingNextPage: boolean; - fetchNextPage: () => void; -} - -export const UserPostsAnalyticsTable = ({ - posts, - isLoading, - hasNextPage, - isFetchingNextPage, - fetchNextPage, -}: UserPostsAnalyticsTableProps): ReactElement => { - const gridClassName = - 'grid grid-cols-[minmax(0,1fr)_max-content_max-content_max-content_max-content] gap-x-4'; - if (isLoading) { - return ( -
- - Loading posts... - -
- ); - } - - return ( -
-
-
-
- - Post - - - Date - - - Reputation - - - Impressions - - - Upvotes - -
- {posts.map((post, index) => { - const isLast = index === posts.length - 1; - - return ( - -
- -
- -
-
- - {post.sharedPost?.title || - post.title || - 'Untitled'} - - {post.isBoosted && ( - - )} -
-
-
- -
-
- - {formatDate({ - value: post.createdAt, - type: TimeFormatType.Post, - })} - -
-
- - {largeNumberFormat(post.analytics?.reputation ?? 0)} - -
-
- - {largeNumberFormat(post.analytics?.impressions ?? 0)} - -
-
- - {largeNumberFormat(post.analytics?.upvotes ?? 0)} - -
- {!isLast && ( -
- )} - - ); - })} -
-
-
- {hasNextPage && ( - - )} -
- ); -}; diff --git a/packages/webapp/components/analytics/creator/CreatorAchievementsSection.tsx b/packages/webapp/components/analytics/creator/CreatorAchievementsSection.tsx new file mode 100644 index 00000000000..89a3a882ac0 --- /dev/null +++ b/packages/webapp/components/analytics/creator/CreatorAchievementsSection.tsx @@ -0,0 +1,54 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; + +interface CreatorAchievementsSectionProps { + /** + * Earned achievements, rendered as a grid once ENG-2127 persists them. + * + * Deliberately has no default: a caller that has nothing to pass must pass + * an empty list and get the empty state, so no placeholder award can ever be + * mistaken for one the creator earned. + */ + children?: ReactNode; + isEmpty: boolean; +} + +/** + * The achievements slot. + * + * It exists now so ticket 05 can drop real records in without moving anything + * else on the page, and it shows nothing but an empty state until then — + * inventing sample badges here would put awards on screen that nobody won. + */ +export const CreatorAchievementsSection = ({ + children, + isEmpty, +}: CreatorAchievementsSectionProps): ReactElement => { + if (isEmpty) { + return ( +
+ + No achievements yet + + + Recognition you earn for your posts will show up here. + +
+ ); + } + + return
{children}
; +}; diff --git a/packages/webapp/components/analytics/creator/CreatorDashboardError.tsx b/packages/webapp/components/analytics/creator/CreatorDashboardError.tsx new file mode 100644 index 00000000000..f639bf935c0 --- /dev/null +++ b/packages/webapp/components/analytics/creator/CreatorDashboardError.tsx @@ -0,0 +1,57 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; + +interface CreatorDashboardErrorProps { + /** What failed, so a partial failure does not blank the whole page. */ + title: string; + onRetry: () => void; + isRetrying: boolean; +} + +/** + * A failed section, scoped to that section. + * + * Each query retries on its own so a broken article table still leaves the + * overview numbers on screen rather than replacing the page with one error. + */ +export const CreatorDashboardError = ({ + title, + onRetry, + isRetrying, +}: CreatorDashboardErrorProps): ReactElement => ( +
+
+ + {title} + + + Something went wrong on our side. Your numbers are safe. + +
+ +
+); diff --git a/packages/webapp/components/analytics/creator/CreatorImpressionsSection.tsx b/packages/webapp/components/analytics/creator/CreatorImpressionsSection.tsx new file mode 100644 index 00000000000..2bcce38b022 --- /dev/null +++ b/packages/webapp/components/analytics/creator/CreatorImpressionsSection.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import dynamic from 'next/dynamic'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { ElementPlaceholder } from '@dailydotdev/shared/src/components/ElementPlaceholder'; +import type { + CreatorPerformance, + CreatorPerformancePeriod, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { buildImpressionsChartData, periodLabel } from './common'; + +const CombinedImpressionsChart = dynamic( + () => + import( + '@dailydotdev/shared/src/components/analytics/CombinedImpressionsChart' + ).then((mod) => mod.CombinedImpressionsChart), + { + loading: () => , + }, +); + +const EmptyChart = ({ children }: { children: string }): ReactElement => ( +
+ + {children} + +
+); + +export const CreatorImpressionsSkeleton = (): ReactElement => ( + +); + +interface CreatorImpressionsSectionProps { + performance: CreatorPerformance; + period: CreatorPerformancePeriod; +} + +/** + * Daily impressions for the selected window. + * + * The axis stops where measurement stops rather than running the full window + * and flattening the unmeasured part to zero, so a creator whose history does + * not reach back 90 days sees a shorter chart and a sentence saying why — + * never a cliff that looks like their reach collapsed. + */ +export const CreatorImpressionsSection = ({ + performance, + period, +}: CreatorImpressionsSectionProps): ReactElement => { + const data = useMemo( + () => buildImpressionsChartData(performance), + [performance], + ); + const hasImpressions = data.some((point) => point.value > 0); + const { coverage } = performance; + const isTruncated = !coverage.isComplete && !!coverage.coveredStartDate; + + return ( +
+
+ + Daily impressions · {periodLabel[period].toLowerCase()} + + {hasImpressions && ( +
+
+
+ Organic +
+
+
+ Promoted +
+
+ )} +
+ {data.length === 0 && ( + + Daily impressions have not been recorded for this period yet. + + )} + {data.length > 0 && !hasImpressions && ( + + No impressions in this period. Check back once your posts start + getting views. + + )} + {data.length > 0 && hasImpressions && ( + + )} + {isTruncated && ( + + The chart starts where daily history does, covering{' '} + {coverage.coveredDays} of the {coverage.requestedDays} days. Earlier + days were not measured rather than being days without impressions. + + )} +
+ ); +}; diff --git a/packages/webapp/components/analytics/creator/CreatorMetricTile.tsx b/packages/webapp/components/analytics/creator/CreatorMetricTile.tsx new file mode 100644 index 00000000000..7d171200a22 --- /dev/null +++ b/packages/webapp/components/analytics/creator/CreatorMetricTile.tsx @@ -0,0 +1,118 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import type { + CreatorMetric, + CreatorPerformancePeriod, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { getMetricDelta, metricScopeLabel, unknownValueLabel } from './common'; + +interface CreatorMetricTileProps { + label: string; + info: string; + icon: ReactNode; + metric: CreatorMetric; + period: CreatorPerformancePeriod; + /** Why the number is unknown, shown instead of a zero. */ + unknownReason: string; +} + +const deltaColor = (percentage: number): TypographyColor => { + if (percentage > 0) { + return TypographyColor.StatusSuccess; + } + + return percentage < 0 + ? TypographyColor.StatusError + : TypographyColor.Tertiary; +}; + +const DeltaChip = ({ + metric, +}: { + metric: CreatorMetric; +}): ReactElement | null => { + const delta = getMetricDelta(metric); + + if (delta.kind === 'none') { + return null; + } + + if (delta.kind === 'new') { + return ( + + + New + + + ); + } + + const { percentage } = delta; + + return ( + + {percentage > 0 && '+'} + {percentage}% + + ); +}; + +export const CreatorMetricTile = ({ + label, + info, + icon, + metric, + period, + unknownReason, +}: CreatorMetricTileProps): ReactElement => { + const isUnknown = metric.value === null; + + return ( + + + {unknownValueLabel} + + + ) : ( + metric.value + ) + } + subtitle={ + + {!isUnknown && } + + {metricScopeLabel(metric, period)} + + + } + /> + ); +}; diff --git a/packages/webapp/components/analytics/creator/CreatorOverviewSection.tsx b/packages/webapp/components/analytics/creator/CreatorOverviewSection.tsx new file mode 100644 index 00000000000..ddaadac7eb9 --- /dev/null +++ b/packages/webapp/components/analytics/creator/CreatorOverviewSection.tsx @@ -0,0 +1,171 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + AddUserIcon, + DiscussIcon, + EyeIcon, + LinkIcon, + ReputationIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { ElementPlaceholder } from '@dailydotdev/shared/src/components/ElementPlaceholder'; +import type { + CreatorMetric, + CreatorPerformance, + CreatorPerformancePeriod, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { CreatorMetricSemantics } from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import type { UserPostsAnalytics } from '@dailydotdev/shared/src/graphql/users'; +import { CreatorMetricTile } from './CreatorMetricTile'; +import { formatCoverageDate, getCoverageNote, periodLabel } from './common'; + +const iconClassName = 'text-text-tertiary'; + +const gridClassName = 'grid grid-cols-2 gap-4 tablet:grid-cols-3'; + +/** + * A running counter dressed as a metric so it renders through the same tile. + * + * `previous: null` is what suppresses the comparison chip, and the LIFETIME + * semantics is what captions it "All time" — both are properties of the + * number, not of the tile, so they travel with it. + */ +const lifetimeMetric = (value: number | null | undefined): CreatorMetric => ({ + value: value ?? null, + previous: null, + semantics: CreatorMetricSemantics.Lifetime, +}); + +export const CreatorOverviewSkeleton = (): ReactElement => ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+); + +interface CreatorOverviewSectionProps { + performance: CreatorPerformance; + period: CreatorPerformancePeriod; + /** Lifetime counters, `null` when that query failed. */ + lifetime: UserPostsAnalytics | null | undefined; +} + +/** + * The headline numbers, split by what they are actually scoped to. + * + * Impressions, upvotes and comments answer "in the selected window". + * Outbound visits, followers and reputation are running totals with no daily + * grain to scope them, so they sit in their own group rather than under a + * period heading that would not be true of them. + */ +export const CreatorOverviewSection = ({ + performance, + period, + lifetime, +}: CreatorOverviewSectionProps): ReactElement => { + const { coverage, updatedAt } = performance; + const coverageNote = getCoverageNote(coverage); + + return ( +
+
+ } + metric={performance.impressions} + period={period} + unknownReason="Daily impressions history does not reach this period, so this cannot be measured." + /> + } + metric={performance.upvotes} + period={period} + unknownReason="This cannot be measured for the selected period." + /> + } + metric={performance.comments} + period={period} + unknownReason="This cannot be measured for the selected period." + /> +
+ {/* Lifetime counters are grouped apart from the period ones rather than + mixed into the same grid, so the split is visible before anyone + reads a caption. */} + + All time + +
+ } + metric={performance.outboundVisits} + period={period} + unknownReason="No click data has been recorded for your posts yet." + /> + } + metric={lifetimeMetric(lifetime?.followers)} + period={period} + unknownReason="Your follower count could not be loaded." + /> + + } + metric={lifetimeMetric(lifetime?.reputation)} + period={period} + unknownReason="Your reputation could not be loaded." + /> +
+
+ + {periodLabel[period]}, through {formatCoverageDate(coverage.endDate)}{' '} + (UTC). Today is still being counted and is not included. + {updatedAt && + ` Last refreshed ${new Date(updatedAt).toLocaleString()}.`} + + {coverageNote && ( + + {coverageNote} + + )} +
+
+ ); +}; diff --git a/packages/webapp/components/analytics/creator/CreatorPeriodSelect.tsx b/packages/webapp/components/analytics/creator/CreatorPeriodSelect.tsx new file mode 100644 index 00000000000..3c1f6bbe8fb --- /dev/null +++ b/packages/webapp/components/analytics/creator/CreatorPeriodSelect.tsx @@ -0,0 +1,58 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { CreatorPerformancePeriod } from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { periodLabel, periodOptions } from './common'; + +interface CreatorPeriodSelectProps { + period: CreatorPerformancePeriod; + onChange: (period: CreatorPerformancePeriod) => void; + disabled?: boolean; +} + +export const CreatorPeriodSelect = ({ + period, + onChange, + disabled, +}: CreatorPeriodSelectProps): ReactElement => ( + + + + + + {periodOptions.map((option) => ( + onChange(option)} + // Radix exposes the checked state to assistive tech; the visual + // affordance is the trigger label, which already names the choice. + aria-current={option === period} + > + {periodLabel[option]} + + ))} + + +); diff --git a/packages/webapp/components/analytics/creator/CreatorPostPerformanceTable.tsx b/packages/webapp/components/analytics/creator/CreatorPostPerformanceTable.tsx new file mode 100644 index 00000000000..0ad8fe4e82c --- /dev/null +++ b/packages/webapp/components/analytics/creator/CreatorPostPerformanceTable.tsx @@ -0,0 +1,318 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { ElementPlaceholder } from '@dailydotdev/shared/src/components/ElementPlaceholder'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; +import { cloudinaryPostImageCoverPlaceholder } from '@dailydotdev/shared/src/lib/image'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib'; +import { + TimeFormatType, + formatDate, +} from '@dailydotdev/shared/src/lib/dateFormat'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import type { CreatorPostPerformance } from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { + CreatorPostSortBy, + CreatorPostSortOrder, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { unknownValueLabel } from './common'; + +export interface CreatorPostSort { + sortBy: CreatorPostSortBy; + order: CreatorPostSortOrder; +} + +interface CreatorPostPerformanceTableProps { + posts: CreatorPostPerformance[]; + sort: CreatorPostSort; + onSortChange: (sort: CreatorPostSort) => void; + isPending: boolean; + hasNextPage: boolean | undefined; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +} + +type Column = { + key: CreatorPostSortBy; + label: string; + /** Screen-reader description of what sorting this column means. */ + numeric: boolean; +}; + +const columns: Column[] = [ + { key: CreatorPostSortBy.PublishedAt, label: 'Published', numeric: false }, + { key: CreatorPostSortBy.Impressions, label: 'Impressions', numeric: true }, + { key: CreatorPostSortBy.Upvotes, label: 'Upvotes', numeric: true }, + { key: CreatorPostSortBy.Comments, label: 'Comments', numeric: true }, + { + key: CreatorPostSortBy.OutboundVisits, + label: 'Outbound visits', + numeric: true, + }, +]; + +const cellClassName = 'px-2 py-3 align-middle'; + +const sortState = ( + isActive: boolean, + isDescending: boolean, +): 'descending' | 'ascending' | 'none' => { + if (!isActive) { + return 'none'; + } + + return isDescending ? 'descending' : 'ascending'; +}; + +const SortableHeader = ({ + column, + sort, + onSortChange, +}: { + column: Column; + sort: CreatorPostSort; + onSortChange: (sort: CreatorPostSort) => void; +}): ReactElement => { + const isActive = sort.sortBy === column.key; + const isDescending = sort.order === CreatorPostSortOrder.Desc; + + return ( + + + + ); +}; + +const MetricCell = ({ + value, + unknownReason, +}: { + value: number | null; + unknownReason: string; +}): ReactElement => ( + + {value === null ? ( + + + {unknownValueLabel} + + + ) : ( + + {largeNumberFormat(value)} + + )} + +); + +const SkeletonRows = (): ReactElement => ( + <> + {Array.from({ length: 5 }, (_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + + + + ))} + +); + +export const CreatorPostPerformanceTable = ({ + posts, + sort, + onSortChange, + isPending, + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: CreatorPostPerformanceTableProps): ReactElement => ( +
+
+ + + + + + {columns.map((column) => ( + + ))} + + + + {isPending && } + {!isPending && + posts.map((row) => ( + + + + + + + + + ))} + +
+ Your posts and how they performed in the selected period, sortable by + column. +
+ + Post + +
+ + + + + {row.post.title || 'Untitled'} + + + + + + {formatDate({ + value: row.post.createdAt, + type: TimeFormatType.Post, + })} + + + {/* The comment count is the way into the discussion, so it + is the link rather than sitting next to one. */} + + + + {largeNumberFormat(row.comments)} + + + +
+
+ {hasNextPage && ( + + )} +
+); diff --git a/packages/webapp/components/analytics/creator/common.ts b/packages/webapp/components/analytics/creator/common.ts new file mode 100644 index 00000000000..c0cfddcd928 --- /dev/null +++ b/packages/webapp/components/analytics/creator/common.ts @@ -0,0 +1,167 @@ +import type { + CreatorMetric, + CreatorPerformance, + CreatorPerformanceCoverage, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; +import { + CreatorMetricSemantics, + CreatorPerformancePeriod, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; + +export const periodLabel: Record = { + [CreatorPerformancePeriod.Last30Days]: 'Last 30 days', + [CreatorPerformancePeriod.Last90Days]: 'Last 90 days', +}; + +export const periodOptions = [ + CreatorPerformancePeriod.Last30Days, + CreatorPerformancePeriod.Last90Days, +]; + +/** + * What a tile's number is scoped to. + * + * Driven by the metric's own `semantics` rather than by the selected period, + * because outbound visits come back as a lifetime total and captioning it + * "Last 30 days" would be a straightforward lie. + */ +export const metricScopeLabel = ( + metric: CreatorMetric, + period: CreatorPerformancePeriod, +): string => + metric.semantics === CreatorMetricSemantics.Period + ? periodLabel[period] + : 'All time'; + +export type MetricDelta = + /** Nothing honest to say: unknown value, or no comparable prior window. */ + | { kind: 'none' } + /** Prior window was zero, so a percentage would divide by nothing. */ + | { kind: 'new' } + | { kind: 'change'; percentage: number }; + +/** + * A comparison is only drawn when every part of it is real. + * + * The server already withholds `previous` when the prior window is not fully + * covered by retained history, so a null here is a deliberate "do not compare" + * rather than missing data to paper over. + */ +export const getMetricDelta = (metric: CreatorMetric): MetricDelta => { + const { value, previous } = metric; + + if (value === null || previous === null) { + return { kind: 'none' }; + } + + if (previous === 0) { + // "+100%" from a base of zero reads as growth that never happened. + return value > 0 ? { kind: 'new' } : { kind: 'none' }; + } + + return { + kind: 'change', + percentage: Math.round(((value - previous) / previous) * 100), + }; +}; + +const utcDate = (date: string): Date => new Date(`${date}T00:00:00.000Z`); + +const millisecondsInDay = 24 * 60 * 60 * 1000; + +/** + * Coverage dates are UTC calendar days, so they are formatted in UTC. + * + * Passing them through the creator's local timezone would shift the label by a + * day for anyone far enough from UTC, and the footer would then disagree with + * the numbers above it. + */ +export const formatCoverageDate = (date: string): string => + utcDate(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }); + +const formatAxisDate = (date: string): string => + utcDate(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); + +export type ImpressionsChartPoint = { + name: string; + value: number; + isBoosted: boolean; +}; + +/** + * The chart's axis, built from coverage rather than from the returned points. + * + * A measured day with no row saw zero impressions and is padded; a day before + * `coveredStartDate` was never measured at all and is left off entirely. Both + * would otherwise render as the same zero bar, which is the difference between + * "nobody saw it" and "we were not counting yet". + */ +export const buildImpressionsChartData = ( + performance: Pick, +): ImpressionsChartPoint[] => { + const { coverage, impressionsSeries } = performance; + + if (!coverage.coveredStartDate) { + return []; + } + + const byDate = new Map( + impressionsSeries.map((point) => [point.date, point] as const), + ); + const end = utcDate(coverage.endDate).getTime(); + const points: ImpressionsChartPoint[] = []; + + for ( + let cursor = utcDate(coverage.coveredStartDate).getTime(); + cursor <= end; + // Plain millisecond arithmetic keeps this on UTC days; adding calendar + // days would drift for a viewer whose local clock crosses a DST boundary. + cursor += millisecondsInDay + ) { + const date = new Date(cursor).toISOString().slice(0, 10); + const point = byDate.get(date); + const impressionsAds = point?.impressionsAds ?? 0; + + points.push({ + name: formatAxisDate(date), + value: (point?.impressions ?? 0) + impressionsAds, + isBoosted: impressionsAds > 0, + }); + } + + return points; +}; + +/** + * The sentence under the overview explaining how much of the window the + * numbers reach, or `null` when they reach all of it and there is nothing to + * explain. + */ +export const getCoverageNote = ( + coverage: CreatorPerformanceCoverage, +): string | null => { + if (coverage.isComplete) { + return null; + } + + if (!coverage.coveredStartDate) { + return 'Daily impressions history does not reach this period yet, so impressions are not available for it.'; + } + + return `Daily impressions history starts ${formatCoverageDate( + coverage.coveredStartDate, + )}, so impressions cover ${coverage.coveredDays} of the ${ + coverage.requestedDays + } days.`; +}; + +export const unknownValueLabel = '—'; diff --git a/packages/webapp/pages/analytics/index.tsx b/packages/webapp/pages/analytics/index.tsx index 827273e6b4c..635de3adc56 100644 --- a/packages/webapp/pages/analytics/index.tsx +++ b/packages/webapp/pages/analytics/index.tsx @@ -1,8 +1,8 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import type { NextSeoProps } from 'next-seo'; -import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; -import { addDays, subDays } from 'date-fns'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import classNames from 'classnames'; import { ResponsivePageContainer, Divider, @@ -11,228 +11,113 @@ import { import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common'; import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; -import classNames from 'classnames'; import { Typography, TypographyColor, TypographyTag, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; -import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; -import { - AddUserIcon, - ReputationIcon, - UpvoteIcon, - EyeIcon, -} from '@dailydotdev/shared/src/components/icons'; -import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import classed from '@dailydotdev/shared/src/lib/classed'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { - generateQueryKey, - RequestKey, - StaleTime, - getNextPageParam, -} from '@dailydotdev/shared/src/lib/query'; -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { - USER_POSTS_ANALYTICS_QUERY, - USER_POSTS_ANALYTICS_HISTORY_QUERY, - USER_POSTS_WITH_ANALYTICS_QUERY, -} from '@dailydotdev/shared/src/graphql/users'; -import type { - UserPostsAnalytics, - UserPostsAnalyticsHistoryNode, - UserPostWithAnalytics, -} from '@dailydotdev/shared/src/graphql/users'; -import type { Connection } from '@dailydotdev/shared/src/graphql/common'; -import { - dateFormatInTimezone, - DEFAULT_TIMEZONE, -} from '@dailydotdev/shared/src/lib/timezones'; -import dynamic from 'next/dynamic'; + CreatorPerformancePeriod, + CreatorPostSortBy, + CreatorPostSortOrder, + creatorLifetimeTotalsQueryOptions, + creatorPerformanceQueryOptions, + creatorPostPerformanceQueryOptions, +} from '@dailydotdev/shared/src/graphql/creatorAnalytics'; import ProtectedPage from '../../components/ProtectedPage'; import { getLayout } from '../../components/layouts/MainLayout'; -import { UserPostsAnalyticsTable } from '../../components/analytics/UserPostsAnalyticsTable'; import { AnalyticsEmptyState } from '../../components/analytics/AnalyticsEmptyState'; - -const CombinedImpressionsChart = dynamic( - () => - import( - '@dailydotdev/shared/src/components/analytics/CombinedImpressionsChart' - ).then((mod) => mod.CombinedImpressionsChart), - { - loading: () =>
, - }, -); +import { CreatorPeriodSelect } from '../../components/analytics/creator/CreatorPeriodSelect'; +import { + CreatorOverviewSection, + CreatorOverviewSkeleton, +} from '../../components/analytics/creator/CreatorOverviewSection'; +import { + CreatorImpressionsSection, + CreatorImpressionsSkeleton, +} from '../../components/analytics/creator/CreatorImpressionsSection'; +import type { CreatorPostSort } from '../../components/analytics/creator/CreatorPostPerformanceTable'; +import { CreatorPostPerformanceTable } from '../../components/analytics/creator/CreatorPostPerformanceTable'; +import { CreatorAchievementsSection } from '../../components/analytics/creator/CreatorAchievementsSection'; +import { CreatorDashboardError } from '../../components/analytics/creator/CreatorDashboardError'; const dividerClassName = 'bg-border-subtlest-tertiary'; const SectionContainer = classed('div', 'flex flex-col gap-4'); + const SectionHeader = ({ children, }: { children: React.ReactNode; -}): ReactElement => { - return ( - - {children} - - ); -}; - -const POST_ANALYTICS_HISTORY_LIMIT = 45; - -type ImpressionNode = { - name: string; - value: number; - isBoosted: boolean; -}; +}): ReactElement => ( + + {children} + +); const Analytics = (): ReactElement => { const { user } = useAuthContext(); - const userTimezone = user?.timezone || DEFAULT_TIMEZONE; - const { isV2 } = useLayoutVariant(); - const isV2Laptop = isV2; - - const analyticsQueryKey = generateQueryKey( - RequestKey.UserPostsAnalytics, - user, - ); - - const historyQueryKey = generateQueryKey( - RequestKey.UserPostsAnalyticsHistory, - user, - ); - - const postsQueryKey = generateQueryKey( - RequestKey.UserPostsWithAnalytics, - user, - ); - - const { data: analytics } = useQuery({ - queryKey: analyticsQueryKey, - queryFn: async () => { - const result = await gqlClient.request<{ - userPostsAnalytics: UserPostsAnalytics; - }>(USER_POSTS_ANALYTICS_QUERY); - return result.userPostsAnalytics; - }, - staleTime: StaleTime.Default, - enabled: !!user, + const { isV2: isV2Laptop } = useLayoutVariant(); + const [period, setPeriod] = useState(CreatorPerformancePeriod.Last30Days); + const [sort, setSort] = useState({ + sortBy: CreatorPostSortBy.PublishedAt, + order: CreatorPostSortOrder.Desc, }); - const { data: historyData, isLoading: isLoadingHistory } = useQuery({ - queryKey: historyQueryKey, - queryFn: async () => { - const result = await gqlClient.request<{ - userPostsAnalyticsHistory: UserPostsAnalyticsHistoryNode[]; - }>(USER_POSTS_ANALYTICS_HISTORY_QUERY); - return result.userPostsAnalyticsHistory; - }, - staleTime: StaleTime.Default, - enabled: !!user, - select: useCallback( - (data: UserPostsAnalyticsHistoryNode[]): ImpressionNode[] => { - if (!data) { - return []; - } - - const impressionsMap = data.reduce((acc, item) => { - const date = dateFormatInTimezone( - new Date(item.date), - 'yyyy-MM-dd', - userTimezone, - ); - - acc[date] = { - name: new Date(item.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }), - value: item.impressions, - isBoosted: item.impressionsAds > 0, - }; - - return acc; - }, {} as Record); - - const historyCutOffDate = subDays( - new Date(), - POST_ANALYTICS_HISTORY_LIMIT - 1, - ); - - const impressionsData: ImpressionNode[] = []; - - for (let i = 0; i < POST_ANALYTICS_HISTORY_LIMIT; i += 1) { - const paddedDate = addDays(historyCutOffDate, i); - const date = dateFormatInTimezone( - paddedDate, - 'yyyy-MM-dd', - userTimezone, - ); - - if (impressionsMap[date]) { - impressionsData.push(impressionsMap[date]); - } else { - impressionsData.push({ - name: paddedDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }), - value: 0, - isBoosted: false, - }); - } - } - - return impressionsData; - }, - [userTimezone], - ), - }); + const { + data: performance, + isPending: isPerformancePending, + isError: isPerformanceError, + isFetching: isPerformanceFetching, + refetch: refetchPerformance, + } = useQuery(creatorPerformanceQueryOptions({ user, period })); + + // Followers and reputation have no daily grain, so they come from the + // lifetime row rather than the period contract. + const { data: lifetime, isPending: isLifetimePending } = useQuery( + creatorLifetimeTotalsQueryOptions({ user }), + ); const { data: postsData, + isPending: isPostsPending, + isError: isPostsError, + isFetching: isPostsFetching, + refetch: refetchPosts, fetchNextPage, hasNextPage, isFetchingNextPage, - isLoading: isLoadingPosts, - } = useInfiniteQuery({ - queryKey: postsQueryKey, - queryFn: async ({ pageParam }) => { - const result = await gqlClient.request<{ - userPostsWithAnalytics: Connection; - }>(USER_POSTS_WITH_ANALYTICS_QUERY, { - first: 20, - after: pageParam, - }); - return result.userPostsWithAnalytics; - }, - initialPageParam: null as string | null, - getNextPageParam: (lastPage) => getNextPageParam(lastPage?.pageInfo), - staleTime: StaleTime.Default, - enabled: !!user, - }); + } = useInfiniteQuery( + creatorPostPerformanceQueryOptions({ + user, + period, + sortBy: sort.sortBy, + order: sort.order, + }), + ); + + // Both feed the same grid of tiles, so it renders once rather than + // half-filling and then reflowing. + const isOverviewPending = isPerformancePending || isLifetimePending; const posts = useMemo( () => - postsData?.pages.flatMap((page) => page.edges.map((e) => e.node)) ?? [], + postsData?.pages.flatMap((page) => page.edges.map(({ node }) => node)) ?? + [], [postsData], ); - const hasNoPosts = !isLoadingPosts && !!postsData && posts.length === 0; - - const hasChartData = useMemo(() => { - if (!historyData || historyData.length === 0) { - return false; - } - return historyData.some((item) => item.value > 0); - }, [historyData]); + // Only an answered query with nothing in it means "no posts". While a sort + // or period change is in flight the list is briefly empty, and showing the + // "start posting" pitch to a creator who has posts would be absurd. + const hasNoPosts = !isPostsPending && !isPostsError && posts.length === 0; return ( @@ -254,109 +139,74 @@ const Analytics = (): ReactElement => { )} - Overview (last 45 days) -
- - } - /> - - } - /> - - } - /> - - } +
+ Overview +
- - - -
- - Impressions in the last 45 days - - {hasChartData && ( -
-
-
- - Organic - -
-
-
- - Promoted - -
-
- )} -
- {isLoadingHistory &&
} - {!isLoadingHistory && hasChartData && ( - + {isOverviewPending && } + {!isOverviewPending && isPerformanceError && ( + )} - {!isLoadingHistory && !hasChartData && ( -
- - No impression data yet. Check back after your posts get some - views. - -
+ {!!performance && !isOverviewPending && !isPerformanceError && ( + )} + {/* The overview's error state already explains the failure; a + second empty section under it would just be a gap. */} + {!isPerformanceError && ( + <> + + + {isPerformancePending && } + {!!performance && ( + + )} + + + )} - Posts - {hasNoPosts ? ( - - ) : ( - Article performance + {isPostsError && ( + + )} + {!isPostsError && hasNoPosts && } + {!isPostsError && !hasNoPosts && ( + )} + + + Achievements + +