Skip to content
Open
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
179 changes: 179 additions & 0 deletions packages/shared/src/components/analytics/AgentViewsChart.tsx
Original file line number Diff line number Diff line change
@@ -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<Post, 'id' | 'author'> | undefined;
visibleSeries: AgentViewsSeries[];
showReads?: boolean;
showCrawls?: boolean;
};

type AgentViewsNode = PostAnalyticsHistoryDay<Record<string, number>>;

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<string, number>,
);

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<PostAnalyticsHistory>): AgentViewsNode[] =>
buildPostAnalyticsHistoryDays({
data,
timezone: userTimezone,
toNode: toAgentViewsNode,
}),
[userTimezone],
),
});

const series = agentViewsSeries.filter(({ key }) =>
visibleSeries.includes(key),
);

return (
<div className="h-40 w-full">
{!!agentViewsHistory && (
<ResponsiveContainer>
<LineChart
width={500}
height={400}
data={agentViewsHistory}
margin={{
top: 10,
right: 0,
left: -20,
bottom: 0,
}}
>
<CartesianGrid
strokeDasharray="0"
stroke="#A8B3CE"
strokeOpacity={0.2}
vertical={false}
/>
<XAxis
dataKey="name"
axisLine={{
stroke: 'transparent',
}}
tickLine={false}
tick={historyChartTickProp}
/>
<YAxis
allowDecimals={false}
axisLine={{
stroke: 'transparent',
}}
tickFormatter={(value) => largeNumberFormat(value) ?? ''}
tickLine={false}
tick={historyChartTickProp}
/>
<Tooltip
cursor={false}
content={({ payload, label }) => {
const node = payload[0]?.payload as AgentViewsNode | undefined;

return (
<div className="TooltipContent z-tooltip max-w-full rounded-10 bg-text-primary px-3 py-1 text-surface-invert typo-subhead">
<div>{label}</div>
{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 (
<div key={key}>
{seriesLabel}: {parts.join(' · ')}
</div>
);
})}
</div>
);
}}
/>
{showReads &&
series.map(({ key, color }) => (
<Line
key={key}
dataKey={key}
type="monotone"
stroke={color}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
))}
{showCrawls &&
series
.filter(({ botKey }) => !!botKey)
Comment thread
capJavert marked this conversation as resolved.
Resolved
.map(({ botKey, color }) => (
<Line
key={botKey}
dataKey={botKey}
type="monotone"
stroke={color}
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
activeDot={{ r: 3 }}
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</div>
);
};
162 changes: 61 additions & 101 deletions packages/shared/src/components/analytics/ImpressionsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Post, 'id' | 'author'> | 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;
Expand All @@ -49,65 +51,12 @@ export const ImpressionsChart = ({
...postAnalyticsHistoryQuery({ id: post?.id }),
enabled: canViewPostAnalytics({ post, user }),
select: useCallback(
(data: Connection<PostAnalyticsHistory>): 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<string, ImpressionNode>,
);

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<PostAnalyticsHistory>): ImpressionNode[] =>
buildPostAnalyticsHistoryDays({
data,
timezone: userTimezone,
toNode: toImpressionNode,
}),
[userTimezone],
),
});
Expand Down Expand Up @@ -140,44 +89,55 @@ export const ImpressionsChart = ({
stroke: 'transparent',
}}
tickLine={false}
tick={tickProp}
tick={historyChartTickProp}
/>
<YAxis
axisLine={{
stroke: 'transparent',
}}
tickFormatter={(value) => largeNumberFormat(value) ?? ''}
tickLine={false}
tick={tickProp}
tick={historyChartTickProp}
interval={1}
/>
<Tooltip
cursor={false}
content={({ payload, label }) => (
<div className="TooltipContent z-tooltip max-w-full rounded-10 bg-text-primary px-3 py-1 text-surface-invert typo-subhead">
<strong>{largeNumberFormat(payload[0]?.value || 0)}</strong>{' '}
impressions on {label}
</div>
)}
/>
<Bar
dataKey="value"
radius={10}
activeBar={{ fill: 'var(--theme-text-primary)' }}
>
{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 (
<Cell
key={`cell-${entry.name}`}
fill={
!entry.isBoosted
? 'var(--theme-brand-default)'
: 'var(--theme-accent-blueCheese-default)'
}
/>
<div className="TooltipContent z-tooltip max-w-full rounded-10 bg-text-primary px-3 py-1 text-surface-invert typo-subhead">
<strong>{largeNumberFormat(organic + promoted)}</strong>{' '}
impressions on {label}
{!!promoted && !!organic && (
<div>
<strong>{largeNumberFormat(promoted)}</strong> promoted
</div>
)}
</div>
);
})}
</Bar>
}}
/>
{showOrganic && (
<Bar
dataKey="organic"
stackId="impressions"
radius={10}
fill="var(--theme-brand-default)"
activeBar={{ fill: 'var(--theme-text-primary)' }}
/>
)}
{showPromoted && (
<Bar
dataKey="promoted"
stackId="impressions"
radius={10}
Comment thread
capJavert marked this conversation as resolved.
Resolved
fill="var(--theme-accent-blueCheese-default)"
activeBar={{ fill: 'var(--theme-text-primary)' }}
/>
)}
</BarChart>
</ResponsiveContainer>
)}
Expand Down
Loading
Loading