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
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
startOfWeek,
startOfMonth,
isAfter,
parseISO,
isEqual,
} from 'date-fns';
import { ActivityContainer } from '../../../../components/profile/ActivitySection';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '../../../../components/typography/Typography';
import { gqlClient } from '../../../../graphql/common';
import type {
UserProfileAnalytics,
UserProfileAnalyticsHistory,
} from '../../../../graphql/users';
import {
USER_PROFILE_ANALYTICS_HISTORY_QUERY,
USER_PROFILE_ANALYTICS_QUERY,
} from '../../../../graphql/users';
import { generateQueryKey, RequestKey } from '../../../../lib/query';
import { largeNumberFormat } from '../../../../lib';
import { SummaryCard } from './BadgesAndAwardsComponents';
import { ElementPlaceholder } from '../../../../components/ElementPlaceholder';

interface ProfileViewsWidgetProps {
userId: string;
}

interface HistoryQueryResult {
userProfileAnalyticsHistory: {
edges: Array<{
node: UserProfileAnalyticsHistory;
}>;
};
}

interface AnalyticsQueryResult {
userProfileAnalytics: UserProfileAnalytics | null;
}

const ProfileViewsWidgetSkeleton = (): ReactElement => {
return (
<ActivityContainer data-testid="ProfileViewsWidgetSkeleton">
<Typography
tag={TypographyTag.H2}
type={TypographyType.Callout}
color={TypographyColor.Primary}
bold
className="flex items-center"
>
Profile Activity
</Typography>
<div className="my-3 flex flex-col gap-2">
<div className="flex gap-2">
<ElementPlaceholder className="flex flex-1 flex-col items-center rounded-10 p-2">
<ElementPlaceholder className="h-6 w-8 rounded-4" />
<ElementPlaceholder className="mt-1 h-4 w-16 rounded-4" />
</ElementPlaceholder>
<ElementPlaceholder className="flex flex-1 flex-col items-center rounded-10 p-2">
<ElementPlaceholder className="h-6 w-8 rounded-4" />
<ElementPlaceholder className="mt-1 h-4 w-16 rounded-4" />
</ElementPlaceholder>
</div>
<ElementPlaceholder className="flex flex-col items-center rounded-10 p-2">
<ElementPlaceholder className="h-6 w-8 rounded-4" />
<ElementPlaceholder className="mt-1 h-4 w-16 rounded-4" />
</ElementPlaceholder>
</div>
</ActivityContainer>
);
};

