diff --git a/packages/shared/src/components/CalendarHeatmap.tsx b/packages/shared/src/components/CalendarHeatmap.tsx
index 1e8a33353b5..b1aac02b2f1 100644
--- a/packages/shared/src/components/CalendarHeatmap.tsx
+++ b/packages/shared/src/components/CalendarHeatmap.tsx
@@ -51,7 +51,7 @@ function getRange(count: number): number[] {
return Array.from(new Array(Math.max(0, count)), (_, i) => i);
}
-function getBins(values: number[]): number[] {
+export function getBins(values: number[]): number[] {
const uniques = Array.from(new Set(values)).sort((a, b) => a - b);
if (uniques.length <= BINS) {
return [
@@ -66,7 +66,7 @@ function getBins(values: number[]): number[] {
);
}
-function getBin(value: number, bins: number[]): number {
+export function getBin(value: number, bins: number[]): number {
if (!value) {
return 0;
}
diff --git a/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts b/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts
index b2fa7d23009..a35bb8e8b0b 100644
--- a/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts
+++ b/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts
@@ -1,6 +1,9 @@
import { AchievementType } from '../../../graphql/user/achievements';
import type { UserAchievement } from '../../../graphql/user/achievements';
-import { sortLockedAchievements } from './sortAchievements';
+import {
+ sortLockedAchievements,
+ sortRarestUnlockedAchievements,
+} from './sortAchievements';
const createAchievement = ({
id,
@@ -71,3 +74,79 @@ describe('sortLockedAchievements', () => {
]);
});
});
+
+describe('sortRarestUnlockedAchievements', () => {
+ const unlocked = ({
+ id,
+ rarity,
+ xp = 10,
+ unlockedAt = '2026-01-01T00:00:00.000Z',
+ }: {
+ id: string;
+ rarity: number | null;
+ xp?: number;
+ unlockedAt?: string;
+ }): UserAchievement => {
+ const base = createAchievement({
+ id,
+ progress: 1,
+ targetCount: 1,
+ xp,
+ unlockedAt,
+ });
+
+ return { ...base, achievement: { ...base.achievement, rarity } };
+ };
+
+ it('drops the locked ones', () => {
+ const result = sortRarestUnlockedAchievements([
+ createAchievement({
+ id: 'locked',
+ progress: 0,
+ targetCount: 5,
+ xp: 1,
+ }),
+ unlocked({ id: 'earned', rarity: 20 }),
+ ]);
+
+ expect(result.map((a) => a.achievement.id)).toEqual(['earned']);
+ });
+
+ it('puts the rarest first, and an unknown rarity last', () => {
+ const result = sortRarestUnlockedAchievements([
+ unlocked({ id: 'common', rarity: 40 }),
+ unlocked({ id: 'unknown', rarity: null }),
+ unlocked({ id: 'rarest', rarity: 1 }),
+ ]);
+
+ expect(result.map((a) => a.achievement.id)).toEqual([
+ 'rarest',
+ 'common',
+ 'unknown',
+ ]);
+ });
+
+ it('breaks a rarity tie on xp, then on the more recent unlock', () => {
+ const result = sortRarestUnlockedAchievements([
+ unlocked({
+ id: 'older',
+ rarity: 5,
+ xp: 50,
+ unlockedAt: '2026-01-01T00:00:00.000Z',
+ }),
+ unlocked({ id: 'less-xp', rarity: 5, xp: 10 }),
+ unlocked({
+ id: 'newer',
+ rarity: 5,
+ xp: 50,
+ unlockedAt: '2026-06-01T00:00:00.000Z',
+ }),
+ ]);
+
+ expect(result.map((a) => a.achievement.id)).toEqual([
+ 'newer',
+ 'older',
+ 'less-xp',
+ ]);
+ });
+});
diff --git a/packages/shared/src/components/modals/achievement/sortAchievements.ts b/packages/shared/src/components/modals/achievement/sortAchievements.ts
index 6dc86589f47..99382234939 100644
--- a/packages/shared/src/components/modals/achievement/sortAchievements.ts
+++ b/packages/shared/src/components/modals/achievement/sortAchievements.ts
@@ -29,3 +29,34 @@ export const sortLockedAchievements = (
return b.achievement.xp - a.achievement.xp;
});
};
+
+/**
+ * Rarest first, so the profile widget and the share card can never disagree
+ * about which achievements are the ones worth showing.
+ */
+export const sortRarestUnlockedAchievements = (
+ achievements: UserAchievement[],
+): UserAchievement[] => {
+ return achievements
+ .filter((achievement) => achievement.unlockedAt !== null)
+ .sort((a, b) => {
+ const rarityA = a.achievement.rarity ?? Infinity;
+ const rarityB = b.achievement.rarity ?? Infinity;
+ if (rarityA !== rarityB) {
+ return rarityA - rarityB;
+ }
+
+ const xpDelta = b.achievement.xp - a.achievement.xp;
+ if (xpDelta !== 0) {
+ return xpDelta;
+ }
+
+ const unlockedDateA = a.unlockedAt ? new Date(a.unlockedAt).getTime() : 0;
+ const unlockedDateB = b.unlockedAt ? new Date(b.unlockedAt).getTime() : 0;
+ if (unlockedDateA !== unlockedDateB) {
+ return unlockedDateB - unlockedDateA;
+ }
+
+ return a.achievement.id.localeCompare(b.achievement.id);
+ });
+};
diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx
index 3061b34c3bf..a71ff7d18a9 100644
--- a/packages/shared/src/components/profile/ProfileHeader.tsx
+++ b/packages/shared/src/components/profile/ProfileHeader.tsx
@@ -8,13 +8,14 @@ import {
TypographyColor,
TypographyType,
} from '../typography/Typography';
-import { DevPlusIcon, EditIcon } from '../icons';
+import { DevPlusIcon, EditIcon, LinkIcon } from '../icons';
import type { PublicProfile } from '../../lib/user';
import type { UserStatsProps } from './UserStats';
import { UserStats } from './UserStats';
import JoinedDate from './JoinedDate';
import { Separator } from '../cards/common/common';
-import { Button, ButtonVariant } from '../buttons/Button';
+import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
+import { CopyStateIcon } from '../share/CopyStateIcon';
import { webappUrl } from '../../lib/constants';
import Link from '../utilities/Link';
import { useAuthContext } from '../../contexts/AuthContext';
@@ -24,6 +25,13 @@ import { locationToString } from '../../lib/utils';
import { IconSize } from '../Icon';
import { fallbackImages } from '../../lib/config';
import { ProfileDesktopPwaBackButton } from './ProfileBackButton';
+import { Tooltip } from '../tooltip/Tooltip';
+import { useCopyLink } from '../../hooks/useCopy';
+import { useGetShortUrl } from '../../hooks/utils/useGetShortUrl';
+import { useLogContext } from '../../contexts/LogContext';
+import { LogEvent, Origin, TargetType } from '../../lib/log';
+import { ShareProvider } from '../../lib/share';
+import { ReferralCampaignKey } from '../../lib/referral';
import { ElementPlaceholder } from '../ElementPlaceholder';
@@ -67,6 +75,25 @@ const ProfileHeader = ({
const { name, username, bio, image, cover, isPlus } = user;
const { user: loggedUser } = useAuthContext();
const isSameUser = propIsSameUser ?? loggedUser?.id === user.id;
+ const { logEvent } = useLogContext();
+ const [isCopying, copyLink] = useCopyLink();
+ const { getTrackedUrl } = useGetShortUrl();
+
+ const onCopyLink = () => {
+ logEvent({
+ event_name: LogEvent.ShareProfile,
+ target_type: TargetType.ProfilePage,
+ target_id: user.id,
+ extra: JSON.stringify({
+ provider: ShareProvider.CopyLink,
+ origin: Origin.ProfileHeader,
+ }),
+ });
+ copyLink({
+ link: getTrackedUrl(user.permalink, ReferralCampaignKey.ShareProfile),
+ shorten: true,
+ });
+ };
return (
@@ -100,6 +127,15 @@ const ProfileHeader = ({
aria-label="Edit profile"
/>
+
+ }
+ onClick={onCopyLink}
+ size={ButtonSize.Medium}
+ variant={ButtonVariant.Float}
+ />
+
{actions}
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx
index 831fd9e867d..630fb53bab2 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx
@@ -142,5 +142,32 @@ describe('AchievementsWidget', () => {
.filter((alt): alt is string => expectedVisibleNames.includes(alt ?? ''));
expect(renderedNames).toEqual(expectedVisibleNames);
+ expect(screen.getByLabelText('Snapshot')).toBeInTheDocument();
+ });
+
+ it('should not offer a snapshot before anything is unlocked', () => {
+ mockUseProfileAchievements.mockReturnValue({
+ achievements: [
+ createUserAchievement({
+ id: 'locked',
+ name: 'Locked',
+ rarity: 1,
+ xp: 100,
+ unlockedAt: null,
+ }),
+ ],
+ unlockedCount: 0,
+ totalCount: 1,
+ totalAchievementXp: 0,
+ isPending: false,
+ isError: false,
+ });
+
+ renderComponent();
+
+ expect(
+ screen.getByText('No achievements unlocked yet'),
+ ).toBeInTheDocument();
+ expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument();
});
});
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx
index 6ce40dd0a3b..e7ea002987f 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx
@@ -21,6 +21,10 @@ import {
import { RaritySparkles } from '../achievements/RaritySparkles';
import HoverCard from '../../../../components/cards/common/HoverCard';
import { AchievementCard } from '../achievements/AchievementCard';
+import { AchievementsSnapshotCard } from '../../../snapshot/AchievementsSnapshotCard';
+import { sortRarestUnlockedAchievements } from '../../../../components/modals/achievement/sortAchievements';
+import { ProfileSnapshotButton } from '../../../snapshot/ProfileSnapshotButton';
+import { Origin } from '../../../../lib/log';
interface AchievementsWidgetProps {
user: PublicProfile;
@@ -47,28 +51,8 @@ function RecentAchievements({
const { achievements, isPending } = useProfileAchievements(user);
const rarestUnlocked = achievements
- ?.filter((a) => a.unlockedAt !== null)
- .sort((a, b) => {
- const rarityA = a.achievement.rarity ?? Infinity;
- const rarityB = b.achievement.rarity ?? Infinity;
- if (rarityA !== rarityB) {
- return rarityA - rarityB;
- }
-
- const xpDelta = b.achievement.xp - a.achievement.xp;
- if (xpDelta !== 0) {
- return xpDelta;
- }
-
- const unlockedDateA = a.unlockedAt ? new Date(a.unlockedAt).getTime() : 0;
- const unlockedDateB = b.unlockedAt ? new Date(b.unlockedAt).getTime() : 0;
- if (unlockedDateA !== unlockedDateB) {
- return unlockedDateB - unlockedDateA;
- }
-
- return a.achievement.id.localeCompare(b.achievement.id);
- })
- .slice(0, 5);
+ ? sortRarestUnlockedAchievements(achievements).slice(0, 5)
+ : undefined;
if (isPending) {
return
;
@@ -110,7 +94,7 @@ function RecentAchievements({
}
>
);
@@ -133,7 +117,8 @@ function RecentAchievements({
export function AchievementsWidget({
user,
}: AchievementsWidgetProps): ReactElement {
- const { unlockedCount, totalCount } = useProfileAchievements(user);
+ const { achievements, unlockedCount, totalCount, totalAchievementXp } =
+ useProfileAchievements(user);
return (
@@ -148,11 +133,42 @@ export function AchievementsWidget({
Achievements
-
-
- {unlockedCount}/{totalCount}
-
-
+
+
+
+ {unlockedCount}/{totalCount}
+
+
+ {unlockedCount > 0 && (
+
(
+ ({
+ image: achievement.image,
+ name: achievement.name,
+ }))}
+ xp={totalAchievementXp}
+ ref={ref}
+ seed={user.username ?? user.id}
+ total={totalCount}
+ unlocked={unlockedCount}
+ user={{
+ handle: `@${user.username ?? user.id}`,
+ image: user.image,
+ name: user.name,
+ }}
+ />
+ )}
+ />
+ )}
+
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx
index c7b6a0e135e..d62b5fed301 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx
@@ -184,6 +184,8 @@ describe('BadgesAndAwards component', () => {
// Should not show any badge or award items
expect(screen.queryByRole('list')).not.toBeInTheDocument();
+ // Nor offer an image of two zeros
+ expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument();
});
it('should render top reader badges when available', async () => {
@@ -206,6 +208,7 @@ describe('BadgesAndAwards component', () => {
// Check badge items
expect(screen.getByText('JavaScript')).toBeInTheDocument();
expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByLabelText('Snapshot')).toBeInTheDocument();
});
it('should render awards when user has cores access', async () => {
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx
index 6d800232861..3547f9a79fa 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx
@@ -24,6 +24,10 @@ import {
BadgesAndAwardsSkeleton,
} from './BadgesAndAwardsComponents';
import { anchorDefaultRel } from '../../../../lib/strings';
+import { ProfileSnapshotButton } from '../../../snapshot/ProfileSnapshotButton';
+import { BadgesSnapshotCard } from '../../../snapshot/BadgesSnapshotCard';
+import { formatDate, TimeFormatType } from '../../../../lib/dateFormat';
+import { Origin } from '../../../../lib/log';
export const BadgesAndAwards = ({
user,
@@ -60,18 +64,57 @@ export const BadgesAndAwards = ({
const totalAwards =
awards?.reduce((sum, award) => sum + (award?.count || 0), 0) ?? 0;
+ const topReaderBadges = topReaders?.[0]?.total ?? 0;
return (
-
- Badges & Awards
-
+
+
+ Badges & Awards
+
+ {(topReaderBadges > 0 || totalAwards > 0) && (
+
(
+ ({
+ count: award.count,
+ image: award.image,
+ name: award.name,
+ })) ?? []
+ }
+ badges={
+ topReaders?.map((badge) => ({
+ earnedAt: formatDate({
+ value: badge.issuedAt,
+ type: TimeFormatType.TopReaderBadge,
+ }),
+ keyword: badge.keyword.flags?.title || badge.keyword.value,
+ })) ?? []
+ }
+ ref={ref}
+ seed={user.username ?? user.id}
+ topReaderBadges={topReaderBadges}
+ totalAwards={totalAwards}
+ user={{
+ handle: `@${user.username ?? user.id}`,
+ image: user.image,
+ name: user.name,
+ }}
+ />
+ )}
+ />
+ )}
+
-
+
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx
index 8606f11a609..f7e04c9311f 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx
@@ -2,15 +2,15 @@ import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import { useQuery } from '@tanstack/react-query';
-import { startOfTomorrow, subDays, subMonths } from 'date-fns';
import dynamic from 'next/dynamic';
import { useAuthContext } from '../../../../contexts/AuthContext';
import { useSettingsContext } from '../../../../contexts/SettingsContext';
import { ActiveOrRecomendedSquads } from './ActiveOrRecomendedSquads';
-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 type { ProfileV2 } from '../../../../graphql/users';
+import {
+ getProfileReadingWindow,
+ profileReadingHistoryQueryOptions,
+} from '../../../../graphql/users';
import { canViewUserProfileAnalytics } from '../../../../lib/user';
import { ReadingOverview } from './ReadingOverview';
import { ProfileCompletion } from './ProfileCompletion';
@@ -96,25 +96,10 @@ export function ProfileWidgets({
!isAchievementsPending &&
shouldRenderTrackingWidget;
- const before = startOfTomorrow();
- const after = subMonths(subDays(before, 2), 5);
-
- const { data: readingHistory, isLoading: isReadingHistoryLoading } =
- useQuery({
- queryKey: generateQueryKey(RequestKey.ReadingStats, user),
- queryFn: () =>
- gqlClient.request(USER_READING_HISTORY_QUERY, {
- id: user?.id,
- before,
- after,
- version: 2,
- limit: 6,
- }),
- enabled: !!user && tokenRefreshed && !!before && !!after,
- refetchOnWindowFocus: false,
- refetchOnReconnect: false,
- refetchOnMount: false,
- });
+ const { before, after } = getProfileReadingWindow();
+ const { data: readingHistory, isLoading: isReadingHistoryLoading } = useQuery(
+ profileReadingHistoryQueryOptions({ user, enabled: tokenRefreshed }),
+ );
const squads = sources?.edges?.map((s) => s.node.source) ?? [];
return (
@@ -147,6 +132,7 @@ export function ProfileWidgets({
profileUserId: user.id,
}) && }
{
expect(screen.getByText('react')).toBeInTheDocument();
expect(screen.getByText('+60%')).toBeInTheDocument(); // javascript percentage
expect(screen.getByText('+40%')).toBeInTheDocument(); // react percentage
+ expect(screen.getByLabelText('Snapshot')).toBeInTheDocument();
+ });
+
+ it('should not offer a snapshot when there is no reading to show', () => {
+ renderComponent({
+ readHistory: [],
+ streak: { ...mockStreak, max: 0, total: 0, current: 0 },
+ mostReadTags: [],
+ });
+
+ expect(screen.getByText('Reading Overview')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument();
+ });
+
+ it('should offer a snapshot for a streak with no reads in the window', () => {
+ renderComponent({ readHistory: [], mostReadTags: [] });
+
+ expect(screen.getByLabelText('Snapshot')).toBeInTheDocument();
});
it('should render the keyword title once it is available', async () => {
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx
index b2801030e6a..1a0672891f6 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx
@@ -1,12 +1,18 @@
import type { ReactElement, ReactNode } from 'react';
-import React, { useMemo } from 'react';
+import React, { forwardRef } from 'react';
+import { useQuery } from '@tanstack/react-query';
import type {
UserReadHistory,
UserStreak,
MostReadTag,
} from '../../../../graphql/users';
+import { sumReadHistory } from '../../../../graphql/users';
import { ActivityContainer } from '../../../../components/profile/ActivitySection';
-import { CalendarHeatmap } from '../../../../components/CalendarHeatmap';
+import {
+ CalendarHeatmap,
+ getBin,
+ getBins,
+} from '../../../../components/CalendarHeatmap';
import { migrateUserToStreaks } from '../../../../lib/constants';
import { ClickableText } from '../../../../components/buttons/ClickableText';
import {
@@ -22,7 +28,15 @@ import {
ReadingOverviewSkeleton,
} from './ReadingOverviewComponents';
import { anchorDefaultRel, pluralize } from '../../../../lib/strings';
-import { largeNumberFormat } from '../../../../lib';
+import { largeNumberFormat } from '../../../../lib/numberFormat';
+import { ReadingOverviewSnapshotCard } from '../../../snapshot/ReadingOverviewSnapshotCard';
+import { ProfileSnapshotButton } from '../../../snapshot/ProfileSnapshotButton';
+import { tagTitlesQueryOptions } from '../../../../graphql/keywords';
+import type { PublicProfile } from '../../../../lib/user';
+import { Origin } from '../../../../lib/log';
+
+/** ReadingOverviewSnapshotCard's heatmap grid: four rows of twenty-two. */
+const SNAPSHOT_HEATMAP_CELLS = 88;
// Utility functions
const readHistoryToValue = (value: UserReadHistory): number => value.reads;
@@ -50,6 +64,7 @@ const readHistoryToTooltip = (
};
export interface ReadingOverviewProps {
+ user: PublicProfile;
readHistory?: UserReadHistory[];
before: Date;
after: Date;
@@ -58,7 +73,60 @@ export interface ReadingOverviewProps {
isLoading?: boolean;
}
+type ReadingOverviewCardProps = Omit;
+
+const ReadingOverviewCard = forwardRef<
+ HTMLDivElement,
+ ReadingOverviewCardProps
+>(function ReadingOverviewCard(
+ { user, readHistory, before, after, streak, mostReadTags },
+ ref,
+): ReactElement {
+ const { data: tagTitles = {} } = useQuery(tagTitlesQueryOptions());
+
+ // The card draws one cell per bucket and stops at its grid, so the window
+ // is compressed into that many buckets rather than handed a day each: a
+ // day per cell would show the oldest weeks and drop everything since.
+ const start = after.getTime();
+ const span = Math.max(1, before.getTime() - start);
+ const buckets = new Array(SNAPSHOT_HEATMAP_CELLS).fill(0);
+
+ readHistory?.forEach((entry) => {
+ const offset = (new Date(entry.date).getTime() - start) / span;
+ const cell = Math.floor(offset * SNAPSHOT_HEATMAP_CELLS);
+
+ buckets[Math.min(SNAPSHOT_HEATMAP_CELLS - 1, Math.max(0, cell))] +=
+ readHistoryToValue(entry);
+ });
+
+ const bins = getBins(buckets);
+
+ return (
+ getBin(reads, bins))}
+ longestStreak={streak?.max}
+ monthsLabel="in the last months"
+ postsRead={sumReadHistory(readHistory)}
+ ref={ref}
+ seed={user.username ?? user.id}
+ topTags={
+ mostReadTags?.map((tag) => ({
+ name: tagTitles[tag.value] || tag.value,
+ percentage: Math.round((tag.percentage ?? 0) * 100),
+ })) ?? []
+ }
+ totalReadingDays={streak?.total}
+ user={{
+ handle: `@${user.username ?? user.id}`,
+ image: user.image,
+ name: user.name,
+ }}
+ />
+ );
+});
+
export function ReadingOverview({
+ user,
readHistory,
before,
after,
@@ -66,15 +134,14 @@ export function ReadingOverview({
mostReadTags,
isLoading = false,
}: ReadingOverviewProps): ReactElement {
- const totalReads = useMemo(() => {
- if (!readHistory?.length) {
- return 0;
- }
- return readHistory.reduce((acc, val) => {
- const reads = val?.reads || 0;
- return acc + (typeof reads === 'number' && reads >= 0 ? reads : 0);
- }, 0);
- }, [readHistory]);
+ const totalReads = sumReadHistory(readHistory);
+ // The card leaves out every section whose number is zero, so with no reads,
+ // no streak and no tags there would be nothing on it but the name.
+ const hasSnapshot =
+ totalReads > 0 ||
+ !!streak?.max ||
+ !!streak?.total ||
+ !!mostReadTags?.length;
if (isLoading) {
return ;
@@ -82,15 +149,35 @@ export function ReadingOverview({
return (
-
- Reading Overview
-
+
+
+ Reading Overview
+
+ {hasSnapshot && (
+
(
+
+ )}
+ />
+ )}
+
{
}
+ icon={ }
onClick={onShareOrCopy}
aria-label={copying ? 'Copied!' : 'Copy link'}
/>
diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx
index 63c8fd500d5..d7a0c11abb1 100644
--- a/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx
+++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx
@@ -128,3 +128,29 @@ describe('AchievementCard β stop tracking', () => {
).not.toBeInTheDocument();
});
});
+
+describe('AchievementCard snapshot', () => {
+ const unlocked = createLockedAchievement({
+ unlockedAt: '2025-05-21T12:00:00.000Z',
+ progress: 1,
+ });
+
+ it('is not offered when the card does not know whose achievement it is', () => {
+ renderCard({ userAchievement: unlocked });
+
+ expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument();
+ });
+
+ it('names the owner and dates the unlock with its year', () => {
+ renderCard({
+ userAchievement: unlocked,
+ user: { id: 'u1', name: 'Ada Lovelace', username: 'ada', image: '' },
+ });
+
+ fireEvent.pointerEnter(screen.getByLabelText('Snapshot'));
+
+ expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
+ expect(screen.getByText('@ada')).toBeInTheDocument();
+ expect(screen.getByText('Completed May 21, 2025')).toBeInTheDocument();
+ });
+});
diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx
index 9d4b5fe6c1b..d99ba6e424d 100644
--- a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx
+++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx
@@ -1,7 +1,9 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
+import { format } from 'date-fns';
import type { UserAchievement } from '../../../../graphql/user/achievements';
+import type { PublicProfile } from '../../../../lib/user';
import {
AchievementType,
getTargetCount,
@@ -29,9 +31,17 @@ import {
rarityGlowClasses,
} from './achievementRarity';
import { RaritySparkles } from './RaritySparkles';
+import { ProfileSnapshotButton } from '../../../snapshot/ProfileSnapshotButton';
+import { AchievementSnapshotCard } from '../../../snapshot/AchievementSnapshotCard';
+import { Origin, TargetType } from '../../../../lib/log';
interface AchievementCardProps {
userAchievement: UserAchievement;
+ /**
+ * Whose achievement this is. The snapshot names them, so it is only offered
+ * where the card knows.
+ */
+ user?: Pick;
isOwner?: boolean;
isTracked?: boolean;
isTrackPending?: boolean;
@@ -42,6 +52,7 @@ interface AchievementCardProps {
export function AchievementCard({
userAchievement,
+ user,
isOwner = false,
isTracked = false,
isTrackPending = false,
@@ -65,7 +76,7 @@ export function AchievementCard({
return (
-
+
+ {isUnlocked && unlockedAt && user && (
+
+ (
+
+ )}
+ targetId={achievement.id}
+ targetType={TargetType.AchievementCard}
+ variant={ButtonVariant.Secondary}
+ />
+
+ )}
);
diff --git a/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx b/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx
index 1dd4bd0ae64..848d1066ab7 100644
--- a/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx
+++ b/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx
@@ -2,6 +2,8 @@ import type { ReactElement } from 'react';
import React, { forwardRef } from 'react';
import { AchievementRarityTier } from '../profile/components/achievements/achievementRarity';
import { SnapshotFrame } from './SnapshotFrame';
+import type { SnapshotIdentityProps } from './SnapshotIdentity';
+import { SnapshotIdentity } from './SnapshotIdentity';
const CARD_WIDTH = 620;
/** Trading-card proportions (2.5:3.5) rather than a square slab. */
@@ -21,6 +23,8 @@ const PILL = {
};
export interface AchievementSnapshotCardProps {
+ /** Who earned it, so a visitor's share does not read as their own. */
+ user: SnapshotIdentityProps;
name: string;
description: string;
image?: string;
@@ -32,6 +36,7 @@ export interface AchievementSnapshotCardProps {
function AchievementSnapshotCardComponent(
{
+ user,
name,
description,
image,
@@ -119,6 +124,15 @@ function AchievementSnapshotCardComponent(
>
Completed {completedAt}
+
+
+
diff --git a/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx b/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx
index f8f6e89abe4..c72c03f9c56 100644
--- a/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx
+++ b/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx
@@ -23,6 +23,7 @@ export interface AchievementsSnapshotCardProps {
user: SnapshotIdentityProps;
unlocked: number;
total: number;
+ /** Left out when zero, as is the rarest row when there is nothing in it. */
xp: number;
achievements: UnlockedAchievement[];
seed?: string;
@@ -53,42 +54,46 @@ function AchievementsSnapshotCardComponent(
label={`of ${total} unlocked`}
value={String(unlocked)}
/>
-
+ {xp > 0 && (
+
+ )}
-
-
Rarest unlocked
-
- {achievements.slice(0, 10).map((achievement) => (
-
- {achievement.image ? (
-
- ) : (
-
- {achievement.emoji}
-
- )}
-
- ))}
+ {achievements.length > 0 && (
+
+
Rarest unlocked
+
+ {achievements.slice(0, 10).map((achievement) => (
+
+ {achievement.image ? (
+
+ ) : (
+
+ {achievement.emoji}
+
+ )}
+
+ ))}
+
-
+ )}
);
diff --git a/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx
index ea79db1db00..1973be7dcec 100644
--- a/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx
+++ b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx
@@ -25,6 +25,7 @@ export interface AwardTally {
export interface BadgesSnapshotCardProps {
user: SnapshotIdentityProps;
+ /** Each tally and list is left out when it is zero or empty. */
topReaderBadges: number;
totalAwards: number;
badges: TopReaderBadge[];
@@ -52,75 +53,87 @@ function BadgesSnapshotCardComponent(
-
-
-
-
+ {(topReaderBadges > 0 || totalAwards > 0) && (
+
+ {topReaderBadges > 0 && (
+
+ )}
+ {totalAwards > 0 && (
+
+ )}
+
+ )}
-
- {badges.slice(0, 4).map((badge) => (
-
-
0 && (
+
+ {badges.slice(0, 4).map((badge) => (
+
- {badge.keyword}
-
-
- {badge.earnedAt}
-
-
- ))}
-
-
-
- {awards.slice(0, 6).map((award) => (
-
- {award.image ? (
-
- ) : (
-
- {award.emoji}
+
+ {badge.keyword}
+
+
+ {badge.earnedAt}
- )}
-
+ ))}
+
+ )}
+
+ {awards.length > 0 && (
+
+ {awards.slice(0, 6).map((award) => (
+
- x{award.count}
-
-
- ))}
-
+ {award.image ? (
+
+ ) : (
+
+ {award.emoji}
+
+ )}
+
+ x{award.count}
+
+
+ ))}
+
+ )}
);
diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx
new file mode 100644
index 00000000000..f54e5037616
--- /dev/null
+++ b/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { QueryClient } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { TestBootProvider } from '../../../__tests__/helpers/boot';
+import { captureShareImage } from '../../lib/imageShare/captureShareImage';
+import { copyShareImage } from '../../lib/imageShare/copyShareImage';
+import { LogEvent, Origin, TargetType } from '../../lib/log';
+import { ShareProvider } from '../../lib/share';
+import { ProfileSnapshotButton } from './ProfileSnapshotButton';
+
+jest.mock('../../lib/imageShare/captureShareImage', () => ({
+ captureShareImage: jest.fn(),
+}));
+jest.mock('../../lib/imageShare/copyShareImage', () => ({
+ copyShareImage: jest.fn(),
+}));
+
+const logEvent = jest.fn();
+const renderCard = jest.fn((ref) =>
profile card
);
+
+const client = new QueryClient();
+const snapshotButton = (ownerId = 'u1') => (
+
+
+
+);
+const renderButton = () => render(snapshotButton());
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ jest
+ .mocked(captureShareImage)
+ .mockResolvedValue(new Blob(['png'], { type: 'image/png' }));
+ jest.mocked(copyShareImage).mockResolvedValue(true);
+});
+
+describe('ProfileSnapshotButton', () => {
+ it('does not build the card until the button is armed', () => {
+ renderButton();
+
+ expect(renderCard).not.toHaveBeenCalled();
+
+ fireEvent.pointerEnter(screen.getByLabelText('Snapshot'));
+
+ expect(screen.getByText('profile card')).toBeInTheDocument();
+ });
+
+ it('drops the armed card when the profile changes under it', () => {
+ const { rerender } = renderButton();
+ fireEvent.pointerEnter(screen.getByLabelText('Snapshot'));
+ expect(screen.getByText('profile card')).toBeInTheDocument();
+
+ rerender(snapshotButton('u2'));
+
+ expect(screen.queryByText('profile card')).not.toBeInTheDocument();
+ });
+
+ it('logs the press as a profile share with its placement', async () => {
+ renderButton();
+ const button = screen.getByLabelText('Snapshot');
+ fireEvent.pointerEnter(button);
+ fireEvent.click(button);
+
+ await waitFor(() =>
+ expect(logEvent).toHaveBeenCalledWith({
+ event_name: LogEvent.ShareProfile,
+ target_type: TargetType.ProfilePage,
+ target_id: 'u1',
+ extra: JSON.stringify({
+ provider: ShareProvider.Snapshot,
+ origin: Origin.ReadingOverview,
+ result: 'clipboard',
+ }),
+ }),
+ );
+ });
+});
diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx
new file mode 100644
index 00000000000..56b3e732006
--- /dev/null
+++ b/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx
@@ -0,0 +1,108 @@
+import type { ReactElement, Ref } from 'react';
+import React, { useCallback, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import type { ButtonVariant } from '../../components/buttons/common';
+import { ButtonSize } from '../../components/buttons/common';
+import type { SnapshotResult } from '../../components/imageShare/SnapshotButton';
+import { SnapshotButton } from '../../components/imageShare/SnapshotButton';
+import { useLogContext } from '../../contexts/LogContext';
+import type { Origin } from '../../lib/log';
+import { LogEvent, TargetType } from '../../lib/log';
+import { ShareProvider } from '../../lib/share';
+import { getSnapshotCaptureOptions } from './snapshotCapture';
+import { useArmedCard } from './useArmedCard';
+
+export interface ProfileSnapshotButtonProps {
+ /** Which placement this is, for the snapshot's share event. */
+ origin: Origin;
+ filename: string;
+ /** The profile's user. The profile is also the target unless one is set. */
+ ownerId: string;
+ targetId?: string;
+ targetType?: TargetType;
+ /**
+ * Called only once the button is armed, so whatever the card derives from
+ * the page's data is not computed on every profile view.
+ */
+ renderCard: (ref: Ref
) => ReactElement;
+ size?: ButtonSize;
+ variant?: ButtonVariant;
+}
+
+/**
+ * A part of the profile as an image. A profile snapshot is a share of the
+ * profile, so it lands on `ShareProfile` with provider `snapshot`, the
+ * placement as the origin, and how the press ended.
+ *
+ * The card is staged off-screen at its full 1080px because the capture reads
+ * the live DOM, and portalled to the body so it inherits neither a widget's
+ * overflow nor a hover card's transform.
+ */
+function ArmedProfileSnapshotButton({
+ origin,
+ filename,
+ ownerId,
+ targetId = ownerId,
+ targetType = TargetType.ProfilePage,
+ renderCard,
+ size = ButtonSize.XSmall,
+ variant,
+}: ProfileSnapshotButtonProps): ReactElement {
+ const cardRef = useRef(null);
+ const { isArmed, armProps } = useArmedCard();
+ const { logEvent } = useLogContext();
+
+ const onResult = useCallback(
+ (result: SnapshotResult) =>
+ logEvent({
+ event_name: LogEvent.ShareProfile,
+ target_type: targetType,
+ target_id: targetId,
+ extra: JSON.stringify({
+ provider: ShareProvider.Snapshot,
+ origin,
+ result,
+ }),
+ }),
+ [logEvent, origin, targetId, targetType],
+ );
+
+ return (
+ <>
+
+ getSnapshotCaptureOptions(cardRef.current)}
+ filename={filename}
+ onResult={onResult}
+ showLabel={false}
+ size={size}
+ target={cardRef}
+ variant={variant}
+ />
+
+ {isArmed &&
+ typeof document !== 'undefined' &&
+ createPortal(
+
+ {renderCard(cardRef)}
+
,
+ document.body,
+ )}
+ >
+ );
+}
+
+// Keyed by the owner: a client-side move to another profile reuses this
+// component, and a card armed on the last profile would stay mounted with the
+// next one's data.
+export function ProfileSnapshotButton({
+ ownerId,
+ ...props
+}: ProfileSnapshotButtonProps): ReactElement {
+ return (
+
+ );
+}
diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotCards.spec.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotCards.spec.tsx
new file mode 100644
index 00000000000..f01d9fb8471
--- /dev/null
+++ b/packages/shared/src/features/snapshot/ProfileSnapshotCards.spec.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ReadingOverviewSnapshotCard } from './ReadingOverviewSnapshotCard';
+import { BadgesSnapshotCard } from './BadgesSnapshotCard';
+import { AchievementsSnapshotCard } from './AchievementsSnapshotCard';
+
+const user = { name: 'Ada Lovelace', handle: '@ada' };
+
+describe('profile snapshot cards with little to show', () => {
+ it('leaves the heatmap, tags and zero streak off the reading card', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Total reading days')).toBeInTheDocument();
+ expect(screen.queryByText(/Longest streak/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Posts read/)).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Top tags by reading days'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('leaves the zero tally and the empty rows off the badges card', () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByText('Top reader badge')).toBeInTheDocument();
+ expect(screen.queryByText('Total awards')).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText('Total awards')).toBeInTheDocument();
+ expect(screen.queryByText('Top reader badge')).not.toBeInTheDocument();
+ expect(screen.queryByText('x0')).not.toBeInTheDocument();
+ });
+
+ it('leaves zero XP and an empty rarest row off the achievements card', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('of 74 unlocked')).toBeInTheDocument();
+ expect(screen.queryByText('Achievement XP')).not.toBeInTheDocument();
+ expect(screen.queryByText('Rarest unlocked')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx
index 3f33c468298..1870bf89e93 100644
--- a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx
+++ b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx
@@ -31,8 +31,12 @@ export interface ReadingOverviewTag {
export interface ReadingOverviewSnapshotCardProps {
user: SnapshotIdentityProps;
- longestStreak: number;
- totalReadingDays: number;
+ /**
+ * Each section below is left out when its number is zero or unknown, so a
+ * quiet stretch reads as fewer sections rather than as zeros.
+ */
+ longestStreak?: number;
+ totalReadingDays?: number;
postsRead: number;
monthsLabel: string;
topTags: ReadingOverviewTag[];
@@ -101,61 +105,74 @@ function ReadingOverviewSnapshotCardComponent(
-
-
-
-
-
-
-
- Top tags by reading days
-
-
- {visibleTags.map((tag) => (
-
+ {!!longestStreak && (
+
+ )}
+ {!!totalReadingDays && (
+
- ))}
+ )}
-
+ )}
-
-
- Posts read {monthsLabel} (
- {largeNumberFormat(postsRead) ?? postsRead})
-
-
- {cells.map((level, index) => (
-
- ))}
+ {visibleTags.length > 0 && (
+
+
+ Top tags by reading days
+
+
+ {visibleTags.map((tag) => (
+
+ ))}
+
+
+ )}
+
+ {postsRead > 0 && (
+
+
+ Posts read {monthsLabel} (
+ {largeNumberFormat(postsRead) ?? postsRead})
+
+
+ {cells.map((level, index) => (
+
+ ))}
+
-
+ )}
);
diff --git a/packages/shared/src/features/snapshot/SnapshotIdentity.tsx b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx
index 1858e397c87..167f49b97a5 100644
--- a/packages/shared/src/features/snapshot/SnapshotIdentity.tsx
+++ b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx
@@ -26,7 +26,7 @@ export function SnapshotIdentity({
style={{ width: 76, height: 76, borderRadius: 22 }}
/>
)}
-
+
{label}
diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts
index dfbccf97231..570dd2ddd07 100644
--- a/packages/shared/src/graphql/users.ts
+++ b/packages/shared/src/graphql/users.ts
@@ -1,5 +1,5 @@
import { ClientError, gql } from 'graphql-request';
-import { subDays } from 'date-fns';
+import { startOfTomorrow, subDays, subMonths } from 'date-fns';
import {
SHARED_POST_INFO_FRAGMENT,
TOP_READER_BADGE_FRAGMENT,
@@ -13,6 +13,7 @@ import type { SourceMember } from './sources';
import type { SendType } from '../hooks';
import type { DayOfWeek } from '../lib/date';
import type { NotificationSettings } from '../components/notifications/utils';
+import { generateQueryKey, RequestKey } from '../lib/query';
export const USER_SHORT_BY_ID = `
query UserShortById($id: ID!) {
@@ -234,6 +235,45 @@ export const USER_READING_HISTORY_QUERY = gql`
}
`;
+export const sumReadHistory = (readHistory?: UserReadHistory[]): number =>
+ readHistory?.reduce((total, entry) => {
+ const reads = entry?.reads || 0;
+
+ return total + (typeof reads === 'number' && reads >= 0 ? reads : 0);
+ }, 0) ?? 0;
+
+export const getProfileReadingWindow = (): { before: Date; after: Date } => {
+ const before = startOfTomorrow();
+
+ return { before, after: subMonths(subDays(before, 2), 5) };
+};
+
+export const profileReadingHistoryQueryOptions = ({
+ user,
+ enabled = true,
+}: {
+ user?: Pick;
+ enabled?: boolean;
+}) => {
+ const { before, after } = getProfileReadingWindow();
+
+ return {
+ queryKey: generateQueryKey(RequestKey.ReadingStats, user),
+ queryFn: (): Promise =>
+ gqlClient.request(USER_READING_HISTORY_QUERY, {
+ id: user?.id,
+ before,
+ after,
+ version: 2,
+ limit: 6,
+ }),
+ enabled: !!user && enabled,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ };
+};
+
export const USER_STREAK_HISTORY = gql`
query UserStreakHistory($id: ID!, $after: String!, $before: String!) {
userReadHistory(id: $id, after: $after, before: $before) {
diff --git a/packages/shared/src/hooks/useCopy.spec.ts b/packages/shared/src/hooks/useCopy.spec.ts
new file mode 100644
index 00000000000..7b35c62ab9d
--- /dev/null
+++ b/packages/shared/src/hooks/useCopy.spec.ts
@@ -0,0 +1,67 @@
+import { act, renderHook } from '@testing-library/react';
+import { useCopyLink } from './useCopy';
+
+const mockDisplayToast = jest.fn();
+const mockWriteText = jest.fn();
+
+jest.mock('./useToastNotification', () => ({
+ useToastNotification: () => ({ displayToast: mockDisplayToast }),
+ ToastType: { Error: 'error' },
+}));
+
+jest.mock('./utils/useGetShortUrl', () => ({
+ useGetShortUrl: () => ({ getShortUrl: jest.fn() }),
+}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ Object.assign(navigator, { clipboard: { writeText: mockWriteText } });
+});
+
+it('copies the link and reports the copied state', async () => {
+ mockWriteText.mockResolvedValue(undefined);
+ const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev'));
+
+ await act(async () => {
+ await result.current[1]();
+ });
+
+ expect(mockWriteText).toHaveBeenCalledWith('https://daily.dev');
+ expect(mockDisplayToast).toHaveBeenCalledWith(
+ 'β
Copied link to clipboard',
+ {},
+ );
+ expect(result.current[0]).toBe(true);
+});
+
+it('says so when the clipboard refuses the write', async () => {
+ mockWriteText.mockRejectedValue(
+ new DOMException('Document is not focused.', 'NotAllowedError'),
+ );
+ const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev'));
+
+ await act(async () => {
+ await result.current[1]();
+ });
+
+ expect(mockDisplayToast).toHaveBeenCalledWith(
+ 'β Your browser blocked the clipboard',
+ { variant: 'error' },
+ );
+ expect(result.current[0]).toBe(false);
+});
+
+it('does not report a copy when there is no link', async () => {
+ const { result } = renderHook(() => useCopyLink(() => ''));
+
+ await act(async () => {
+ await result.current[1]();
+ });
+
+ expect(mockWriteText).not.toHaveBeenCalled();
+ expect(mockDisplayToast).toHaveBeenCalledWith(
+ 'β Could not copy, link is missing',
+ { variant: 'error' },
+ );
+ expect(result.current[0]).toBe(false);
+});
diff --git a/packages/shared/src/hooks/useCopy.ts b/packages/shared/src/hooks/useCopy.ts
index 8f81d628348..563e4bf4900 100644
--- a/packages/shared/src/hooks/useCopy.ts
+++ b/packages/shared/src/hooks/useCopy.ts
@@ -76,6 +76,8 @@ export function useCopyLink(
}
} else {
displayToast(noLinkErrorMessage, { variant: ToastType.Error });
+
+ return;
}
setCopying(true);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 7a4381cc3d5..aed0460d293 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -70,6 +70,14 @@ export enum Origin {
HappeningNowSelection = 'happening now selection',
HighlightsCard = 'highlights card',
// snapshot placements - end
+ // profile share placements - start
+ ProfileHeader = 'profile header',
+ ReadingOverview = 'reading overview',
+ BadgesAndAwards = 'badges and awards',
+ AchievementsWidget = 'achievements widget',
+ AchievementCard = 'achievement card',
+ DevCard = 'devcard',
+ // profile share placements - end
History = 'history',
FeedbackCard = 'feedback card',
FeedCard = 'feed card',
diff --git a/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx b/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx
index a4de92da575..66b5c87ff0d 100644
--- a/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx
+++ b/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx
@@ -5,10 +5,25 @@ import type { UserReadHistory, UserStreak, MostReadTag } from '@dailydotdev/shar
import { addDays, subDays, subMonths } from 'date-fns';
import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext';
import { fn } from 'storybook/test';
+import type { PublicProfile } from '@dailydotdev/shared/src/lib/user';
+
+const profile = {
+ id: 'storybook-user',
+ name: 'Storybook User',
+ username: 'storybook',
+ premium: false,
+ reputation: 100,
+ image: 'https://via.placeholder.com/40',
+ createdAt: '2023-01-01T00:00:00Z',
+ permalink: 'https://app.daily.dev/storybook',
+} as PublicProfile;
const meta: Meta = {
title: 'Features/Profile/ReadingOverview',
component: ReadingOverview,
+ args: {
+ user: profile,
+ },
parameters: {
layout: 'padded',
},
diff --git a/packages/storybook/stories/features/snapshot/ShareImages.stories.tsx b/packages/storybook/stories/features/snapshot/ShareImages.stories.tsx
index dd2a1288e93..2732087106d 100644
--- a/packages/storybook/stories/features/snapshot/ShareImages.stories.tsx
+++ b/packages/storybook/stories/features/snapshot/ShareImages.stories.tsx
@@ -186,13 +186,14 @@ const PLACEMENTS: Placement[] = [
render: (ref) => (
),
},
diff --git a/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx b/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx
index d57a2cd167e..5152af65a8c 100644
--- a/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx
+++ b/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx
@@ -245,13 +245,14 @@ const CARDS: CardSpec[] = [
node: (ref) => (
),
},
@@ -266,6 +267,10 @@ const CARDS: CardSpec[] = [
rarity={38}
seed="ac-b"
tier={AchievementRarityTier.Bronze}
+ user={{
+ name: 'A Considerably Longer Display Name For Truncation',
+ handle: '@an-extremely-long-handle-that-keeps-going',
+ }}
/>
),
},
diff --git a/packages/storybook/stories/features/snapshot/SnapshotPlacements.stories.tsx b/packages/storybook/stories/features/snapshot/SnapshotPlacements.stories.tsx
index d9fbf829918..6ad8337632c 100644
--- a/packages/storybook/stories/features/snapshot/SnapshotPlacements.stories.tsx
+++ b/packages/storybook/stories/features/snapshot/SnapshotPlacements.stories.tsx
@@ -1,4 +1,3 @@
-import classNames from 'classnames';
import React, { useRef, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -11,13 +10,8 @@ import {
ButtonSize,
ButtonVariant,
} from '@dailydotdev/shared/src/components/buttons/Button';
-import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements';
-import { AchievementType } from '@dailydotdev/shared/src/graphql/user/achievements';
import {
- EditIcon,
ArrowIcon,
- HotIcon,
- MedalBadgeIcon,
UpvoteIcon,
DiscussIcon,
BookmarkIcon,
@@ -431,48 +425,6 @@ const HighlightPlacement = () => {
);
};
-/** 3. Leaderboard β icon-only, revealed on row hover. */
-const LEADERBOARD_ROWS = [
- { score: 15500, name: 'Bobby Iliev', handle: 'bobbyiliev', level: 103 },
- { score: 14200, name: 'Keshav Ashiya', handle: 'keshavashiya', level: 98 },
- { score: 13700, name: 'Hadil Ben Abdallah', handle: 'hadilben', level: 96 },
-];
-
-const LeaderboardPlacement = () => (
-
- {LEADERBOARD_ROWS.map((row) => (
-
-
- {row.score.toLocaleString()}
-
-
- {row.level}
-
-
-
-
- {row.name}
-
-
- @{row.handle}
-
-
-
-
- ))}
-
-);
-
/** 4. Watercooler feed β one per post card, in the card action row. */
const WatercoolerPlacement = () => {
const ref = useRef(null);
@@ -482,14 +434,9 @@ const WatercoolerPlacement = () => {
-
-
- Ante BariΔ
-
-
- Watercooler Β· 2h
-
-
+
+ Watercooler Β· 2h
+
What is the one dev tool you would not give up?
@@ -531,193 +478,16 @@ const HotTakePlacement = () => {
Every formatter argument is a proxy war over indentation.
-
-
-
- );
-};
-
-/** 6a. Profile header β right of the edit button. */
-const ProfileHeaderPlacement = () => {
- const ref = useRef
(null);
-
- return (
-
-
-
-
-
-
}
- aria-label="Edit profile"
- className="text-text-secondary"
- />
-
-
-
- Tomer Redlich
-
-
@tomer
-
-
- );
-};
-
-/** 6bβ6d. Profile widgets β icon-only, in the widget header row. */
-const WidgetPlacement = ({
- title,
- trailing,
- children,
-}: {
- title: React.ReactNode;
- trailing?: React.ReactNode;
- children: React.ReactNode;
-}) => {
- const ref = useRef(null);
-
- return (
-
- );
-};
-
-const ACHIEVEMENT: UserAchievement = {
- achievement: {
- id: 'achievement-1',
- name: 'Streak keeper',
- description: 'Read something on daily.dev 100 days in a row.',
- image:
- 'https://media.daily.dev/image/upload/s--SNnLKKWe--/q_auto/v1773608419/achievements/coraholic',
- points: 120,
- rarity: 4,
- type: AchievementType.Milestone,
- criteria: { targetCount: 100 },
- unit: 'days',
- },
- progress: 100,
- unlockedAt: '2026-06-01T00:00:00Z',
- createdAt: '2026-01-01T00:00:00Z',
- updatedAt: '2026-06-01T00:00:00Z',
-};
-
-const AchievementBox = ({ entry }: { entry: UserAchievement }) => {
- const isUnlocked = entry.unlockedAt !== null;
-
- return (
-
-
-
-
-
- {entry.achievement.name}
-
-
- {entry.achievement.description}
-
-
-
-
-
- {entry.achievement.xp}
-
-
);
};
-const LOCKED_ACHIEVEMENT: UserAchievement = {
- ...ACHIEVEMENT,
- achievement: {
- ...ACHIEVEMENT.achievement,
- id: 'achievement-2',
- name: 'First take',
- description: 'Post your first hot take.',
- rarity: 38,
- image:
- 'https://media.daily.dev/image/upload/v1770222937/achievements/Town_crier.png',
- criteria: { targetCount: 1 },
- unit: null,
- },
- progress: 0,
- unlockedAt: null,
-};
-
const Placements = () => {
const [capture, setCapture] = useState(null);
const onCapture = React.useCallback((blob: Blob) => {
@@ -748,9 +518,11 @@ const Placements = () => {
Sharing map .
- Placements 1β7 are built and live; 8β20 are mock-ups of surfaces the
- Sharing map covers but the code does not touch yet, so the control
- and its verdict can be reviewed before anything is wired.
+ Placement 1 is live on the post page, and the profile placements (6
+ and 7) are left out because the live profile is the reference. The
+ rest are mock-ups of surfaces the Sharing map covers but the code
+ does not touch yet, so the control and its verdict can be reviewed
+ before anything is wired.
@@ -899,15 +671,6 @@ const Placements = () => {
-
-
-
-
{
-
-
-
-
-
- Learn more
-
- }
- >
-
- Posts read in the last months (412)
-
-
- {Array.from({ length: 36 }).map((_, i) => (
-
- ))}
-
-
-
-
-
-
-
- x4
-
-
- Top reader badge
-
-
-
-
- x12
-
-
- Total Awards
-
-
-
-
-
-
-
- Achievements
- >
- }
- trailing={
- 18/60
- }
- >
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
-
-
-
-
-
-
- {[ACHIEVEMENT, LOCKED_ACHIEVEMENT].map((entry) => (
-
- ))}
-
-
-
{
filename="daily-thread"
leads="Link"
title="Enjoyed this discussion?"
- body="24 replies Β· last one 4 minutes ago"
/>
@@ -1078,7 +750,6 @@ const Placements = () => {
@@ -1089,7 +760,6 @@ const Placements = () => {
@@ -1104,7 +774,7 @@ const Placements = () => {
step="Placement 13"
leads="Link"
title="Leaderboard page"
- note="Copy link leads for the board itself β it changes weekly, so a link stays true where an image does not. Sharing your own rank is Placement 3."
+ note="Copy link leads for the board itself: it changes weekly, so a link stays true where an image does not."
>
{
eyebrow="My feed"
filename="daily-my-feed"
leads="Snapshot"
- meta="Top 20 posts right now"
title="What I'm reading"
/>
@@ -1187,7 +856,6 @@ const Placements = () => {
@@ -1222,7 +890,6 @@ const Placements = () => {
filename="daily-invite"
leads="Link"
title="Come read with me on daily.dev"
- body="We both get a month of Plus Β· daily.dev/join/tomer"
/>
diff --git a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx
index 6a1af8c2be8..c20ce784efe 100644
--- a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx
+++ b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx
@@ -81,8 +81,8 @@ const PAGES: React.ReactNode[][] = [
],
[
'Profile',
- '#6354 #6360 #6356',
- 'Header, the three widgets, and the DevCard β three surfaces on one page that want three different controls',
+ '#6580',
+ 'Shipped: the live profile is the reference, so it has no mockup here',
],
[
'Status moments',
diff --git a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx
deleted file mode 100644
index f8705253899..00000000000
--- a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-import React from 'react';
-import type { Meta, StoryObj } from '@storybook/react-vite';
-import {
- Button,
- ButtonSize,
- ButtonVariant,
-} from '@dailydotdev/shared/src/components/buttons/Button';
-import {
- DownloadIcon,
- EditIcon,
- MedalBadgeIcon,
- ReputationIcon,
-} from '@dailydotdev/shared/src/components/icons';
-import type { DeviceName } from '../surfaceChrome';
-import {
- AVATAR,
- Category,
- Control,
- Device,
- Rail,
- SurfacePage,
- Variant,
-} from '../surfaceChrome';
-
-/* ------------------------------------------------------------------ header */
-
-/**
- * ProfileHeader: an h-36 cover, a 7.5rem rounded-16 avatar pinned at
- * `left-6 top-16`, then a right-aligned action row above the name. The
- * snapshot button already ships here, matched to the edit button at Medium
- * Float.
- */
-const ProfileScreen = ({ device }: { device: DeviceName }) => (
-
-
-
-
-
-
-
- }
- size={ButtonSize.Medium}
- variant={ButtonVariant.Float}
- />
-
-
-
-
-
- Tomer Redlich
-
-
-
-
- Building the feed developers actually read.
-
-
Tel Aviv
-
- @tomer Β· Joined Jan 4. 2021
-
-
-
-
-
- 1.2K Reputation
-
-
- 3.4K Upvotes
-
-
- 842 Followers
-
-
- 61 Following
-
-
-
-
-
-
-);
-
-/* ----------------------------------------------------------------- widgets */
-
-const SummaryCard = ({ count, label }: { count: string; label: string }) => (
-
- {count}
- {label}
-
-);
-
-const WidgetHeader = ({
- title,
- icon,
- trailing,
-}: {
- title: string;
- icon?: React.ReactNode;
- trailing?: React.ReactNode;
-}) => (
-
-
- {icon}
- {title}
-
-
- {trailing}
-
-
-
-);
-
-const WidgetsScreen = ({ device }: { device: DeviceName }) => (
-
-
-
-
- Learn more
-
-
-
-
-
- Top tags by reading days
-
-
- {[
- ['#typescript', 82],
- ['#react', 64],
- ['#webdev', 41],
- ['#css', 28],
- ].map(([tag, pct]) => (
-
-
-
- {tag}
-
-
- ))}
-
-
- Posts read in the last months (3.4K)
-
-
- {Array.from({ length: 60 }, (_, i) => {
- const level = Math.max(
- 0,
- Math.min(3, Math.round(2 + Math.sin(i / 4) * 1.4)),
- );
- const tone = [
- 'bg-surface-float',
- 'bg-overlay-float-cabbage',
- 'bg-accent-cabbage-subtler',
- 'bg-accent-cabbage-default',
- ][level];
-
- return (
- // eslint-disable-next-line react/no-array-index-key
-
- );
- })}
-
-
-
-
-
- Learn more
-
-
-
-
-
- {['#typescript', '#react'].map((tag) => (
-
- π₯ Top reader in {tag}
-
- ))}
-
-
-
-
- }
- title="Achievements"
- trailing={12/40 }
- />
-
- {['Can't spend it all', 'Big byte energy'].map((name) => (
-
-
-
-
- {name}
-
-
- Unlocked 12 Aug 2026
-
-
-
- 120
-
-
- ))}
-
-
- {device === 'Mobile' &&
mobile }
-
-
-);
-
-/* ---------------------------------------------------------------- devcard */
-
-const DevCardScreen = () => (
-
-
-
- Your DevCard is ready
-
-
-
- }
- size={ButtonSize.Small}
- variant={ButtonVariant.Float}
- >
- Download
-
-
-
-
-
-);
-
-/* -------------------------------------------------------------------- page */
-
-const Profile = () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
-
-const meta: Meta
= {
- title: 'Features/Snapshot/Surfaces/Profile',
- component: Profile,
- parameters: { layout: 'fullscreen' },
-};
-
-export default meta;
-
-export const Variations: StoryObj = {};
diff --git a/packages/webapp/__tests__/DevCardShare.spec.tsx b/packages/webapp/__tests__/DevCardShare.spec.tsx
new file mode 100644
index 00000000000..3fa345ae85e
--- /dev/null
+++ b/packages/webapp/__tests__/DevCardShare.spec.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { QueryClient } from '@tanstack/react-query';
+import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot';
+import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser';
+import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard';
+import { DevCardTheme } from '@dailydotdev/shared/src/components/profile/devcard/common';
+import {
+ generateQueryKey,
+ RequestKey,
+} from '@dailydotdev/shared/src/lib/query';
+import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log';
+import { ShareProvider } from '@dailydotdev/shared/src/lib/share';
+import { DevCardStep2 } from '../components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2';
+
+const writeText = jest.fn();
+const logEvent = jest.fn();
+
+const devCard: DevCardQueryData = {
+ devCard: {
+ id: 'dc1',
+ user: { ...loggedUser, premium: false, reputation: 10 },
+ createdAt: '2024-01-01T00:00:00.000Z',
+ theme: DevCardTheme.Default,
+ isProfileCover: false,
+ showBorder: true,
+ reputation: 10,
+ articlesRead: 3,
+ tags: [],
+ sources: [],
+ streak: { max: 1 },
+ },
+ userStreakProfile: { max: 1 },
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ writeText.mockResolvedValue(undefined);
+ Object.assign(navigator, { clipboard: { writeText } });
+});
+
+it('copies the profile link with the share campaign on it', async () => {
+ const client = new QueryClient();
+ client.setQueryData(
+ generateQueryKey(RequestKey.DevCard, { id: loggedUser.id }),
+ devCard,
+ );
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }));
+
+ await waitFor(() =>
+ expect(writeText).toHaveBeenCalledWith(
+ `${loggedUser.permalink}?userid=${loggedUser.id}&cid=share_profile`,
+ ),
+ );
+ expect(logEvent).toHaveBeenCalledWith({
+ event_name: LogEvent.ShareDevcard,
+ target_id: loggedUser.id,
+ extra: JSON.stringify({
+ provider: ShareProvider.CopyLink,
+ origin: Origin.DevCard,
+ }),
+ });
+});
diff --git a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx
index f386d178e2e..7363219b003 100644
--- a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx
+++ b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx
@@ -15,14 +15,17 @@ import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks';
import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard';
import { useDevCard } from '@dailydotdev/shared/src/hooks/profile/useDevCard';
import { useCopyLink } from '@dailydotdev/shared/src/hooks/useCopy';
+import { useGetShortUrl } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl';
import { downloadUrl } from '@dailydotdev/shared/src/lib/blob';
+import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral';
+import { ShareProvider } from '@dailydotdev/shared/src/lib/share';
import {
generateQueryKey,
RequestKey,
} from '@dailydotdev/shared/src/lib/query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
-import { LogEvent } from '@dailydotdev/shared/src/lib/log';
+import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log';
import { Button } from '@dailydotdev/shared/src/components/buttons/Button';
import { ClickableText } from '@dailydotdev/shared/src/components/buttons/ClickableText';
import {
@@ -32,15 +35,20 @@ import {
import { RadioItem } from '@dailydotdev/shared/src/components/fields/RadioItem';
import { IconSize } from '@dailydotdev/shared/src/components/Icon';
import {
+ DownloadIcon,
GitHubIcon,
OpenLinkIcon,
+ ShareIcon,
TwitterIcon,
} from '@dailydotdev/shared/src/components/icons';
import { DevCardFetchWrapper } from '@dailydotdev/shared/src/components/profile/devcard/DevCardFetchWrapper';
import { devCard } from '@dailydotdev/shared/src/lib/constants';
import { checkLowercaseEquality } from '@dailydotdev/shared/src/lib/strings';
import classNames from 'classnames';
-import { isNullOrUndefined } from '@dailydotdev/shared/src/lib/func';
+import {
+ isNullOrUndefined,
+ shouldUseNativeShare,
+} from '@dailydotdev/shared/src/lib/func';
import { Switch } from '@dailydotdev/shared/src/components/fields/Switch';
import {
Typography,
@@ -90,6 +98,39 @@ export const DevCardStep2 = ({
[user?.name, user?.username, devCardSrc, type],
);
const [copyingEmbed, copyEmbed] = useCopyLink(() => embedCode);
+ const [copyingProfileLink, copyProfileLink] = useCopyLink();
+ const { getTrackedUrl } = useGetShortUrl();
+ const onShareDevCard = async () => {
+ // The tracked link is known without a request, so both the share sheet
+ // and the clipboard get it inside the press; the copy swaps in the short
+ // link once it resolves.
+ const link = getTrackedUrl(
+ user?.permalink ?? '',
+ ReferralCampaignKey.ShareProfile,
+ );
+ const logShare = (provider: ShareProvider) =>
+ logEvent({
+ event_name: LogEvent.ShareDevcard,
+ target_id: userId,
+ extra: JSON.stringify({ provider, origin: Origin.DevCard }),
+ });
+
+ if (shouldUseNativeShare()) {
+ try {
+ await navigator.share({
+ text: `Check out my #DevCard on daily.dev\n${link}`,
+ });
+ logShare(ShareProvider.Native);
+ } catch {
+ // Dismissing the sheet rejects too.
+ }
+
+ return;
+ }
+
+ logShare(ShareProvider.CopyLink);
+ copyProfileLink({ link, shorten: true });
+ };
const [selectedTab, setSelectedTab] = useState(0);
const { mutateAsync: onDownloadUrl, isPending: downloading } = useMutation({
mutationFn: downloadUrl,
@@ -230,18 +271,29 @@ export const DevCardStep2 = ({
{!isNullOrUndefined(devcard) && (
- generateThenDownload({})}
- disabled={downloading || isLoading}
- tag={isMobile ? 'a' : 'button'}
- href={devCardSrc}
- target={isMobile ? '_blank' : undefined}
- >
- Download DevCard
-
+
+ }
+ onClick={() => generateThenDownload({})}
+ disabled={downloading || isLoading}
+ tag={isMobile ? 'a' : 'button'}
+ href={devCardSrc}
+ target={isMobile ? '_blank' : undefined}
+ >
+ Download
+
+ }
+ onClick={onShareDevCard}
+ disabled={copyingProfileLink || isLoading}
+ >
+ Share
+
+
)}
diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx
index 6c0241f3c24..18e7c7f1b92 100644
--- a/packages/webapp/pages/game-center/index.tsx
+++ b/packages/webapp/pages/game-center/index.tsx
@@ -486,6 +486,7 @@ function GameCenterPage({