Skip to content

Commit ae8a1d2

Browse files
rebelchrisclaude
andauthored
feat(analytics): show earned achievements on the creator dashboard (#6699)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 536cf81 commit ae8a1d2

8 files changed

Lines changed: 669 additions & 38 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { gql } from 'graphql-request';
2+
import type { Connection } from './common';
3+
import { gqlClient } from './common';
4+
import {
5+
generateQueryKey,
6+
getNextPageParam,
7+
RequestKey,
8+
StaleTime,
9+
} from '../lib/query';
10+
import type { LoggedUser } from '../lib/user';
11+
12+
export enum CreatorAchievementType {
13+
CategoryRanking = 'category_ranking',
14+
PostUpvoteMilestone = 'post_upvote_milestone',
15+
CreatorImpressionMilestone = 'creator_impression_milestone',
16+
}
17+
18+
/**
19+
* One award a creator earned, as the server recorded it.
20+
*
21+
* Render from these fields rather than recomputing anything: the same record
22+
* is what a notification, an email and a share card will read, and a number
23+
* derived here would be the one place they could disagree.
24+
*/
25+
export interface CreatorAchievement {
26+
id: string;
27+
type: CreatorAchievementType;
28+
/**
29+
* When it was earned. For a backfilled award this is the historical date,
30+
* so it is what history should be ordered and labelled by.
31+
*/
32+
achievedAt: string;
33+
/**
34+
* `null` only for creator-wide types such as
35+
* `CreatorImpressionMilestone`. A per-article award whose article is gone is
36+
* withheld by the server rather than returned with a null `post`, so a null
37+
* here on a per-article type is a bug, not a state to design a card for.
38+
*/
39+
post: {
40+
id: string;
41+
title: string | null;
42+
commentsPermalink: string;
43+
} | null;
44+
/** `null` for awards that are not category-scoped. */
45+
keyword: {
46+
value: string;
47+
flags: { title: string | null } | null;
48+
} | null;
49+
periodStart: string | null;
50+
periodEnd: string | null;
51+
/** What the award claims, for milestones — not the creator's real total. */
52+
threshold: number | null;
53+
/** 1-based placement, for ranking awards. */
54+
rank: number | null;
55+
/**
56+
* The measured value behind a milestone.
57+
*
58+
* The creator's own analytics. Fine on their dashboard; keep it off
59+
* anything shared publicly, which is what `threshold` is for.
60+
*/
61+
measuredValue: number | null;
62+
/** A public page the award can be verified on, when it has one. */
63+
evidenceUrl: string | null;
64+
/**
65+
* Backfilled from history rather than earned live. Shown like any other
66+
* award; it just never triggered a notification.
67+
*/
68+
isHistorical: boolean;
69+
}
70+
71+
export const CREATOR_ACHIEVEMENT_FRAGMENT = gql`
72+
fragment CreatorAchievementFragment on CreatorAchievement {
73+
id
74+
type
75+
achievedAt
76+
periodStart
77+
periodEnd
78+
threshold
79+
rank
80+
measuredValue
81+
evidenceUrl
82+
isHistorical
83+
post {
84+
id
85+
title
86+
commentsPermalink
87+
}
88+
keyword {
89+
value
90+
flags {
91+
title
92+
}
93+
}
94+
}
95+
`;
96+
97+
export const CREATOR_ACHIEVEMENTS_QUERY = gql`
98+
query CreatorAchievements($after: String, $first: Int) {
99+
creatorAchievements(after: $after, first: $first) {
100+
pageInfo {
101+
hasNextPage
102+
endCursor
103+
}
104+
edges {
105+
cursor
106+
node {
107+
...CreatorAchievementFragment
108+
}
109+
}
110+
}
111+
}
112+
${CREATOR_ACHIEVEMENT_FRAGMENT}
113+
`;
114+
115+
export const creatorAchievementsPageSize = 20;
116+
117+
/** Spread into `useInfiniteQuery`; the server caps `first` at 50. */
118+
export const creatorAchievementsQueryOptions = ({
119+
user,
120+
first = creatorAchievementsPageSize,
121+
}: {
122+
user: Pick<LoggedUser, 'id'> | null | undefined;
123+
first?: number;
124+
}) => ({
125+
queryKey: generateQueryKey(
126+
RequestKey.CreatorAchievements,
127+
user ?? undefined,
128+
{ first },
129+
),
130+
queryFn: async ({ pageParam }: { pageParam?: string }) => {
131+
const { creatorAchievements } = await gqlClient.request<{
132+
creatorAchievements: Connection<CreatorAchievement>;
133+
}>(CREATOR_ACHIEVEMENTS_QUERY, { after: pageParam, first });
134+
135+
return creatorAchievements;
136+
},
137+
initialPageParam: '',
138+
getNextPageParam: (lastPage: Connection<CreatorAchievement>) =>
139+
getNextPageParam(lastPage?.pageInfo),
140+
enabled: !!user,
141+
staleTime: StaleTime.Default,
142+
});

‎packages/shared/src/lib/dateFormat.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,21 @@ export const getReadHistoryDateFormat = (currentDate: Date): string => {
220220
return `${dayOfTheWeek}, ${dayOfTheMonth} ${month}${year}`;
221221
};
222222

223-
export const getTopReaderBadgeDateFormat = (date: string | Date): string => {
223+
export const getTopReaderBadgeDateFormat = (
224+
date: string | Date,
225+
options?: {
226+
/**
227+
* Format on the UTC calendar rather than the viewer's. For a month that
228+
* was cut in UTC — a monthly ranking period, say — the local calendar
229+
* shifts the label by a month for anyone far enough east or west.
230+
*/
231+
utc?: boolean;
232+
},
233+
): string => {
224234
return new Date(date).toLocaleString('en-US', {
225235
year: 'numeric',
226236
month: 'long',
237+
...(options?.utc && { timeZone: 'UTC' }),
227238
});
228239
};
229240

‎packages/shared/src/lib/query.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export enum RequestKey {
243243
UserPostsAnalytics = 'user_posts_analytics',
244244
UserPostsAnalyticsHistory = 'user_posts_analytics_history',
245245
UserPostsWithAnalytics = 'user_posts_with_analytics',
246+
CreatorAchievements = 'creator_achievements',
246247
CreatorPerformance = 'creator_performance',
247248
CreatorPostPerformance = 'creator_post_performance',
248249
SquadAnalytics = 'squad_analytics',
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { CreatorAchievement } from '@dailydotdev/shared/src/graphql/creatorAchievements';
2+
import { CreatorAchievementType } from '@dailydotdev/shared/src/graphql/creatorAchievements';
3+
import {
4+
achievementCategoryLabel,
5+
achievementPeriodLabel,
6+
achievementSubtitle,
7+
achievementTitle,
8+
describableAchievements,
9+
} from '../components/analytics/creator/achievements';
10+
11+
const achievement = (
12+
partial: Partial<CreatorAchievement> = {},
13+
): CreatorAchievement => ({
14+
id: 'ca1',
15+
type: CreatorAchievementType.CategoryRanking,
16+
achievedAt: '2026-09-01T00:00:00.000Z',
17+
post: {
18+
id: 'p1',
19+
title: 'Shipping a design system',
20+
commentsPermalink: 'http://localhost:5002/posts/p1',
21+
},
22+
keyword: { value: 'webdev', flags: { title: 'Web Development' } },
23+
periodStart: '2026-08-01T00:00:00.000Z',
24+
periodEnd: '2026-09-01T00:00:00.000Z',
25+
threshold: null,
26+
rank: 2,
27+
measuredValue: null,
28+
evidenceUrl: 'https://daily.dev/tags/webdev/best-of/2026/08',
29+
isHistorical: false,
30+
...partial,
31+
});
32+
33+
describe('achievementCategoryLabel', () => {
34+
it('should prefer the display title over the raw keyword', () => {
35+
expect(achievementCategoryLabel(achievement())).toEqual('Web Development');
36+
});
37+
38+
it('should fall back to the keyword when it has no title', () => {
39+
expect(
40+
achievementCategoryLabel(
41+
achievement({ keyword: { value: 'rust', flags: null } }),
42+
),
43+
).toEqual('rust');
44+
});
45+
});
46+
47+
describe('achievementPeriodLabel', () => {
48+
it('should name the month the period starts in, in UTC', () => {
49+
expect(achievementPeriodLabel(achievement())).toEqual('August 2026');
50+
});
51+
52+
it('should have nothing to say for a threshold award', () => {
53+
expect(
54+
achievementPeriodLabel(achievement({ periodStart: null })),
55+
).toBeNull();
56+
});
57+
});
58+
59+
describe('achievementTitle', () => {
60+
it('should state the placement and category for a ranking', () => {
61+
expect(achievementTitle(achievement())).toEqual('#2 in Web Development');
62+
});
63+
64+
it('should state the threshold, not the measured value, for a milestone', () => {
65+
expect(
66+
achievementTitle(
67+
achievement({
68+
type: CreatorAchievementType.PostUpvoteMilestone,
69+
rank: null,
70+
threshold: 100,
71+
measuredValue: 137,
72+
}),
73+
),
74+
).toEqual('100 upvotes');
75+
expect(
76+
achievementTitle(
77+
achievement({
78+
type: CreatorAchievementType.CreatorImpressionMilestone,
79+
rank: null,
80+
threshold: 100000,
81+
measuredValue: 104233,
82+
}),
83+
),
84+
).toEqual('100K impressions');
85+
});
86+
87+
it('should say nothing for an award it cannot describe', () => {
88+
expect(achievementTitle(achievement({ rank: null }))).toBeNull();
89+
expect(
90+
achievementTitle(
91+
achievement({
92+
type: 'unknown_future_type' as CreatorAchievementType,
93+
}),
94+
),
95+
).toBeNull();
96+
});
97+
});
98+
99+
describe('achievementSubtitle', () => {
100+
it('should caption a ranking with its month', () => {
101+
expect(achievementSubtitle(achievement())).toEqual('August 2026');
102+
});
103+
104+
it('should caption an article milestone with the article', () => {
105+
expect(
106+
achievementSubtitle(
107+
achievement({
108+
type: CreatorAchievementType.PostUpvoteMilestone,
109+
periodStart: null,
110+
}),
111+
),
112+
).toEqual('Shipping a design system');
113+
});
114+
});
115+
116+
describe('describableAchievements', () => {
117+
it('should keep every award it can describe', () => {
118+
const ranking = achievement();
119+
const milestone = achievement({
120+
id: 'ca3',
121+
type: CreatorAchievementType.CreatorImpressionMilestone,
122+
rank: null,
123+
threshold: 100000,
124+
post: null,
125+
keyword: null,
126+
periodStart: null,
127+
periodEnd: null,
128+
});
129+
130+
expect(describableAchievements([ranking, milestone])).toEqual([
131+
ranking,
132+
milestone,
133+
]);
134+
});
135+
136+
it('should drop awards this build cannot describe', () => {
137+
const known = achievement();
138+
const unknown = achievement({
139+
id: 'ca2',
140+
type: 'unknown_future_type' as CreatorAchievementType,
141+
});
142+
143+
expect(describableAchievements([known, unknown])).toEqual([known]);
144+
});
145+
});

0 commit comments

Comments
 (0)