export const ProfileViewsWidget = ({
userId,
}: ProfileViewsWidgetProps): ReactElement => {
const { data: historyData, isPending: isHistoryPending } =
useQuery<HistoryQueryResult>({
queryKey: generateQueryKey(RequestKey.ProfileAnalyticsHistory, {
id: userId,
}),
queryFn: () =>
gqlClient.request(USER_PROFILE_ANALYTICS_HISTORY_QUERY, {
userId,
first: 31,
}),
enabled: !!userId,
refetchOnWindowFocus: false,
});

const { data: analyticsData, isPending: isAnalyticsPending } =
useQuery<AnalyticsQueryResult>({
queryKey: generateQueryKey(RequestKey.ProfileAnalytics, { id: userId }),
queryFn: () =>
gqlClient.request(USER_PROFILE_ANALYTICS_QUERY, {
userId,
}),
enabled: !!userId,
refetchOnWindowFocus: false,
});

const { thisWeek, thisMonth } = useMemo(() => {
if (!historyData?.userProfileAnalyticsHistory?.edges) {
return { thisWeek: 0, thisMonth: 0 };
}

const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const monthStart = startOfMonth(now);

let weekTotal = 0;
let monthTotal = 0;

historyData.userProfileAnalyticsHistory.edges.forEach(({ node }) => {
const entryDate = parseISO(node.date);

if (isAfter(entryDate, monthStart) || isEqual(entryDate, monthStart)) {
monthTotal += node.uniqueVisitors;
}

if (isAfter(entryDate, weekStart) || isEqual(entryDate, weekStart)) {
weekTotal += node.uniqueVisitors;
}
});

return { thisWeek: weekTotal, thisMonth: monthTotal };
}, [historyData]);

const total = analyticsData?.userProfileAnalytics?.uniqueVisitors ?? 0;

if (isHistoryPending || isAnalyticsPending) {
return <ProfileViewsWidgetSkeleton />;
}

return (
<ActivityContainer>
<Typography
tag={TypographyTag.H2}
type={TypographyType.Callout}
color={TypographyColor.Primary}
bold
className="flex items-center"
>
Profile Activity
</Typography>
<div className="my-3 flex flex-col gap-2">
<div className="flex gap-2">
<SummaryCard
count={largeNumberFormat(thisWeek)}
label="Views this week"
/>
<SummaryCard
count={largeNumberFormat(thisMonth)}
label="Views this month"
/>
</div>
<SummaryCard
count={largeNumberFormat(total)}
label="Total profile views"
/>
</div>
</ActivityContainer>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import type { ProfileReadingData, ProfileV2 } from '../../../../graphql/users';
import { USER_READING_HISTORY_QUERY } from '../../../../graphql/users';
import { generateQueryKey, RequestKey } from '../../../../lib/query';
import { gqlClient } from '../../../../graphql/common';
import { canViewUserProfileAnalytics } from '../../../../lib/user';
import { ReadingOverview } from './ReadingOverview';
import { ProfileCompletion } from './ProfileCompletion';
import { Share } from './Share';
import { ProfileViewsWidget } from './ProfileViewsWidget';

const BadgesAndAwards = dynamic(() =>
import('./BadgesAndAwards').then((mod) => mod.BadgesAndAwards),
Expand Down Expand Up @@ -63,6 +65,10 @@ export function ProfileWidgets({
{isSameUser && (
<Share permalink={user?.permalink} className="hidden laptop:flex" />
)}
{canViewUserProfileAnalytics({
user: loggedUser,
profileUserId: user.id,
}) && <ProfileViewsWidget userId={user.id} />}
<ReadingOverview
readHistory={readingHistory?.userReadHistory}
before={before}
Expand Down
37 changes: 37 additions & 0 deletions packages/shared/src/graphql/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,12 +580,49 @@ export interface UserStreak {
lastViewAt: Date;
}

export interface UserProfileAnalytics {
id: string;
uniqueVisitors: number;
updatedAt: Date;
}

export interface UserProfileAnalyticsHistory {
id: string;
date: string;
uniqueVisitors: number;
updatedAt: Date;
}

export const getReadingStreak = async (): Promise<UserStreak> => {
const res = await gqlClient.request(USER_STREAK_QUERY);

return res.userStreak;
};

export const USER_PROFILE_ANALYTICS_QUERY = gql`
query UserProfileAnalytics($userId: ID!) {
userProfileAnalytics(userId: $userId) {
id
uniqueVisitors
updatedAt
}
}
`;

export const USER_PROFILE_ANALYTICS_HISTORY_QUERY = gql`
query UserProfileAnalyticsHistory($userId: ID!, $first: Int) {
userProfileAnalyticsHistory(userId: $userId, first: $first) {
edges {
node {
id
date
uniqueVisitors
}
}
}
}
`;

export interface UserStreakRecoverData {
canRecover: boolean;
cost: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/lib/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ export enum RequestKey {
NotificationSettings = 'notification_settings',
PostAnalytics = 'post_analytics',
PostAnalyticsHistory = 'post_analytics_history',
ProfileAnalytics = 'profile_analytics',
ProfileAnalyticsHistory = 'profile_analytics_history',
CheckLocation = 'check_location',
GenerateBrief = 'generate_brief',
Opportunity = 'opportunity',
Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/lib/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,20 @@ export const canViewPostAnalytics = ({
return !!user?.id && user.id === post?.author?.id;
};

export const canViewUserProfileAnalytics = ({
user,
profileUserId,
}: {
user?: Pick<LoggedUser, 'id' | 'isTeamMember'>;
profileUserId?: string;
}): boolean => {
if (user?.isTeamMember) {
return true;
}

return !!user?.id && user.id === profileUserId;
};

export const userProfileQueryOptions = ({ id }) => {
return {
queryKey: generateQueryKey(
Expand Down
13 changes: 2 additions & 11 deletions packages/webapp/components/layouts/ProfileLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ import type { NextSeoProps } from 'next-seo';
import { useProfile } from '@dailydotdev/shared/src/hooks/profile/useProfile';
import CustomAuthBanner from '@dailydotdev/shared/src/components/auth/CustomAuthBanner';
import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext';
import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log';
import { usePostReferrerContext } from '@dailydotdev/shared/src/contexts/PostReferrerContext';
import { LogEvent } from '@dailydotdev/shared/src/lib/log';
import { getLayout as getFooterNavBarLayout } from '../FooterNavBarLayout';
import { getLayout as getMainLayout } from '../MainLayout';
import { getTemplatedTitle } from '../utils';
Expand Down Expand Up @@ -90,7 +89,6 @@ export default function ProfileLayout({
const { user } = useProfile(initialUser);
const [trackedView, setTrackedView] = useState(false);
const { logEvent } = useLogContext();
const { referrerPost } = usePostReferrerContext();

// Auto-collapse sidebar on small screens
useProfileSidebarCollapse();
Expand All @@ -103,16 +101,9 @@ export default function ProfileLayout({
logEvent({
event_name: LogEvent.ProfileView,
target_id: user.id,
...(!!referrerPost && {
extra: JSON.stringify({
referrer_target_id: referrerPost.id,
referrer_target_type: TargetType.Post,
author: user?.id && referrerPost.author?.id === user.id ? 1 : 0,
}),
}),
});
setTrackedView(true);
}, [user, trackedView, logEvent, referrerPost]);
}, [user, trackedView, logEvent]);

if (!isFallback && !user) {
return <Custom404 />;
Expand Down