From 19b9af5a3ad069da2b4fc196508b0f28aa0ffb6a Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Sun, 18 Jan 2026 23:28:30 +0100 Subject: [PATCH 1/2] feat: profile views --- .../ProfileWidgets/ProfileViewersWidget.tsx | 170 ++++++++++++++++++ .../ProfileWidgets/ProfileWidgets.tsx | 6 + packages/shared/src/graphql/users.ts | 37 ++++ packages/shared/src/lib/query.ts | 2 + packages/shared/src/lib/user.ts | 14 ++ .../layouts/ProfileLayout/index.tsx | 13 +- 6 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewersWidget.tsx diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewersWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewersWidget.tsx new file mode 100644 index 0000000000..1f6021dfb0 --- /dev/null +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewersWidget.tsx @@ -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 ProfileViewersWidgetProps { + userId: string; +} + +interface HistoryQueryResult { + userProfileAnalyticsHistory: { + edges: Array<{ + node: UserProfileAnalyticsHistory; + }>; + }; +} + +interface AnalyticsQueryResult { + userProfileAnalytics: UserProfileAnalytics | null; +} + +const ProfileViewersWidgetSkeleton = (): ReactElement => { + return ( + + + Profile Activity + +
+
+ + + + + + + + +
+ + + + +
+
+ ); +}; + +export const ProfileViewersWidget = ({ + userId, +}: ProfileViewersWidgetProps): ReactElement => { + const { data: historyData, isPending: isHistoryPending } = + useQuery({ + 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({ + 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 ; + } + + return ( + + + Profile Activity + +
+
+ + +
+ +
+
+ ); +}; diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx index 9dbd757961..ada9ce112e 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx @@ -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 { ProfileViewersWidget } from './ProfileViewersWidget'; const BadgesAndAwards = dynamic(() => import('./BadgesAndAwards').then((mod) => mod.BadgesAndAwards), @@ -63,6 +65,10 @@ export function ProfileWidgets({ {isSameUser && ( )} + {canViewUserProfileAnalytics({ + user: loggedUser, + profileUserId: user.id, + }) && } => { 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; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 2b84978312..6416c9f355 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -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', diff --git a/packages/shared/src/lib/user.ts b/packages/shared/src/lib/user.ts index 8ffd6c6173..f19655abd1 100644 --- a/packages/shared/src/lib/user.ts +++ b/packages/shared/src/lib/user.ts @@ -306,6 +306,20 @@ export const canViewPostAnalytics = ({ return !!user?.id && user.id === post?.author?.id; }; +export const canViewUserProfileAnalytics = ({ + user, + profileUserId, +}: { + user?: Pick; + profileUserId?: string; +}): boolean => { + if (user?.isTeamMember) { + return true; + } + + return !!user?.id && user.id === profileUserId; +}; + export const userProfileQueryOptions = ({ id }) => { return { queryKey: generateQueryKey( diff --git a/packages/webapp/components/layouts/ProfileLayout/index.tsx b/packages/webapp/components/layouts/ProfileLayout/index.tsx index 301490010c..fc72734834 100644 --- a/packages/webapp/components/layouts/ProfileLayout/index.tsx +++ b/packages/webapp/components/layouts/ProfileLayout/index.tsx @@ -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'; @@ -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(); @@ -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 ; From 83adf3511e590c4dd7e4e776a9e0d67e3f43b554 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 19 Jan 2026 11:21:41 +0100 Subject: [PATCH 2/2] rename widget --- ...ofileViewersWidget.tsx => ProfileViewsWidget.tsx} | 12 ++++++------ .../components/ProfileWidgets/ProfileWidgets.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) rename packages/shared/src/features/profile/components/ProfileWidgets/{ProfileViewersWidget.tsx => ProfileViewsWidget.tsx} (94%) diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewersWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewsWidget.tsx similarity index 94% rename from packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewersWidget.tsx rename to packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewsWidget.tsx index 1f6021dfb0..e7f466f7de 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewersWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileViewsWidget.tsx @@ -29,7 +29,7 @@ import { largeNumberFormat } from '../../../../lib'; import { SummaryCard } from './BadgesAndAwardsComponents'; import { ElementPlaceholder } from '../../../../components/ElementPlaceholder'; -interface ProfileViewersWidgetProps { +interface ProfileViewsWidgetProps { userId: string; } @@ -45,9 +45,9 @@ interface AnalyticsQueryResult { userProfileAnalytics: UserProfileAnalytics | null; } -const ProfileViewersWidgetSkeleton = (): ReactElement => { +const ProfileViewsWidgetSkeleton = (): ReactElement => { return ( - + { ); }; -export const ProfileViewersWidget = ({ +export const ProfileViewsWidget = ({ userId, -}: ProfileViewersWidgetProps): ReactElement => { +}: ProfileViewsWidgetProps): ReactElement => { const { data: historyData, isPending: isHistoryPending } = useQuery({ queryKey: generateQueryKey(RequestKey.ProfileAnalyticsHistory, { @@ -135,7 +135,7 @@ export const ProfileViewersWidget = ({ const total = analyticsData?.userProfileAnalytics?.uniqueVisitors ?? 0; if (isHistoryPending || isAnalyticsPending) { - return ; + return ; } return ( diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx index ada9ce112e..7a448b37cf 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx @@ -14,7 +14,7 @@ import { canViewUserProfileAnalytics } from '../../../../lib/user'; import { ReadingOverview } from './ReadingOverview'; import { ProfileCompletion } from './ProfileCompletion'; import { Share } from './Share'; -import { ProfileViewersWidget } from './ProfileViewersWidget'; +import { ProfileViewsWidget } from './ProfileViewsWidget'; const BadgesAndAwards = dynamic(() => import('./BadgesAndAwards').then((mod) => mod.BadgesAndAwards), @@ -68,7 +68,7 @@ export function ProfileWidgets({ {canViewUserProfileAnalytics({ user: loggedUser, profileUserId: user.id, - }) && } + }) && }