diff --git a/packages/shared/src/components/analytics/AgentViewsChart.tsx b/packages/shared/src/components/analytics/AgentViewsChart.tsx new file mode 100644 index 0000000000..d29e257d4c --- /dev/null +++ b/packages/shared/src/components/analytics/AgentViewsChart.tsx @@ -0,0 +1,179 @@ +import React, { useCallback } from 'react'; +import type { ReactElement } from 'react'; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { useQuery } from '@tanstack/react-query'; +import { largeNumberFormat } from '../../lib'; +import type { Post, PostAnalyticsHistory } from '../../graphql/posts'; +import { postAnalyticsHistoryQuery } from '../../graphql/posts'; +import { canViewPostAnalytics } from '../../lib/user'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { DEFAULT_TIMEZONE } from '../../lib/timezones'; +import type { Connection } from '../../graphql/common'; +import type { PostAnalyticsHistoryDay } from './postAnalyticsHistory'; +import { + buildPostAnalyticsHistoryDays, + historyChartTickProp, +} from './postAnalyticsHistory'; +import type { AgentViewsSeries } from './agentViews'; +import { agentViewsSeries } from './agentViews'; + +export type AgentViewsChartProps = { + post: Pick | undefined; + visibleSeries: AgentViewsSeries[]; + showReads?: boolean; + showCrawls?: boolean; +}; + +type AgentViewsNode = PostAnalyticsHistoryDay>; + +const toAgentViewsNode = (item?: PostAnalyticsHistory) => + agentViewsSeries.reduce( + (acc, { key, botKey }) => { + acc[key] = item?.[key] ?? 0; + + if (botKey) { + acc[botKey] = item?.[botKey] ?? 0; + } + + return acc; + }, + {} as Record, + ); + +export const AgentViewsChart = ({ + post, + visibleSeries, + showReads = true, + showCrawls = true, +}: AgentViewsChartProps): ReactElement => { + const { user } = useAuthContext(); + const userTimezone = user?.timezone || DEFAULT_TIMEZONE; + + const { data: agentViewsHistory } = useQuery({ + ...postAnalyticsHistoryQuery({ id: post?.id }), + enabled: canViewPostAnalytics({ post, user }), + select: useCallback( + (data: Connection): AgentViewsNode[] => + buildPostAnalyticsHistoryDays({ + data, + timezone: userTimezone, + toNode: toAgentViewsNode, + }), + [userTimezone], + ), + }); + + const series = agentViewsSeries.filter(({ key }) => + visibleSeries.includes(key), + ); + + return ( +
+ {!!agentViewsHistory && ( + + + + + largeNumberFormat(value) ?? ''} + tickLine={false} + tick={historyChartTickProp} + /> + { + const node = payload[0]?.payload as AgentViewsNode | undefined; + + return ( +
+
{label}
+ {series.map(({ key, label: seriesLabel, botKey }) => { + const parts = [ + showReads && + `${largeNumberFormat(node?.[key] ?? 0)} reads`, + showCrawls && + !!botKey && + `${largeNumberFormat(node?.[botKey] ?? 0)} crawls`, + ].filter(Boolean); + + if (!parts.length) { + return null; + } + + return ( +
+ {seriesLabel}: {parts.join(' · ')} +
+ ); + })} +
+ ); + }} + /> + {showReads && + series.map(({ key, color }) => ( + + ))} + {showCrawls && + series + .filter(({ botKey }) => !!botKey) + .map(({ botKey, color }) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/analytics/ImpressionsChart.tsx b/packages/shared/src/components/analytics/ImpressionsChart.tsx index 5102804379..cc12bae573 100644 --- a/packages/shared/src/components/analytics/ImpressionsChart.tsx +++ b/packages/shared/src/components/analytics/ImpressionsChart.tsx @@ -4,43 +4,45 @@ import { Bar, BarChart, CartesianGrid, - Cell, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; import { useQuery } from '@tanstack/react-query'; -import { addDays, subDays } from 'date-fns'; -import type { TickProp } from 'recharts/types/util/types'; import { largeNumberFormat } from '../../lib'; import type { Post, PostAnalyticsHistory } from '../../graphql/posts'; -import { - postAnalyticsHistoryLimit, - postAnalyticsHistoryQuery, -} from '../../graphql/posts'; +import { postAnalyticsHistoryQuery } from '../../graphql/posts'; import { canViewPostAnalytics } from '../../lib/user'; import { useAuthContext } from '../../contexts/AuthContext'; -import { dateFormatInTimezone, DEFAULT_TIMEZONE } from '../../lib/timezones'; +import { DEFAULT_TIMEZONE } from '../../lib/timezones'; import type { Connection } from '../../graphql/common'; +import type { PostAnalyticsHistoryDay } from './postAnalyticsHistory'; +import { + buildPostAnalyticsHistoryDays, + historyChartTickProp, +} from './postAnalyticsHistory'; export type ImpressionsChartProps = { post: Pick | undefined; + showOrganic?: boolean; + showPromoted?: boolean; }; -type ImpressionNode = { - name: string; - value: number; - isBoosted: boolean; -}; +type ImpressionNode = PostAnalyticsHistoryDay<{ + organic: number; + promoted: number; +}>; -const tickProp: TickProp = { - fill: 'var(--theme-text-tertiary)', - fontSize: '0.6875rem', -}; +const toImpressionNode = (item?: PostAnalyticsHistory) => ({ + organic: Math.max((item?.impressions ?? 0) - (item?.impressionsAds ?? 0), 0), + promoted: item?.impressionsAds ?? 0, +}); export const ImpressionsChart = ({ post, + showOrganic = true, + showPromoted = true, }: ImpressionsChartProps): ReactElement => { const { user } = useAuthContext(); const userTimezone = user?.timezone || DEFAULT_TIMEZONE; @@ -49,65 +51,12 @@ export const ImpressionsChart = ({ ...postAnalyticsHistoryQuery({ id: post?.id }), enabled: canViewPostAnalytics({ post, user }), select: useCallback( - (data: Connection): ImpressionNode[] => { - if (!data) { - return []; - } - - const impressionsMap = data?.edges?.reduce( - (acc, { node: 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, - // if any boost was active on this date - isBoosted: item.impressionsAds > 0, - }; - - return acc; - }, - {} as Record, - ); - - const historyCutOffDate = subDays( - new Date(), - postAnalyticsHistoryLimit - 1, - ); - - const impressionsData: ImpressionNode[] = []; - - for (let i = 0; i < postAnalyticsHistoryLimit; 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; - }, + (data: Connection): ImpressionNode[] => + buildPostAnalyticsHistoryDays({ + data, + timezone: userTimezone, + toNode: toImpressionNode, + }), [userTimezone], ), }); @@ -140,7 +89,7 @@ export const ImpressionsChart = ({ stroke: 'transparent', }} tickLine={false} - tick={tickProp} + tick={historyChartTickProp} /> largeNumberFormat(value) ?? ''} tickLine={false} - tick={tickProp} + tick={historyChartTickProp} interval={1} /> ( -
- {largeNumberFormat(payload[0]?.value || 0)}{' '} - impressions on {label} -
- )} - /> - - {postAnalyticsHistory.map((entry) => { + content={({ payload, label }) => { + const node = payload[0]?.payload as ImpressionNode | undefined; + const organic = showOrganic ? (node?.organic ?? 0) : 0; + const promoted = showPromoted ? (node?.promoted ?? 0) : 0; + return ( - +
+ {largeNumberFormat(organic + promoted)}{' '} + impressions on {label} + {!!promoted && !!organic && ( +
+ {largeNumberFormat(promoted)} promoted +
+ )} +
); - })} -
+ }} + /> + {showOrganic && ( + + )} + {showPromoted && ( + + )} )} diff --git a/packages/shared/src/components/analytics/agentViews.ts b/packages/shared/src/components/analytics/agentViews.ts new file mode 100644 index 0000000000..a88206c07f --- /dev/null +++ b/packages/shared/src/components/analytics/agentViews.ts @@ -0,0 +1,78 @@ +import type { PostAnalytics } from '../../graphql/posts'; + +export enum AgentViewsSeries { + Total = 'agentViews', + Claude = 'agentViewsClaude', + Chatgpt = 'agentViewsChatgpt', + Gemini = 'agentViewsGemini', + Perplexity = 'agentViewsPerplexity', + Exa = 'agentViewsExa', + Meta = 'agentViewsMeta', +} + +type AgentViewsBotKey = Extract; + +export type AgentViewsSeriesConfig = { + key: AgentViewsSeries; + label: string; + color: string; + dotClassName: string; + botKey?: AgentViewsBotKey; +}; + +export const agentViewsTotalSeries: AgentViewsSeriesConfig = { + key: AgentViewsSeries.Total, + label: 'AI agents', + color: 'var(--theme-accent-avocado-default)', + dotClassName: 'bg-accent-avocado-default', + botKey: 'agentViewsBot', +}; + +export const agentViewsVendorSeries: AgentViewsSeriesConfig[] = [ + { + key: AgentViewsSeries.Claude, + label: 'Claude', + color: 'var(--theme-accent-bacon-default)', + dotClassName: 'bg-accent-bacon-default', + botKey: 'agentViewsClaudeBot', + }, + { + key: AgentViewsSeries.Chatgpt, + label: 'ChatGPT', + color: 'var(--theme-accent-lettuce-default)', + dotClassName: 'bg-accent-lettuce-default', + botKey: 'agentViewsChatgptBot', + }, + { + key: AgentViewsSeries.Gemini, + label: 'Gemini', + color: 'var(--theme-accent-water-default)', + dotClassName: 'bg-accent-water-default', + }, + { + key: AgentViewsSeries.Perplexity, + label: 'Perplexity', + color: 'var(--theme-accent-onion-default)', + dotClassName: 'bg-accent-onion-default', + botKey: 'agentViewsPerplexityBot', + }, + { + key: AgentViewsSeries.Exa, + label: 'Exa', + color: 'var(--theme-accent-cheese-default)', + dotClassName: 'bg-accent-cheese-default', + botKey: 'agentViewsExaBot', + }, + { + key: AgentViewsSeries.Meta, + label: 'Meta AI', + color: 'var(--theme-accent-bun-default)', + dotClassName: 'bg-accent-bun-default', + botKey: 'agentViewsMetaBot', + }, +]; + +export const agentViewsSeries: AgentViewsSeriesConfig[] = [ + agentViewsTotalSeries, + ...agentViewsVendorSeries, +]; diff --git a/packages/shared/src/components/analytics/postAnalyticsHistory.ts b/packages/shared/src/components/analytics/postAnalyticsHistory.ts new file mode 100644 index 0000000000..0d1a3564ff --- /dev/null +++ b/packages/shared/src/components/analytics/postAnalyticsHistory.ts @@ -0,0 +1,71 @@ +import { addDays, subDays } from 'date-fns'; +import type { TickProp } from 'recharts/types/util/types'; +import type { PostAnalyticsHistory } from '../../graphql/posts'; +import { postAnalyticsHistoryLimit } from '../../graphql/posts'; +import type { Connection } from '../../graphql/common'; +import { dateFormatInTimezone } from '../../lib/timezones'; + +export type PostAnalyticsHistoryDay = T & { + name: string; +}; + +export const historyChartTickProp: TickProp = { + fill: 'var(--theme-text-tertiary)', + fontSize: '0.6875rem', +}; + +const formatDayName = (date: Date): string => + date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + +export const buildPostAnalyticsHistoryDays = ({ + data, + timezone, + toNode, +}: { + data: Connection | undefined; + timezone: string; + toNode: (item?: PostAnalyticsHistory) => T; +}): PostAnalyticsHistoryDay[] => { + if (!data) { + return []; + } + + const daysMap = data.edges?.reduce( + (acc, { node: item }) => { + const date = dateFormatInTimezone( + new Date(item.date), + 'yyyy-MM-dd', + timezone, + ); + + acc[date] = { + ...toNode(item), + name: formatDayName(new Date(item.date)), + }; + + return acc; + }, + {} as Record>, + ); + + const historyCutOffDate = subDays(new Date(), postAnalyticsHistoryLimit - 1); + + const days: PostAnalyticsHistoryDay[] = []; + + for (let i = 0; i < postAnalyticsHistoryLimit; i += 1) { + const paddedDate = addDays(historyCutOffDate, i); + const date = dateFormatInTimezone(paddedDate, 'yyyy-MM-dd', timezone); + + days.push( + daysMap[date] ?? { + ...toNode(), + name: formatDayName(paddedDate), + }, + ); + } + + return days; +}; diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 444aa7b174..63d0f662fc 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -1568,6 +1568,19 @@ export const POST_ANALYTICS_QUERY = gql` reachAds impressionsAds clicks + agentViews + agentViewsClaude + agentViewsChatgpt + agentViewsGemini + agentViewsPerplexity + agentViewsExa + agentViewsMeta + agentViewsBot + agentViewsClaudeBot + agentViewsChatgptBot + agentViewsPerplexityBot + agentViewsExaBot + agentViewsMetaBot } } `; @@ -1590,6 +1603,19 @@ export type PostAnalytics = { reachAds: number; impressionsAds: number; clicks: number; + agentViews: number; + agentViewsClaude: number; + agentViewsChatgpt: number; + agentViewsGemini: number; + agentViewsPerplexity: number; + agentViewsExa: number; + agentViewsMeta: number; + agentViewsBot: number; + agentViewsClaudeBot: number; + agentViewsChatgptBot: number; + agentViewsPerplexityBot: number; + agentViewsExaBot: number; + agentViewsMetaBot: number; }; export const postAnalyticsQueryOptions = ({ id }: { id?: string }) => { @@ -1621,6 +1647,19 @@ export const POST_ANALYTICS_HISTORY_QUERY = gql` date impressions impressionsAds + agentViews + agentViewsClaude + agentViewsChatgpt + agentViewsGemini + agentViewsPerplexity + agentViewsExa + agentViewsMeta + agentViewsBot + agentViewsClaudeBot + agentViewsChatgptBot + agentViewsPerplexityBot + agentViewsExaBot + agentViewsMetaBot } } } @@ -1629,7 +1668,22 @@ export const POST_ANALYTICS_HISTORY_QUERY = gql` export type PostAnalyticsHistory = Pick< PostAnalytics, - 'id' | 'impressions' | 'impressionsAds' + | 'id' + | 'impressions' + | 'impressionsAds' + | 'agentViews' + | 'agentViewsClaude' + | 'agentViewsChatgpt' + | 'agentViewsGemini' + | 'agentViewsPerplexity' + | 'agentViewsExa' + | 'agentViewsMeta' + | 'agentViewsBot' + | 'agentViewsClaudeBot' + | 'agentViewsChatgptBot' + | 'agentViewsPerplexityBot' + | 'agentViewsExaBot' + | 'agentViewsMetaBot' > & { date: Date; }; diff --git a/packages/webapp/pages/posts/[id]/analytics/index.tsx b/packages/webapp/pages/posts/[id]/analytics/index.tsx index 016037385e..c22fb31ac7 100644 --- a/packages/webapp/pages/posts/[id]/analytics/index.tsx +++ b/packages/webapp/pages/posts/[id]/analytics/index.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; import { useIsPhone } from '@dailydotdev/shared/src/hooks/useViewSize'; import type { GetServerSideProps } from 'next'; @@ -95,6 +95,12 @@ import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/type import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; import { useCampaignEstimation } from '@dailydotdev/shared/src/features/boost/useCampaignEstimation'; import { useCampaigns } from '@dailydotdev/shared/src/features/boost/useCampaigns'; +import { + AgentViewsSeries, + agentViewsTotalSeries, + agentViewsVendorSeries, +} from '@dailydotdev/shared/src/components/analytics/agentViews'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; import { getSeoDescription } from '../../../../components/PostSEOSchema'; import type { Props } from '../index'; import { seoTitle } from '../index'; @@ -115,6 +121,16 @@ const ImpressionsChart = dynamic( }, ); +const AgentViewsChart = dynamic( + () => + import('../../../../../shared/src/components/analytics/AgentViewsChart').then( + (mod) => mod.AgentViewsChart, + ), + { + loading: () =>
, + }, +); + interface SectionHeaderProps { children: React.ReactNode; } @@ -197,6 +213,54 @@ const SectionHeader = ({ children }: SectionHeaderProps): ReactElement => { ); }; +const AgentDot = ({ + colorClassName, +}: { + colorClassName: string; +}): ReactElement => ( + +); + +const LegendToggle = ({ + label, + dotClassName, + shapeClassName = 'size-2 rounded-full', + isActive, + isLocked = false, + onToggle, +}: { + label: string; + dotClassName: string; + shapeClassName?: string; + isActive: boolean; + isLocked?: boolean; + onToggle: () => void; +}): ReactElement => ( + +); + const PostAnalyticsPage = ({ id, initialData, @@ -230,6 +294,48 @@ const PostAnalyticsPage = ({ }); const campaign = campaignQuery.data?.pages[0]?.edges[0]?.node; + const [showOrganic, setShowOrganic] = useState(true); + const [showPromoted, setShowPromoted] = useState(true); + const [showAgentReads, setShowAgentReads] = useState(true); + const [showAgentCrawls, setShowAgentCrawls] = useState(true); + const [visibleAgentSeries, setVisibleAgentSeries] = useState< + AgentViewsSeries[] + >([AgentViewsSeries.Total]); + const [showAgentBreakdown, setShowAgentBreakdown] = useState(false); + + const toggleAgentSeries = (key: AgentViewsSeries) => { + if (key === AgentViewsSeries.Total) { + setVisibleAgentSeries([AgentViewsSeries.Total]); + + return; + } + + setVisibleAgentSeries((current) => { + const vendors = current.filter((item) => item !== AgentViewsSeries.Total); + const next = vendors.includes(key) + ? vendors.filter((item) => item !== key) + : [...vendors, key]; + + return next.length ? next : [AgentViewsSeries.Total]; + }); + }; + + const agentViewsList: AnalyticsNumberList = agentViewsVendorSeries.map( + ({ key, label, dotClassName, botKey }) => { + const reads = largeNumberFormat(postAnalytics?.[key] ?? 0); + + return { + icon: , + label, + value: botKey + ? `${reads} reads · ${largeNumberFormat( + postAnalytics?.[botKey] ?? 0, + )} crawls` + : `${reads} reads`, + }; + }, + ); + const canBoost = useShowBoostButton({ post }); const isBoosting = !!post?.flags?.campaignId; @@ -525,22 +631,107 @@ const PostAnalyticsPage = ({ } />
-
+
Impressions in the last 45 days -
-
-
{' '} - Organic -
-
-
{' '} - Promoted -
+
+ setShowOrganic((current) => !current)} + /> + setShowPromoted((current) => !current)} + />
- + + + + + AI agents +
+ + +
+
+ + AI agents in the last 45 days + +
+ setShowAgentReads((current) => !current)} + /> + setShowAgentCrawls((current) => !current)} + /> +
+
+
+ {[agentViewsTotalSeries, ...agentViewsVendorSeries].map( + (series) => ( + toggleAgentSeries(series.key)} + /> + ), + )} +
+ + setShowAgentBreakdown((current) => !current)} + > + {showAgentBreakdown ? 'Hide' : 'Show'} per agent + + {showAgentBreakdown && }
{canBoost && !campaign && (