diff --git a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx index f9dca357e7..183c870437 100644 --- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { ReactElement } from 'react'; import { ProfileSection } from '../ProfileSection'; -import { CoinIcon, DevCardIcon, UserIcon } from '../../icons'; +import { AnalyticsIcon, CoinIcon, DevCardIcon, UserIcon } from '../../icons'; import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; @@ -29,6 +29,11 @@ export const MainSection = (): ReactElement => { href: `${settingsUrl}/customization/devcard`, icon: DevCardIcon, }, + { + title: 'Analytics', + href: `${webappUrl}analytics`, + icon: AnalyticsIcon, + }, ].filter(Boolean)} /> ); diff --git a/packages/shared/src/components/analytics/CombinedImpressionsChart.tsx b/packages/shared/src/components/analytics/CombinedImpressionsChart.tsx new file mode 100644 index 0000000000..8ed1ee7279 --- /dev/null +++ b/packages/shared/src/components/analytics/CombinedImpressionsChart.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { TickProp } from 'recharts/types/util/types'; +import { largeNumberFormat } from '../../lib'; + +type ImpressionNode = { + name: string; + value: number; + isBoosted: boolean; +}; + +export interface CombinedImpressionsChartProps { + data: ImpressionNode[] | undefined; +} + +const tickProp: TickProp = { + fill: 'var(--theme-text-tertiary)', + fontSize: '0.6875rem', +}; + +export const CombinedImpressionsChart = ({ + data, +}: CombinedImpressionsChartProps): ReactElement => { + return ( +
+ {!!data && ( + + + + + largeNumberFormat(value)} + tickLine={false} + tick={tickProp} + interval={1} + /> + ( +
+ {largeNumberFormat(payload?.[0]?.value || 0)}{' '} + impressions on {label} +
+ )} + /> + + {data.map((entry) => { + return ( + + ); + })} + +
+
+ )} +
+ ); +}; diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index a846ebabd2..5e4ae1dac6 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -593,6 +593,41 @@ export interface UserProfileAnalyticsHistory { updatedAt: Date; } +export interface UserPostsAnalytics { + id: string; + impressions: number; + reach: number; + upvotes: number; + comments: number; + bookmarks: number; + awards: number; + profileViews: number; + followers: number; + reputation: number; + coresEarned: number; + shares: number; + clicks: number; + upvotesRatio: number; +} + +export interface UserPostsAnalyticsHistoryNode { + date: string; + impressions: number; + impressionsAds: number; +} + +export interface UserPostWithAnalytics { + id: string; + title: string | null; + image: string | null; + createdAt: string; + impressions: number; + upvotes: number; + reputation: number; + isBoosted: boolean; + commentsPermalink: string; +} + export const getReadingStreak = async (): Promise => { const res = await gqlClient.request(USER_STREAK_QUERY); @@ -892,3 +927,59 @@ export const updateNotificationSettings = async ( notificationFlags, }); }; + +export const USER_POSTS_ANALYTICS_QUERY = gql` + query UserPostsAnalytics { + userPostsAnalytics { + id + impressions + reach + upvotes + comments + bookmarks + awards + profileViews + followers + reputation + coresEarned + shares + clicks + upvotesRatio + } + } +`; + +export const USER_POSTS_ANALYTICS_HISTORY_QUERY = gql` + query UserPostsAnalyticsHistory { + userPostsAnalyticsHistory { + date + impressions + impressionsAds + } + } +`; + +export const USER_POSTS_WITH_ANALYTICS_QUERY = gql` + query UserPostsWithAnalytics($after: String, $first: Int) { + userPostsWithAnalytics(after: $after, first: $first) { + edges { + cursor + node { + id + title + image + createdAt + impressions + upvotes + reputation + isBoosted + commentsPermalink + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index bf95982f7c..f4e40979d5 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -214,6 +214,9 @@ export enum RequestKey { PostAnalyticsHistory = 'post_analytics_history', ProfileAnalytics = 'profile_analytics', ProfileAnalyticsHistory = 'profile_analytics_history', + UserPostsAnalytics = 'user_posts_analytics', + UserPostsAnalyticsHistory = 'user_posts_analytics_history', + UserPostsWithAnalytics = 'user_posts_with_analytics', CheckLocation = 'check_location', GenerateBrief = 'generate_brief', Opportunity = 'opportunity', diff --git a/packages/webapp/components/analytics/AnalyticsEmptyState.tsx b/packages/webapp/components/analytics/AnalyticsEmptyState.tsx new file mode 100644 index 0000000000..9549f88fc1 --- /dev/null +++ b/packages/webapp/components/analytics/AnalyticsEmptyState.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; +import { link } from '@dailydotdev/shared/src/lib/links'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; + +export const AnalyticsEmptyState = (): ReactElement => { + return ( +
+
+ + It's never too late to start posting + + + Hardest part of being a developer? Where do we start... it's + everything. Go on, share with us your best rant. + +
+ + + +
+ ); +}; diff --git a/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx b/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx new file mode 100644 index 0000000000..2c2d7979d5 --- /dev/null +++ b/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx @@ -0,0 +1,191 @@ +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 => { + if (isLoading) { + return ( +
+ + Loading posts... + +
+ ); + } + + return ( +
+
+ + + + + + + + + + + + {posts.map((post) => ( + + + + + + + + ))} + +
+ + Post + + + + Date + + + + Reputation + + + + Impressions + + + + Upvotes + +
+ +
+ +
+
+ + {post.title || 'Untitled'} + + {post.isBoosted && ( + + )} +
+
+
+ +
+ + {formatDate({ + value: post.createdAt, + type: TimeFormatType.Post, + })} + + + + {largeNumberFormat(post.reputation)} + + + + {largeNumberFormat(post.impressions)} + + + + {largeNumberFormat(post.upvotes)} + +
+
+ {hasNextPage && ( + + )} +
+ ); +}; diff --git a/packages/webapp/pages/analytics/index.tsx b/packages/webapp/pages/analytics/index.tsx new file mode 100644 index 0000000000..23aaa6fef0 --- /dev/null +++ b/packages/webapp/pages/analytics/index.tsx @@ -0,0 +1,362 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import type { NextSeoProps } from 'next-seo'; +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { addDays, subDays } from 'date-fns'; +import { + ResponsivePageContainer, + Divider, + pageBorders, +} from '@dailydotdev/shared/src/components/utilities'; +import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common'; +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'; +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: () =>
, + }, +); + +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; +}; + +const Analytics = (): ReactElement => { + const { user } = useAuthContext(); + const userTimezone = user?.timezone || DEFAULT_TIMEZONE; + + 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 { data: historyData } = 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: postsData, + 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, + }); + + const posts = useMemo( + () => + postsData?.pages.flatMap((page) => page.edges.map((e) => e.node)) ?? [], + [postsData], + ); + + const hasNoPosts = !isLoadingPosts && posts.length === 0; + + const hasChartData = useMemo(() => { + if (!historyData || historyData.length === 0) { + return false; + } + return historyData.some((item) => item.value > 0); + }, [historyData]); + + return ( + +
+ + + Analytics + + + + + Overview +
+ + } + /> + + } + /> + + } + /> + + } + /> +
+
+ + +
+ + Impressions in the last 45 days + + {hasChartData && ( +
+
+
+ + Organic + +
+
+
+ + Promoted + +
+
+ )} +
+ {hasChartData ? ( + + ) : ( +
+ + No impression data yet. Check back after your posts get some + views. + +
+ )} + + + + Posts + {hasNoPosts ? ( + + ) : ( + + )} + + +
+ + ); +}; + +const seo: NextSeoProps = { title: 'Analytics', nofollow: true, noindex: true }; + +Analytics.getLayout = getLayout; +Analytics.layoutProps = { seo }; + +export default Analytics; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6ae2a760..59d14e2491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15885,11 +15885,7 @@ snapshots: pretty-format: 26.6.2 throat: 5.0.0 transitivePeerDependencies: - - bufferutil - - canvas - supports-color - - ts-node - - utf-8-validate jest-junit@12.3.0: dependencies: