Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
04c5cd9
feat(shared): capture an element as a branded share image
tomeredlich Sep 2, 2026
a9805fd
feat(profile): snapshot the header, its widgets and achievements
tomeredlich Sep 2, 2026
be96f05
feat(profile): copy link in the header, and lead the DevCard with share
tomeredlich Sep 2, 2026
3e9e531
docs(snapshot): add the profile surface page to Storybook
tomeredlich Sep 2, 2026
6e7ab3e
feat(profile): confirm the copy with an arrow, and show only what ships
tomeredlich Sep 3, 2026
e93a91f
fix(profile): shrink the widget snapshot buttons to XSmall
tomeredlich Sep 3, 2026
f4bc21f
docs(snapshot): drop the profile surface page from Storybook
tomeredlich Sep 3, 2026
aa8f9c0
fix(share): tell the user when a copy did not happen
tomeredlich Sep 3, 2026
56723b6
feat(profile): confirm the share widget's copy with the same green arrow
tomeredlich Sep 3, 2026
0511521
fix(profile): confirm a copied link with a checkmark, not an upvote
tomeredlich Sep 6, 2026
456f264
fix(profile): match the copy confirmation to the shared CopyStateIcon
tomeredlich Sep 6, 2026
9891f66
Merge branch 'main' into claude/profile-content-draft-pr-368a12
tsahimatsliah Sep 6, 2026
645e478
feat(snapshot): rasterize designed cards instead of the live DOM
tomeredlich Sep 6, 2026
9782fea
feat(profile): wire the remaining four placements to their designed c…
tomeredlich Sep 6, 2026
f426b49
fix(snapshot): keep the Snapshot button, not a share button
tomeredlich Sep 7, 2026
6cd7731
fix(snapshot): copy the image and say so, as the button used to
tomeredlich Sep 7, 2026
1e23049
feat(snapshot): take the current card design from snapshot-share-images
tomeredlich Sep 8, 2026
bcab94e
fix(profile): spread the snapshot heatmap over the whole window
tomeredlich Sep 8, 2026
b81aeb8
Merge branch 'main' into claude/profile-content-draft-pr-368a12
tomeredlich Sep 9, 2026
d252417
Merge branch 'main' into claude/profile-content-draft-pr-368a12
idoshamun Sep 10, 2026
47ddeb7
fix(profile): port the profile snapshots onto main's SnapshotButton
idoshamun Sep 10, 2026
5ea76ec
fix(share): do not confirm a copy when the link is missing
idoshamun Sep 10, 2026
5006b21
fix(devcard): log the share with its origin and the user
idoshamun Sep 10, 2026
05f53d0
test(profile): pin the profile snapshot's lazy card and its share event
idoshamun Sep 10, 2026
85aae27
fix(snapshot): format the reading card's longest streak like the page
idoshamun Sep 10, 2026
562f100
chore(storybook): drop the profile mockups now that the profile ships
idoshamun Sep 10, 2026
ad5a31f
refactor(profile): drop ProfileSnapshotButton's unused className
idoshamun Sep 10, 2026
9a580b3
fix(profile): label the share image's posts read with its window
idoshamun Sep 10, 2026
d0bdad2
chore(storybook): drop invented stats from the placement mock-ups
idoshamun Sep 10, 2026
5aaf087
Merge remote-tracking branch 'origin/main' into qa-6580
idoshamun Sep 10, 2026
d51d61e
fix(profile): tie profile snapshots to the profile they show
idoshamun Sep 10, 2026
2f53266
fix(profile): show the header image's lifetime reads, and no zeros
idoshamun Sep 10, 2026
dedbf61
fix(profile): offer a profile snapshot only when its card has something
idoshamun Sep 10, 2026
4275a95
fix(devcard): share a tracked profile link, like the profile header
idoshamun Sep 10, 2026
d2fc792
Merge remote-tracking branch 'origin/main' into qa-6580
idoshamun Sep 10, 2026
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
4 changes: 2 additions & 2 deletions packages/shared/src/components/CalendarHeatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -71,3 +74,79 @@ describe('sortLockedAchievements', () => {
]);
});
});

describe('sortRarestUnlockedAchievements', () => {
const unlocked = ({
id,
rarity,
points = 10,
unlockedAt = '2026-01-01T00:00:00.000Z',
}: {
id: string;
rarity: number | null;
points?: number;
unlockedAt?: string;
}): UserAchievement => {
const base = createAchievement({
id,
progress: 1,
targetCount: 1,
points,
unlockedAt,
});

return { ...base, achievement: { ...base.achievement, rarity } };
};

it('drops the locked ones', () => {
const result = sortRarestUnlockedAchievements([
createAchievement({
id: 'locked',
progress: 0,
targetCount: 5,
points: 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 points, then on the more recent unlock', () => {
const result = sortRarestUnlockedAchievements([
unlocked({
id: 'older',
rarity: 5,
points: 50,
unlockedAt: '2026-01-01T00:00:00.000Z',
}),
unlocked({ id: 'fewer-points', rarity: 5, points: 10 }),
unlocked({
id: 'newer',
rarity: 5,
points: 50,
unlockedAt: '2026-06-01T00:00:00.000Z',
}),
]);

expect(result.map((a) => a.achievement.id)).toEqual([
'newer',
'older',
'fewer-points',
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,34 @@ export const sortLockedAchievements = (
return b.achievement.points - a.achievement.points;
});
};

/**
* 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 pointsDelta = b.achievement.points - a.achievement.points;
if (pointsDelta !== 0) {
return pointsDelta;
}

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);
});
};
84 changes: 80 additions & 4 deletions packages/shared/src/components/profile/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import type { ReactNode } from 'react';
import React from 'react';
import type { ReactElement, ReactNode } from 'react';
import React, { forwardRef } from 'react';
import dynamic from 'next/dynamic';
import { format } from 'date-fns';
import classNames from 'classnames';
import { useQuery } from '@tanstack/react-query';
import { Image } from '../image/Image';
import {
Typography,
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';
Expand All @@ -24,6 +27,16 @@ import { locationToString } from '../../lib/utils';
import { IconSize } from '../Icon';
import { fallbackImages } from '../../lib/config';
import { ProfileDesktopPwaBackButton } from './ProfileBackButton';
import { ProfileSnapshotButton } from '../../features/snapshot/ProfileSnapshotButton';
import { ProfileSnapshotCard } from '../../features/snapshot/ProfileSnapshotCard';
import { devCardQueryOptions } from '../../hooks/profile/useDevCard';
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';

Expand All @@ -46,6 +59,32 @@ const ProfileActions = dynamic(
},
);

const ProfileCard = forwardRef<HTMLDivElement, { user: PublicProfile }>(
function ProfileCard({ user }, ref): ReactElement {
// The lifetime count the DevCard shows. Only an armed card mounts this, so
// a profile view does not fetch it.
const { data: devCard } = useQuery(
devCardQueryOptions({ userId: user.id }),
);
const handle = user.username ?? user.id;

return (
<ProfileSnapshotCard
bio={user.bio}
cover={user.cover}
handle={`@${handle}`}
image={user.image}
joined={format(new Date(user.createdAt), 'MMMM y')}
name={user.name}
postsRead={devCard?.devCard.articlesRead}
ref={ref}
reputation={user.reputation}
seed={handle}
/>
);
},
);

type ProfileHeaderProps = {
user: PublicProfile;
/** Optional for the same reason the profile's static props are: the counts
Expand All @@ -67,6 +106,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 (
<div className="relative w-full overflow-hidden laptop:rounded-t-16">
Expand Down Expand Up @@ -100,6 +158,24 @@ const ProfileHeader = ({
aria-label="Edit profile"
/>
</Link>
<ProfileSnapshotButton
filename={`daily-profile-${username ?? user.id}`}
origin={Origin.ProfileHeader}
ownerId={user.id}
renderCard={(ref) => <ProfileCard ref={ref} user={user} />}
// Matches the edit button beside it, which takes Button's default.
size={ButtonSize.Medium}
variant={ButtonVariant.Float}
/>
<Tooltip content={isCopying ? 'Copied!' : 'Copy link'}>
<Button
aria-label="Copy link"
icon={<CopyStateIcon copied={isCopying} icon={LinkIcon} />}
onClick={onCopyLink}
size={ButtonSize.Medium}
variant={ButtonVariant.Float}
/>
</Tooltip>
{actions}
</div>
<div className="flex items-center gap-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
points: 100,
unlockedAt: null,
}),
],
unlockedCount: 0,
totalCount: 1,
totalPoints: 0,
isPending: false,
isError: false,
});

renderComponent();

expect(
screen.getByText('No achievements unlocked yet'),
).toBeInTheDocument();
expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument();
});
});
Loading
Loading