From 911edb10d2f2f1cf93796268e4e513faa3d0fb40 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Sun, 22 Mar 2026 16:29:56 +0100 Subject: [PATCH 1/9] feat: hub page --- packages/shared/src/components/DataTile.tsx | 12 +- .../src/components/quest/QuestButton.spec.tsx | 23 +- .../src/components/quest/QuestButton.tsx | 16 +- packages/shared/src/graphql/fragments.ts | 5 + packages/shared/src/graphql/leaderboard.ts | 12 + packages/shared/src/graphql/quests.ts | 3 + packages/shared/src/graphql/users.spec.ts | 19 + packages/shared/src/graphql/users.ts | 5 - packages/webapp/lib/hub.spec.ts | 269 ++++ packages/webapp/lib/hub.ts | 316 +++++ packages/webapp/pages/hub/index.tsx | 1131 +++++++++++++++++ 11 files changed, 1797 insertions(+), 14 deletions(-) create mode 100644 packages/shared/src/graphql/users.spec.ts create mode 100644 packages/webapp/lib/hub.spec.ts create mode 100644 packages/webapp/lib/hub.ts create mode 100644 packages/webapp/pages/hub/index.tsx diff --git a/packages/shared/src/components/DataTile.tsx b/packages/shared/src/components/DataTile.tsx index 99fcc8b40a8..8a8468c65c4 100644 --- a/packages/shared/src/components/DataTile.tsx +++ b/packages/shared/src/components/DataTile.tsx @@ -9,10 +9,11 @@ import { formatDataTileValue } from '../lib/numberFormat'; interface DataTileProps { label: string; - value: number; + value: number | ReactNode; info?: string; icon?: ReactNode; subtitle?: ReactNode; + valueClassName?: string; className?: { container?: string; }; @@ -24,6 +25,7 @@ export const DataTile: React.FC = ({ info, icon, subtitle, + valueClassName, className, }) => { return ( @@ -43,8 +45,12 @@ export const DataTile: React.FC = ({ {icon} - - {formatDataTileValue(value)} + + {typeof value === 'number' ? formatDataTileValue(value) : value} {subtitle} diff --git a/packages/shared/src/components/quest/QuestButton.spec.tsx b/packages/shared/src/components/quest/QuestButton.spec.tsx index f80b5484fa6..c7bce447832 100644 --- a/packages/shared/src/components/quest/QuestButton.spec.tsx +++ b/packages/shared/src/components/quest/QuestButton.spec.tsx @@ -57,6 +57,16 @@ jest.mock('../../hooks/usePlusSubscription', () => ({ usePlusSubscription: jest.fn(), })); +jest.mock('../../lib/constants', () => { + const actual = jest.requireActual('../../lib/constants'); + + return { + ...actual, + plusUrl: 'https://app.daily.dev/plus', + webappUrl: 'https://app.daily.dev/', + }; +}); + jest.mock('next/router', () => ({ useRouter: jest.fn(), })); @@ -178,6 +188,7 @@ const questDashboard = { xpInLevel: 250, xpToNextLevel: 150, }, + currentStreak: 0, daily: { regular: [ { @@ -336,7 +347,7 @@ describe('QuestButton', () => { await userEvent.click(destinationButton); - expect(mockPush).toHaveBeenCalledWith('/'); + expect(mockPush).toHaveBeenCalledWith('https://app.daily.dev/'); }); it('should route hot take quests to the hot takes modal path', async () => { @@ -382,7 +393,9 @@ describe('QuestButton', () => { await screen.findByRole('button', { name: 'Go to Hot takes' }), ); - expect(mockPush).toHaveBeenCalledWith('/?openModal=hottakes'); + expect(mockPush).toHaveBeenCalledWith( + 'https://app.daily.dev/?openModal=hottakes', + ); }); it('should route share-post quests back to the feed', async () => { @@ -428,7 +441,7 @@ describe('QuestButton', () => { await screen.findByRole('button', { name: 'Go to Feed' }), ); - expect(mockPush).toHaveBeenCalledWith('/'); + expect(mockPush).toHaveBeenCalledWith('https://app.daily.dev/'); }); it('should label user follow quests as leaderboards', async () => { @@ -562,7 +575,9 @@ describe('QuestButton', () => { await screen.findByRole('button', { name: 'Go to Create post' }), ); - expect(mockPush).toHaveBeenCalledWith('/squads/create'); + expect(mockPush).toHaveBeenCalledWith( + 'https://app.daily.dev/squads/create', + ); }); it('should log when opening the quest dropdown', async () => { diff --git a/packages/shared/src/components/quest/QuestButton.tsx b/packages/shared/src/components/quest/QuestButton.tsx index a89723f4097..a646a1c7750 100644 --- a/packages/shared/src/components/quest/QuestButton.tsx +++ b/packages/shared/src/components/quest/QuestButton.tsx @@ -45,7 +45,7 @@ import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLogContext } from '../../contexts/LogContext'; import { useSettingsContext } from '../../contexts/SettingsContext'; -import { plusUrl } from '../../lib/constants'; +import { plusUrl, webappUrl } from '../../lib/constants'; import { generateQueryKey, RequestKey } from '../../lib/query'; import useSubscription from '../../hooks/useSubscription'; import { ProgressBar } from '../fields/ProgressBar'; @@ -262,6 +262,18 @@ type QuestDestination = { const HOT_TAKES_MODAL_PATH = '/?openModal=hottakes'; +const getQuestDestinationHref = (path: string): string => { + if (/^https?:\/\//.test(path)) { + return path; + } + + if (path === '/') { + return webappUrl; + } + + return `${webappUrl}${path.replace(/^\//, '')}`; +}; + const getQuestDestination = ( quest: UserQuest['quest'], ): QuestDestination | null => { @@ -1578,7 +1590,7 @@ export const QuestButton = ({ const handleDestinationClick = useCallback( async (destination: QuestDestination) => { setIsOpen(false); - await router.push(destination.path); + await router.push(getQuestDestinationHref(destination.path)); }, [router], ); diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index d9320c7d0ce..e98422e3dda 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -523,6 +523,11 @@ export const TOP_READER_BADGE_FRAGMENT = gql` issuedAt image total + user { + name + username + image + } keyword { value flags { diff --git a/packages/shared/src/graphql/leaderboard.ts b/packages/shared/src/graphql/leaderboard.ts index 694f1162838..e7cf41e42ed 100644 --- a/packages/shared/src/graphql/leaderboard.ts +++ b/packages/shared/src/graphql/leaderboard.ts @@ -56,6 +56,7 @@ export enum LeaderboardType { MostReadingDays = 'mostReadingDays', MostVerifiedUsers = 'mostVerifiedUsers', MostAchievementPoints = 'mostAchievementPoints', + MostQuestsCompleted = 'mostQuestsCompleted', HighestLevel = 'highestLevel', } @@ -68,6 +69,7 @@ export const leaderboardTypeToTitle: Record = { [LeaderboardType.MostReadingDays]: 'Most reading days', [LeaderboardType.MostVerifiedUsers]: 'Most verified employees', [LeaderboardType.MostAchievementPoints]: 'Most achievement points', + [LeaderboardType.MostQuestsCompleted]: 'Most quests completed', [LeaderboardType.HighestLevel]: 'Highest level', }; @@ -137,6 +139,15 @@ export const MOST_ACHIEVEMENT_POINTS_QUERY = gql` ${LEADERBOARD_FRAGMENT} `; +export const MOST_QUESTS_COMPLETED_QUERY = gql` + query MostQuestsCompleted($limit: Int = 100) { + mostQuestsCompleted(limit: $limit) { + ...LeaderboardFragment + } + } + ${LEADERBOARD_FRAGMENT} +`; + export const HIGHEST_LEVEL_QUERY = gql` query HighestLevel($limit: Int = 100) { highestLevel(limit: $limit) { @@ -172,6 +183,7 @@ export const leaderboardQueries: Record = { [LeaderboardType.MostReferrals]: MOST_REFERRALS_QUERY, [LeaderboardType.MostReadingDays]: MOST_READING_DAYS_QUERY, [LeaderboardType.MostAchievementPoints]: MOST_ACHIEVEMENT_POINTS_QUERY, + [LeaderboardType.MostQuestsCompleted]: MOST_QUESTS_COMPLETED_QUERY, [LeaderboardType.HighestLevel]: HIGHEST_LEVEL_QUERY, [LeaderboardType.MostVerifiedUsers]: MOST_VERIFIED_USERS_QUERY, }; diff --git a/packages/shared/src/graphql/quests.ts b/packages/shared/src/graphql/quests.ts index 84d53f00293..936b8d61fe2 100644 --- a/packages/shared/src/graphql/quests.ts +++ b/packages/shared/src/graphql/quests.ts @@ -59,6 +59,7 @@ export interface QuestLevel { export interface QuestDashboard { level: QuestLevel; + currentStreak: number; daily: QuestBucket; weekly: QuestBucket; } @@ -107,6 +108,7 @@ export const QUEST_DASHBOARD_QUERY = gql` xpInLevel xpToNextLevel } + currentStreak daily { regular { userQuestId @@ -212,6 +214,7 @@ export const CLAIM_QUEST_REWARD_MUTATION = gql` xpInLevel xpToNextLevel } + currentStreak daily { regular { userQuestId diff --git a/packages/shared/src/graphql/users.spec.ts b/packages/shared/src/graphql/users.spec.ts new file mode 100644 index 00000000000..00d2b0554ff --- /dev/null +++ b/packages/shared/src/graphql/users.spec.ts @@ -0,0 +1,19 @@ +import { TOP_READER_BADGE, TOP_READER_BADGE_BY_ID } from './users'; + +describe('top reader badge queries', () => { + it('includes the badge owner in the list query', () => { + expect(TOP_READER_BADGE).toContain('topReaderBadge(limit: $limit, userId: $userId)'); + expect(TOP_READER_BADGE).toContain('user {'); + expect(TOP_READER_BADGE).toContain('name'); + expect(TOP_READER_BADGE).toContain('username'); + expect(TOP_READER_BADGE).toContain('image'); + }); + + it('includes the badge owner in the by-id query', () => { + expect(TOP_READER_BADGE_BY_ID).toContain('topReaderBadgeById(id: $id)'); + expect(TOP_READER_BADGE_BY_ID).toContain('user {'); + expect(TOP_READER_BADGE_BY_ID).toContain('name'); + expect(TOP_READER_BADGE_BY_ID).toContain('username'); + expect(TOP_READER_BADGE_BY_ID).toContain('image'); + }); +}); diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index 2b42f39bf18..59f0fda04b0 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -794,11 +794,6 @@ export const TOP_READER_BADGE_BY_ID = gql` query TopReaderBadgeById($id: ID!) { topReaderBadgeById(id: $id) { ...TopReader - user { - name - username - image - } } } diff --git a/packages/webapp/lib/hub.spec.ts b/packages/webapp/lib/hub.spec.ts new file mode 100644 index 00000000000..01aa5245838 --- /dev/null +++ b/packages/webapp/lib/hub.spec.ts @@ -0,0 +1,269 @@ +import type { + QuestDashboard, + UserQuest, +} from '@dailydotdev/shared/src/graphql/quests'; +import type { UserProductSummary } from '@dailydotdev/shared/src/graphql/njord'; +import { + QuestRewardType, + QuestStatus, + QuestType, +} from '@dailydotdev/shared/src/graphql/quests'; +import type { TopReader } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; +import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { AchievementType } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { + getAchievementSummary, + getAwardSummary, + getBadgeSummary, + getQuestSummary, + getTopReaderTopicLabel, +} from './hub'; + +const createQuest = ( + overrides: Partial & { questId: string; name: string }, +): UserQuest => ({ + userQuestId: `${overrides.questId}-user`, + rotationId: `${overrides.questId}-rotation`, + progress: 0, + status: QuestStatus.InProgress, + completedAt: null, + claimedAt: null, + locked: false, + claimable: false, + rewards: [{ type: QuestRewardType.Xp, amount: 50 }], + quest: { + id: overrides.questId, + name: overrides.name, + description: `${overrides.name} description`, + type: QuestType.Daily, + eventType: 'read_post', + targetCount: 5, + }, + ...overrides, +}); + +const createAchievement = ( + overrides: Partial & { + id: string; + name: string; + points?: number; + }, +): UserAchievement => ({ + achievement: { + id: overrides.id, + name: overrides.name, + description: `${overrides.name} description`, + image: 'https://daily.dev/achievement.png', + type: AchievementType.Milestone, + criteria: { targetCount: 10 }, + points: overrides.points ?? 100, + rarity: 10, + unit: 'posts', + }, + progress: 0, + unlockedAt: null, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + ...overrides, +}); + +describe('hub helpers', () => { + it('builds quest summaries and prioritizes claimable quests', () => { + const dashboard: QuestDashboard = { + level: { + level: 7, + totalXp: 1450, + xpInLevel: 150, + xpToNextLevel: 250, + }, + currentStreak: 4, + daily: { + regular: [ + createQuest({ + questId: 'daily-claimable', + name: 'Claimable quest', + progress: 5, + claimable: true, + status: QuestStatus.Completed, + }), + createQuest({ + questId: 'daily-progress', + name: 'Almost there', + progress: 4, + }), + ], + plus: [ + createQuest({ + questId: 'daily-plus', + name: 'Locked plus quest', + locked: true, + progress: 5, + status: QuestStatus.Completed, + }), + ], + }, + weekly: { + regular: [ + createQuest({ + questId: 'weekly-claimed', + name: 'Already claimed', + claimable: false, + status: QuestStatus.Claimed, + claimedAt: new Date('2025-02-01T00:00:00.000Z'), + }), + ], + plus: [], + }, + }; + + const summary = getQuestSummary(dashboard); + + expect(summary.totalCount).toBe(4); + expect(summary.completedCount).toBe(3); + expect(summary.claimableCount).toBe(1); + expect(summary.lockedCount).toBe(1); + expect(summary.daily.completionRate).toBe(67); + expect(summary.weekly.completionRate).toBe(100); + expect(summary.highlightedQuest?.quest.id).toBe('daily-claimable'); + }); + + it('builds achievement summaries with deduped featured cards', () => { + const tracked = createAchievement({ + id: 'tracked', + name: 'Tracked', + progress: 9, + points: 50, + }); + const rareUnlocked = createAchievement({ + id: 'rare', + name: 'Rare unlocked', + unlockedAt: '2025-03-01T00:00:00.000Z', + points: 200, + achievement: { + id: 'rare', + name: 'Rare unlocked', + description: 'Rare unlocked description', + image: 'https://daily.dev/rare.png', + type: AchievementType.Milestone, + criteria: { targetCount: 10 }, + points: 200, + rarity: 1, + unit: 'posts', + }, + }); + const latestUnlocked = createAchievement({ + id: 'latest', + name: 'Latest unlocked', + unlockedAt: '2025-03-10T00:00:00.000Z', + points: 120, + }); + + const summary = getAchievementSummary( + [tracked, rareUnlocked, latestUnlocked], + tracked, + ); + + expect(summary.unlockedCount).toBe(2); + expect(summary.totalCount).toBe(3); + expect(summary.totalPoints).toBe(320); + expect(summary.nextToUnlock?.achievement.id).toBe('tracked'); + expect(summary.latestUnlocked?.achievement.id).toBe('latest'); + expect(summary.rarestUnlocked?.achievement.id).toBe('rare'); + expect( + summary.featuredAchievements.map((item) => item.achievement.id), + ).toEqual(['tracked', 'latest', 'rare']); + }); + + it('builds badge summaries from recent top-reader badges', () => { + const badges: TopReader[] = [ + { + id: 'badge-1', + total: 6, + issuedAt: '2025-01-02T00:00:00.000Z', + image: 'https://daily.dev/badge-1.png', + user: { + name: 'Taylor', + image: 'https://daily.dev/taylor.png', + username: 'taylor', + }, + keyword: { + value: 'react', + flags: { title: 'React' }, + }, + }, + { + id: 'badge-2', + total: 6, + issuedAt: '2025-03-02T00:00:00.000Z', + image: 'https://daily.dev/badge-2.png', + user: { + name: 'Taylor', + image: 'https://daily.dev/taylor.png', + username: 'taylor', + }, + keyword: { + value: 'typescript', + flags: { title: 'TypeScript' }, + }, + }, + { + id: 'badge-3', + total: 6, + issuedAt: '2025-02-02T00:00:00.000Z', + image: 'https://daily.dev/badge-3.png', + user: { + name: 'Taylor', + image: 'https://daily.dev/taylor.png', + username: 'taylor', + }, + keyword: { + value: 'react', + flags: { title: 'React' }, + }, + }, + ]; + + const summary = getBadgeSummary(badges); + + expect(summary.totalBadges).toBe(6); + expect(summary.uniqueTopics).toBe(2); + expect(summary.latestBadge?.id).toBe('badge-2'); + expect(summary.mostEarnedBadge?.id).toBe('badge-3'); + expect(summary.mostEarnedBadgeCount).toBe(2); + expect(getTopReaderTopicLabel(badges[0])).toBe('React'); + }); + + it('builds award summaries and orders trophies by count', () => { + const awards: UserProductSummary[] = [ + { + id: 'award-1', + name: 'Supportive', + image: 'https://daily.dev/award-1.png', + count: 2, + }, + { + id: 'award-2', + name: 'Insightful', + image: 'https://daily.dev/award-2.png', + count: 5, + }, + { + id: 'award-3', + name: 'Creative', + image: 'https://daily.dev/award-3.png', + count: 1, + }, + ]; + + const summary = getAwardSummary(awards); + + expect(summary.totalAwards).toBe(8); + expect(summary.uniqueAwards).toBe(3); + expect(summary.favoriteAward?.id).toBe('award-2'); + expect(summary.awards.map((award) => award.id)).toEqual([ + 'award-2', + 'award-1', + 'award-3', + ]); + }); +}); diff --git a/packages/webapp/lib/hub.ts b/packages/webapp/lib/hub.ts new file mode 100644 index 00000000000..b9f8e34a757 --- /dev/null +++ b/packages/webapp/lib/hub.ts @@ -0,0 +1,316 @@ +import type { TopReader } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; +import type { UserProductSummary } from '@dailydotdev/shared/src/graphql/njord'; +import type { + QuestBucket, + QuestDashboard, + UserQuest, +} from '@dailydotdev/shared/src/graphql/quests'; +import { QuestStatus } from '@dailydotdev/shared/src/graphql/quests'; +import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { getTargetCount } from '@dailydotdev/shared/src/graphql/user/achievements'; + +const getDateValue = (value?: string | Date | null): number => { + if (!value) { + return 0; + } + + return new Date(value).getTime() || 0; +}; + +const isQuestComplete = (quest: UserQuest): boolean => + quest.claimable || + quest.status === QuestStatus.Completed || + quest.status === QuestStatus.Claimed; + +const getQuestProgressRatio = (quest: UserQuest): number => { + const target = Math.max(quest.quest.targetCount, 1); + return Math.min(quest.progress / target, 1); +}; + +const getQuestRewardTotal = (quest: UserQuest): number => + quest.rewards.reduce((total, reward) => total + reward.amount, 0); + +export type HubQuestBucketSummary = { + all: UserQuest[]; + regular: UserQuest[]; + plus: UserQuest[]; + totalCount: number; + completedCount: number; + claimableCount: number; + inProgressCount: number; + lockedCount: number; + completionRate: number; +}; + +export type HubQuestSummary = HubQuestBucketSummary & { + daily: HubQuestBucketSummary; + weekly: HubQuestBucketSummary; + highlightedQuest: UserQuest | null; +}; + +const getQuestBucketSummary = (bucket?: QuestBucket): HubQuestBucketSummary => { + const regular = bucket?.regular ?? []; + const plus = bucket?.plus ?? []; + const all = [...regular, ...plus]; + const completedCount = all.filter(isQuestComplete).length; + const claimableCount = all.filter((quest) => quest.claimable).length; + const lockedCount = all.filter((quest) => quest.locked).length; + const totalCount = all.length; + + return { + all, + regular, + plus, + totalCount, + completedCount, + claimableCount, + lockedCount, + inProgressCount: Math.max(totalCount - completedCount, 0), + completionRate: + totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0, + }; +}; + +const getHighlightedQuest = (quests: UserQuest[]): UserQuest | null => { + if (quests.length === 0) { + return null; + } + + return [...quests].sort((left, right) => { + if (left.claimable !== right.claimable) { + return left.claimable ? -1 : 1; + } + + if (left.locked !== right.locked) { + return left.locked ? 1 : -1; + } + + const ratioDifference = + getQuestProgressRatio(right) - getQuestProgressRatio(left); + + if (ratioDifference !== 0) { + return ratioDifference; + } + + return getQuestRewardTotal(right) - getQuestRewardTotal(left); + })[0]; +}; + +export const getQuestSummary = ( + dashboard?: QuestDashboard, +): HubQuestSummary => { + const daily = getQuestBucketSummary(dashboard?.daily); + const weekly = getQuestBucketSummary(dashboard?.weekly); + const all = [...daily.all, ...weekly.all]; + const completedCount = all.filter(isQuestComplete).length; + const claimableCount = all.filter((quest) => quest.claimable).length; + const lockedCount = all.filter((quest) => quest.locked).length; + const totalCount = all.length; + + return { + all, + totalCount, + completedCount, + claimableCount, + lockedCount, + inProgressCount: Math.max(totalCount - completedCount, 0), + completionRate: + totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0, + highlightedQuest: getHighlightedQuest(all), + daily, + weekly, + regular: [...daily.regular, ...weekly.regular], + plus: [...daily.plus, ...weekly.plus], + }; +}; + +const getAchievementProgressRatio = (achievement: UserAchievement): number => { + const target = Math.max(getTargetCount(achievement.achievement), 1); + return Math.min(achievement.progress / target, 1); +}; + +const dedupeAchievements = ( + achievements: Array, +): UserAchievement[] => { + const seen = new Set(); + + return achievements.filter((achievement): achievement is UserAchievement => { + if (!achievement) { + return false; + } + + if (seen.has(achievement.achievement.id)) { + return false; + } + + seen.add(achievement.achievement.id); + return true; + }); +}; + +export type HubAchievementSummary = { + unlockedCount: number; + totalCount: number; + totalPoints: number; + latestUnlocked: UserAchievement | null; + rarestUnlocked: UserAchievement | null; + nextToUnlock: UserAchievement | null; + featuredAchievements: UserAchievement[]; +}; + +export const getAchievementSummary = ( + achievements?: UserAchievement[], + trackedAchievement?: UserAchievement | null, +): HubAchievementSummary => { + const allAchievements = achievements ?? []; + const unlocked = allAchievements.filter( + (achievement) => achievement.unlockedAt !== null, + ); + const locked = allAchievements.filter( + (achievement) => achievement.unlockedAt === null, + ); + + const latestUnlocked = + [...unlocked].sort( + (left, right) => + getDateValue(right.unlockedAt) - getDateValue(left.unlockedAt), + )[0] ?? null; + + const rarestUnlocked = + [...unlocked].sort((left, right) => { + const leftRarity = left.achievement.rarity ?? Number.POSITIVE_INFINITY; + const rightRarity = right.achievement.rarity ?? Number.POSITIVE_INFINITY; + + if (leftRarity !== rightRarity) { + return leftRarity - rightRarity; + } + + return getDateValue(right.unlockedAt) - getDateValue(left.unlockedAt); + })[0] ?? null; + + const nextToUnlock = + [...locked].sort((left, right) => { + const progressDifference = + getAchievementProgressRatio(right) - getAchievementProgressRatio(left); + + if (progressDifference !== 0) { + return progressDifference; + } + + if (left.progress !== right.progress) { + return right.progress - left.progress; + } + + return right.achievement.points - left.achievement.points; + })[0] ?? null; + + const featuredAchievements = dedupeAchievements([ + trackedAchievement?.unlockedAt ? null : trackedAchievement ?? null, + nextToUnlock, + latestUnlocked, + rarestUnlocked, + ]); + + return { + unlockedCount: unlocked.length, + totalCount: allAchievements.length, + totalPoints: unlocked.reduce( + (total, achievement) => total + (achievement.achievement.points ?? 0), + 0, + ), + latestUnlocked, + rarestUnlocked, + nextToUnlock, + featuredAchievements, + }; +}; + +export const getTopReaderTopicLabel = ( + badge: Pick, +): string => badge.keyword.flags?.title || badge.keyword.value; + +const sortAwardsByCount = ( + awards: UserProductSummary[], +): UserProductSummary[] => { + return [...awards].sort((left, right) => { + if (left.count !== right.count) { + return right.count - left.count; + } + + return left.name.localeCompare(right.name); + }); +}; + +export type HubAwardSummary = { + awards: UserProductSummary[]; + totalAwards: number; + uniqueAwards: number; + favoriteAward: UserProductSummary | null; +}; + +export const getAwardSummary = ( + awards?: UserProductSummary[], +): HubAwardSummary => { + const allAwards = sortAwardsByCount(awards ?? []); + + return { + awards: allAwards, + totalAwards: allAwards.reduce((total, award) => total + award.count, 0), + uniqueAwards: allAwards.length, + favoriteAward: allAwards[0] ?? null, + }; +}; + +export type HubBadgeSummary = { + totalBadges: number; + uniqueTopics: number; + latestBadge: TopReader | null; + mostEarnedBadge: TopReader | null; + mostEarnedBadgeCount: number; +}; + +export const getBadgeSummary = (badges?: TopReader[]): HubBadgeSummary => { + const allBadges = badges ?? []; + const latestBadge = + [...allBadges].sort( + (left, right) => + getDateValue(right.issuedAt) - getDateValue(left.issuedAt), + )[0] ?? null; + const topicCounts = allBadges.reduce>((counts, badge) => { + const topic = getTopReaderTopicLabel(badge); + counts.set(topic, (counts.get(topic) ?? 0) + 1); + return counts; + }, new Map()); + const mostEarnedBadge = + [...allBadges].sort((left, right) => { + const countDifference = + (topicCounts.get(getTopReaderTopicLabel(right)) ?? 0) - + (topicCounts.get(getTopReaderTopicLabel(left)) ?? 0); + + if (countDifference !== 0) { + return countDifference; + } + + const issuedAtDifference = + getDateValue(right.issuedAt) - getDateValue(left.issuedAt); + + if (issuedAtDifference !== 0) { + return issuedAtDifference; + } + + return getTopReaderTopicLabel(left).localeCompare( + getTopReaderTopicLabel(right), + ); + })[0] ?? null; + const mostEarnedBadgeCount = mostEarnedBadge + ? topicCounts.get(getTopReaderTopicLabel(mostEarnedBadge)) ?? 0 + : 0; + + return { + totalBadges: allBadges[0]?.total ?? allBadges.length, + uniqueTopics: new Set(allBadges.map(getTopReaderTopicLabel)).size, + latestBadge, + mostEarnedBadge, + mostEarnedBadgeCount, + }; +}; diff --git a/packages/webapp/pages/hub/index.tsx b/packages/webapp/pages/hub/index.tsx new file mode 100644 index 00000000000..59258b8ef7f --- /dev/null +++ b/packages/webapp/pages/hub/index.tsx @@ -0,0 +1,1131 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { GetStaticPropsResult } from 'next'; +import type { NextSeoProps } from 'next-seo'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { + HIGHEST_REPUTATION_QUERY, + LeaderboardType, + MOST_QUESTS_COMPLETED_QUERY, +} from '@dailydotdev/shared/src/graphql/leaderboard'; +import { + ProductType, + userProductSummaryQueryOptions, +} from '@dailydotdev/shared/src/graphql/njord'; +import { getTargetCount } from '@dailydotdev/shared/src/graphql/user/achievements'; +import type { UserPostsAnalytics } from '@dailydotdev/shared/src/graphql/users'; +import { USER_POSTS_ANALYTICS_QUERY } from '@dailydotdev/shared/src/graphql/users'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useProfileAchievements } from '@dailydotdev/shared/src/hooks/profile/useProfileAchievements'; +import { useTrackedAchievement } from '@dailydotdev/shared/src/hooks/profile/useTrackedAchievement'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { useHasAccessToCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; +import { useQuestDashboard } from '@dailydotdev/shared/src/hooks/useQuestDashboard'; +import { useRequestProtocol } from '@dailydotdev/shared/src/hooks/useRequestProtocol'; +import { shouldShowAchievementTracker } from '@dailydotdev/shared/src/lib/achievements'; +import { + formatDate, + TimeFormatType, +} from '@dailydotdev/shared/src/lib/dateFormat'; +import { + achievementTrackingWidgetFeature, + questsFeature, +} from '@dailydotdev/shared/src/lib/featureManagement'; +import { fetchTopReaders } from '@dailydotdev/shared/src/lib/topReader'; +import { getFirstName } from '@dailydotdev/shared/src/lib/user'; +import { + generateQueryKey, + RequestKey, + StaleTime, +} from '@dailydotdev/shared/src/lib/query'; +import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common'; +import { + Divider, + ResponsivePageContainer, + pageBorders, +} from '@dailydotdev/shared/src/components/utilities'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; +import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { AchievementCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementCard'; +import { TopReaderBadge } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; +import { + QuestLevelProgressCircle, + getQuestLevelProgress, +} from '@dailydotdev/shared/src/components/quest/QuestLevelProgressCircle'; +import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; +import { UserTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + ArrowIcon, + CoreIcon, + MedalBadgeIcon, + PinIcon, + ReputationIcon, + SparkleIcon, + StarIcon, + TrendingIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { getPageSeoTitles } from '../../components/layouts/utils'; +import ProtectedPage from '../../components/ProtectedPage'; +import { defaultOpenGraph } from '../../next-seo'; +import { + getAchievementSummary, + getAwardSummary, + getBadgeSummary, + getQuestSummary, + getTopReaderTopicLabel, +} from '../../lib/hub'; + +type HubPageProps = { + highestReputation: UserLeaderboard[]; + mostQuestsCompleted: UserLeaderboard[]; +}; + +type SectionProps = { + title: string; + description: string; + action?: ReactElement; +}; + +const dividerClassName = 'bg-border-subtlest-tertiary'; +const leaderboardLimit = 3; + +const SectionHeader = ({ + title, + description, + action, +}: SectionProps): ReactElement => { + return ( +
+
+ + {title} + + + {description} + +
+ {action} +
+ ); +}; + +const EmptyStateCard = ({ + title, + description, +}: { + title: string; + description: string; +}): ReactElement => { + return ( +
+ + {title} + + + {description} + +
+ ); +}; + +const StatPill = ({ + label, + value, +}: { + label: string; + value: string; +}): ReactElement => ( +
+ + {label} + + + {value} + +
+); + +const TrophyCard = ({ + name, + image, + count, +}: { + name: string; + image: string; + count: number; +}): ReactElement => { + return ( + +
+ + + x{count.toLocaleString()} + +
+
+ ); +}; + +const seoTitles = getPageSeoTitles('Gamification hub'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, + description: + 'Track your quests, XP, achievements, badges, awards, and community standing in one place.', + nofollow: true, + noindex: true, +}; + +function HubPage({ + highestReputation, + mostQuestsCompleted, +}: HubPageProps): ReactElement { + const { user } = useAuthContext(); + const { requestMethod } = useRequestProtocol(); + const { value: isQuestsFeatureEnabled } = useConditionalFeature({ + feature: questsFeature, + shouldEvaluate: !!user, + }); + const { value: isAchievementTrackingEnabled } = useConditionalFeature({ + feature: achievementTrackingWidgetFeature, + shouldEvaluate: !!user, + }); + const { data: questDashboard, isPending: isQuestPending } = + useQuestDashboard(); + const questSummary = useMemo( + () => getQuestSummary(questDashboard), + [questDashboard], + ); + const { + achievements, + unlockedCount, + totalCount, + isPending: isAchievementsPending, + } = useProfileAchievements(user); + const shouldTrackAchievements = shouldShowAchievementTracker({ + isExperimentEnabled: isAchievementTrackingEnabled === true, + unlockedCount, + totalCount, + }); + const trackedAchievementState = useTrackedAchievement( + undefined, + shouldTrackAchievements, + ); + const achievementSummary = useMemo( + () => + getAchievementSummary( + achievements, + trackedAchievementState.trackedAchievement, + ), + [achievements, trackedAchievementState.trackedAchievement], + ); + const hasCoresAccess = useHasAccessToCores(); + + const analyticsQueryKey = generateQueryKey( + RequestKey.UserPostsAnalytics, + user, + ); + const { data: analytics } = useQuery({ + queryKey: analyticsQueryKey, + queryFn: async () => { + const result = await requestMethod<{ + userPostsAnalytics: UserPostsAnalytics; + }>(USER_POSTS_ANALYTICS_QUERY); + + return result.userPostsAnalytics; + }, + staleTime: StaleTime.Default, + enabled: !!user, + }); + + const topReaderQueryKey = generateQueryKey( + RequestKey.TopReaderBadge, + user, + 'hub:100', + ); + const { data: topReaderBadges = [], isPending: isBadgesPending } = useQuery({ + queryKey: topReaderQueryKey, + queryFn: () => { + if (!user?.id) { + throw new Error('Cannot load top reader badges without a user id.'); + } + + return fetchTopReaders(100, user.id); + }, + staleTime: StaleTime.OneHour, + enabled: !!user?.id, + }); + const badgeCaseBadges = useMemo( + () => topReaderBadges.slice(0, 3), + [topReaderBadges], + ); + const badgeSummary = useMemo( + () => getBadgeSummary(topReaderBadges), + [topReaderBadges], + ); + const { + data: awardProducts = [], + isPending: isAwardsPending, + error: awardsError, + } = useQuery({ + ...userProductSummaryQueryOptions({ + userId: user?.id ?? '', + limit: 100, + type: ProductType.Award, + }), + enabled: !!user?.id && hasCoresAccess, + }); + const awardSummary = useMemo( + () => getAwardSummary(awardProducts), + [awardProducts], + ); + + const levelProgress = questDashboard + ? getQuestLevelProgress(questDashboard.level) + : 0; + const firstName = user?.name ? getFirstName(user.name) : 'there'; + const { featuredAchievements } = achievementSummary; + const [featuredAchievement] = featuredAchievements; + const { highlightedQuest } = questSummary; + let mostEarnedBadgeSubtitle = + 'Read in a topic more than once to see a favorite'; + + if (badgeSummary.mostEarnedBadge) { + mostEarnedBadgeSubtitle = + badgeSummary.mostEarnedBadgeCount === 1 + ? 'earned once' + : `earned ${badgeSummary.mostEarnedBadgeCount.toLocaleString()} times`; + } + + const isFeaturedAchievementTrackable = + shouldTrackAchievements && + !!featuredAchievement && + !featuredAchievement.unlockedAt; + const isFeaturedAchievementTracked = + isFeaturedAchievementTrackable && + trackedAchievementState.trackedAchievement?.achievement.id === + featuredAchievement.achievement.id; + const isFeaturedAchievementTrackingPending = + trackedAchievementState.isPending || + trackedAchievementState.isTrackPending || + trackedAchievementState.isUntrackPending; + + const handleFeaturedAchievementTracking = async () => { + if (!isFeaturedAchievementTrackable || !featuredAchievement) { + return; + } + + if (isFeaturedAchievementTracked) { + await trackedAchievementState.untrackAchievement(); + return; + } + + await trackedAchievementState.trackAchievement( + featuredAchievement.achievement.id, + ); + }; + + let achievementShelfContent: ReactElement; + + if (isAchievementsPending) { + achievementShelfContent = ( + + ); + } else if (featuredAchievements.length > 0) { + achievementShelfContent = ( +
+ {featuredAchievements.map((achievement) => ( + + ))} +
+ ); + } else { + achievementShelfContent = ( + + ); + } + + let badgeCaseContent: ReactElement; + + if (isBadgesPending) { + badgeCaseContent = ( + + ); + } else if (topReaderBadges.length > 0) { + badgeCaseContent = ( + <> +
+ + {badgeSummary.latestBadge + ? formatDate({ + value: badgeSummary.latestBadge.issuedAt, + type: TimeFormatType.TopReaderBadge, + }) + : 'Read deeply to earn your first badge'} + + } + /> + + } + subtitle={ + + breadth of expertise + + } + /> + + {mostEarnedBadgeSubtitle} + + } + /> +
+
+ {badgeCaseBadges.map((badge) => ( +
+ +
+ ))} +
+ + ); + } else { + badgeCaseContent = ( + + ); + } + + let trophyCaseContent: ReactElement; + + if (!hasCoresAccess) { + trophyCaseContent = ( + + ); + } else if (isAwardsPending) { + trophyCaseContent = ( + + ); + } else if (awardsError) { + trophyCaseContent = ( + + ); + } else if (awardSummary.awards.length > 0) { + trophyCaseContent = ( + <> +
+ + } + subtitle={ + + all-time collection + + } + /> + + } + subtitle={ + + unique trophies earned + + } + /> + + } + subtitle={ + + {awardSummary.favoriteAward?.name ?? 'No awards yet'} + + } + /> +
+
+
+ {awardSummary.awards.map((award) => ( + + ))} +
+
+ + ); + } else { + trophyCaseContent = ( + + ); + } + + return ( + +
+ + + Gamification hub + + + +
+
+
+
+
+
+
+
+ + Progress snapshot + + + {firstName}, here's how your game is moving. + + + This hub pulls together your quest progress, achievement + milestones, recent badges, creator rewards, and a few + community benchmarks so you can see both momentum and upside + at a glance. + +
+ +
+ + +
+ +
+
+ + Best next quest + + + {highlightedQuest?.quest.name ?? + 'No active quest selected'} + + + {highlightedQuest + ? `${Math.min( + highlightedQuest.progress, + highlightedQuest.quest.targetCount, + )}/${highlightedQuest.quest.targetCount} progress` + : 'Your next rotation will show up here.'} + +
+ +
+
+ + Closest achievement + + {isFeaturedAchievementTrackable && ( + +
+
+ {featuredAchievement && ( + + )} +
+ + {featuredAchievement?.achievement.name ?? + 'No tracked achievement'} + + + {featuredAchievement + ? `${featuredAchievement.progress}/${getTargetCount( + featuredAchievement.achievement, + )} progress` + : 'Once achievements load, your closest milestone shows here.'} + +
+
+
+
+
+ +
+ {isQuestsFeatureEnabled === true && questDashboard ? ( + <> +
+ +
+ + Current level + + + Level {questDashboard.level.level} + +
+
+
+
+ + XP to next level + + + {questDashboard.level.xpToNextLevel.toLocaleString()} + +
+ +
+ + ) : ( + <> + + Personal highlight + + + {achievementSummary.unlockedCount}/ + {achievementSummary.totalCount} + + + achievements unlocked so far + + + )} +
+
+
+ +
+ + } + subtitle={ + + {achievementSummary.totalCount} total available + + } + /> + + } + subtitle={ + + {badgeSummary.uniqueTopics} distinct topics + + } + /> + + } + subtitle={ + + creator rewards + + } + /> +
+ + + +
+ + + Open full leaderboards + + + + } + /> + {highestReputation.length > 0 || mostQuestsCompleted.length > 0 ? ( +
+ {highestReputation.length > 0 && ( + + )} + {mostQuestsCompleted.length > 0 && ( + + )} +
+ ) : ( + + )} +
+ + + +
+ + + View all achievements + + + + ) : undefined + } + /> + + {achievementShelfContent} +
+ + + +
+ + + {badgeCaseContent} +
+ + + +
+ + + {trophyCaseContent} +
+ + + +
+ +
+ + } + /> + + } + /> + + } + /> + + } + /> +
+
+
+
+
+ ); +} + +const getHubLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +HubPage.getLayout = getHubLayout; +HubPage.layoutProps = { screenCentered: false, seo }; + +export default HubPage; + +export async function getStaticProps(): Promise< + GetStaticPropsResult +> { + try { + const [highestReputationRes, mostQuestsCompletedRes] = await Promise.all([ + gqlClient.request<{ + highestReputation: UserLeaderboard[]; + }>(HIGHEST_REPUTATION_QUERY, { limit: leaderboardLimit }), + gqlClient.request<{ + mostQuestsCompleted: UserLeaderboard[]; + }>(MOST_QUESTS_COMPLETED_QUERY, { limit: leaderboardLimit }), + ]); + + return { + props: { + highestReputation: highestReputationRes.highestReputation ?? [], + mostQuestsCompleted: mostQuestsCompletedRes.mostQuestsCompleted ?? [], + }, + revalidate: 3600, + }; + } catch (err: unknown) { + const error = err as { + response?: { + errors?: Array<{ + extensions?: { + code?: ApiError; + }; + }>; + }; + }; + + if ( + [ApiError.NotFound, ApiError.Forbidden].includes( + error?.response?.errors?.[0]?.extensions?.code, + ) + ) { + return { + props: { + highestReputation: [], + mostQuestsCompleted: [], + }, + revalidate: 300, + }; + } + + return { + props: { + highestReputation: [], + mostQuestsCompleted: [], + }, + revalidate: 300, + }; + } +} From 130696d594f2bf6e7f25308e032bf01b87703dcd Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 23 Mar 2026 17:28:35 +0100 Subject: [PATCH 2/9] add more stats --- packages/shared/src/components/DataTile.tsx | 4 +- .../src/components/quest/QuestButton.spec.tsx | 1 + packages/shared/src/graphql/leaderboard.ts | 33 ++ packages/shared/src/graphql/quests.ts | 3 + .../webapp/__tests__/HubStaticProps.spec.ts | 130 ++++++++ packages/webapp/lib/hub.spec.ts | 1 + packages/webapp/pages/hub/index.tsx | 311 +++++++++--------- 7 files changed, 323 insertions(+), 160 deletions(-) create mode 100644 packages/webapp/__tests__/HubStaticProps.spec.ts diff --git a/packages/shared/src/components/DataTile.tsx b/packages/shared/src/components/DataTile.tsx index 8a8468c65c4..0ef463ea9c3 100644 --- a/packages/shared/src/components/DataTile.tsx +++ b/packages/shared/src/components/DataTile.tsx @@ -43,12 +43,12 @@ export const DataTile: React.FC = ({ - + {icon} {typeof value === 'number' ? formatDataTileValue(value) : value} diff --git a/packages/shared/src/components/quest/QuestButton.spec.tsx b/packages/shared/src/components/quest/QuestButton.spec.tsx index c7bce447832..30c205d0e16 100644 --- a/packages/shared/src/components/quest/QuestButton.spec.tsx +++ b/packages/shared/src/components/quest/QuestButton.spec.tsx @@ -189,6 +189,7 @@ const questDashboard = { xpToNextLevel: 150, }, currentStreak: 0, + longestStreak: 0, daily: { regular: [ { diff --git a/packages/shared/src/graphql/leaderboard.ts b/packages/shared/src/graphql/leaderboard.ts index e7cf41e42ed..9c673de067d 100644 --- a/packages/shared/src/graphql/leaderboard.ts +++ b/packages/shared/src/graphql/leaderboard.ts @@ -60,6 +60,19 @@ export enum LeaderboardType { HighestLevel = 'highestLevel', } +export type QuestCompletionLeader = { + questId: string; + questName: string; + questDescription: string; + count: number; +}; + +export type QuestCompletionStats = { + totalCount: number; + allTimeLeader: QuestCompletionLeader | null; + weeklyLeader: QuestCompletionLeader | null; +}; + export const leaderboardTypeToTitle: Record = { [LeaderboardType.HighestReputation]: 'Highest reputation', [LeaderboardType.LongestStreak]: 'Longest streak', @@ -148,6 +161,26 @@ export const MOST_QUESTS_COMPLETED_QUERY = gql` ${LEADERBOARD_FRAGMENT} `; +export const QUEST_COMPLETION_STATS_QUERY = gql` + query QuestCompletionStats { + questCompletionStats { + totalCount + allTimeLeader { + questId + questName + questDescription + count + } + weeklyLeader { + questId + questName + questDescription + count + } + } + } +`; + export const HIGHEST_LEVEL_QUERY = gql` query HighestLevel($limit: Int = 100) { highestLevel(limit: $limit) { diff --git a/packages/shared/src/graphql/quests.ts b/packages/shared/src/graphql/quests.ts index 936b8d61fe2..04e28217705 100644 --- a/packages/shared/src/graphql/quests.ts +++ b/packages/shared/src/graphql/quests.ts @@ -60,6 +60,7 @@ export interface QuestLevel { export interface QuestDashboard { level: QuestLevel; currentStreak: number; + longestStreak: number; daily: QuestBucket; weekly: QuestBucket; } @@ -109,6 +110,7 @@ export const QUEST_DASHBOARD_QUERY = gql` xpToNextLevel } currentStreak + longestStreak daily { regular { userQuestId @@ -215,6 +217,7 @@ export const CLAIM_QUEST_REWARD_MUTATION = gql` xpToNextLevel } currentStreak + longestStreak daily { regular { userQuestId diff --git a/packages/webapp/__tests__/HubStaticProps.spec.ts b/packages/webapp/__tests__/HubStaticProps.spec.ts new file mode 100644 index 00000000000..cf4970c1245 --- /dev/null +++ b/packages/webapp/__tests__/HubStaticProps.spec.ts @@ -0,0 +1,130 @@ +import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { + HIGHEST_REPUTATION_QUERY, + MOST_QUESTS_COMPLETED_QUERY, + QUEST_COMPLETION_STATS_QUERY, + type QuestCompletionStats, +} from '@dailydotdev/shared/src/graphql/leaderboard'; +import { getStaticProps as getHubStaticProps } from '../pages/hub/index'; + +jest.mock('@dailydotdev/shared/src/graphql/common', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); + + return { + ...actual, + gqlClient: { + request: jest.fn(), + }, + }; +}); + +const mockRequest = gqlClient.request as jest.Mock; + +const highestReputation = [ + { + score: 1200, + user: { + id: 'user-1', + username: 'user-1', + }, + }, +] as UserLeaderboard[]; + +const mostQuestsCompleted = [ + { + score: 42, + user: { + id: 'user-2', + username: 'user-2', + }, + }, +] as UserLeaderboard[]; + +const questCompletionStats: QuestCompletionStats = { + totalCount: 987, + allTimeLeader: { + questId: 'quest-1', + questName: 'Link Drop', + questDescription: 'Create a shared link post', + count: 321, + }, + weeklyLeader: { + questId: 'quest-2', + questName: 'Hot Take Mic Check', + questDescription: 'Vote on a hot take', + count: 27, + }, +}; + +const createMissingSchemaError = (message: string) => ({ + response: { + errors: [{ message }], + }, +}); + +describe('hub static props', () => { + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('should include quest completion stats when the schema supports them', async () => { + mockRequest.mockImplementation((query: string) => { + if (query === HIGHEST_REPUTATION_QUERY) { + return Promise.resolve({ highestReputation }); + } + + if (query === MOST_QUESTS_COMPLETED_QUERY) { + return Promise.resolve({ mostQuestsCompleted }); + } + + if (query === QUEST_COMPLETION_STATS_QUERY) { + return Promise.resolve({ questCompletionStats }); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + const result = await getHubStaticProps(); + + expect(result).toMatchObject({ + props: { + highestReputation, + mostQuestsCompleted, + questCompletionStats, + }, + }); + }); + + it('should keep leaderboards when quest completion stats are not yet in the schema', async () => { + mockRequest.mockImplementation((query: string) => { + if (query === HIGHEST_REPUTATION_QUERY) { + return Promise.resolve({ highestReputation }); + } + + if (query === MOST_QUESTS_COMPLETED_QUERY) { + return Promise.resolve({ mostQuestsCompleted }); + } + + if (query === QUEST_COMPLETION_STATS_QUERY) { + return Promise.reject( + createMissingSchemaError( + 'Cannot query field "questCompletionStats" on type "Query".', + ), + ); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + const result = await getHubStaticProps(); + + expect(result).toMatchObject({ + props: { + highestReputation, + mostQuestsCompleted, + questCompletionStats: null, + }, + }); + }); +}); diff --git a/packages/webapp/lib/hub.spec.ts b/packages/webapp/lib/hub.spec.ts index 01aa5245838..727da2f6f97 100644 --- a/packages/webapp/lib/hub.spec.ts +++ b/packages/webapp/lib/hub.spec.ts @@ -77,6 +77,7 @@ describe('hub helpers', () => { xpToNextLevel: 250, }, currentStreak: 4, + longestStreak: 9, daily: { regular: [ createQuest({ diff --git a/packages/webapp/pages/hub/index.tsx b/packages/webapp/pages/hub/index.tsx index 59258b8ef7f..e7b582eaa23 100644 --- a/packages/webapp/pages/hub/index.tsx +++ b/packages/webapp/pages/hub/index.tsx @@ -5,30 +5,31 @@ import type { NextSeoProps } from 'next-seo'; import classNames from 'classnames'; import { useQuery } from '@tanstack/react-query'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; import { HIGHEST_REPUTATION_QUERY, LeaderboardType, MOST_QUESTS_COMPLETED_QUERY, + QUEST_COMPLETION_STATS_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; import { ProductType, userProductSummaryQueryOptions, } from '@dailydotdev/shared/src/graphql/njord'; import { getTargetCount } from '@dailydotdev/shared/src/graphql/user/achievements'; -import type { UserPostsAnalytics } from '@dailydotdev/shared/src/graphql/users'; -import { USER_POSTS_ANALYTICS_QUERY } from '@dailydotdev/shared/src/graphql/users'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useProfileAchievements } from '@dailydotdev/shared/src/hooks/profile/useProfileAchievements'; import { useTrackedAchievement } from '@dailydotdev/shared/src/hooks/profile/useTrackedAchievement'; import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; import { useHasAccessToCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; import { useQuestDashboard } from '@dailydotdev/shared/src/hooks/useQuestDashboard'; -import { useRequestProtocol } from '@dailydotdev/shared/src/hooks/useRequestProtocol'; import { shouldShowAchievementTracker } from '@dailydotdev/shared/src/lib/achievements'; import { formatDate, TimeFormatType, } from '@dailydotdev/shared/src/lib/dateFormat'; +import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; +import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; import { achievementTrackingWidgetFeature, questsFeature, @@ -55,6 +56,7 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; import { @@ -76,10 +78,6 @@ import { CoreIcon, MedalBadgeIcon, PinIcon, - ReputationIcon, - SparkleIcon, - StarIcon, - TrendingIcon, } from '@dailydotdev/shared/src/components/icons'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; @@ -97,6 +95,7 @@ import { type HubPageProps = { highestReputation: UserLeaderboard[]; mostQuestsCompleted: UserLeaderboard[]; + questCompletionStats: QuestCompletionStats | null; }; type SectionProps = { @@ -108,6 +107,18 @@ type SectionProps = { const dividerClassName = 'bg-border-subtlest-tertiary'; const leaderboardLimit = 3; +const isQuestCompletionStatsSchemaMissing = (error: GraphQLError): boolean => { + return ( + error?.response?.errors?.some(({ message }) => + message?.includes('Cannot query field "questCompletionStats"'), + ) ?? false + ); +}; + +const formatQuestCompletionCount = (count: number): string => { + return count === 1 ? '1 completion' : `${count.toLocaleString()} completions`; +}; + const SectionHeader = ({ title, description, @@ -205,7 +216,7 @@ const TrophyCard = ({ ); }; -const seoTitles = getPageSeoTitles('Gamification hub'); +const seoTitles = getPageSeoTitles('The Hub'); const seo: NextSeoProps = { title: seoTitles.title, openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, @@ -218,9 +229,9 @@ const seo: NextSeoProps = { function HubPage({ highestReputation, mostQuestsCompleted, + questCompletionStats, }: HubPageProps): ReactElement { const { user } = useAuthContext(); - const { requestMethod } = useRequestProtocol(); const { value: isQuestsFeatureEnabled } = useConditionalFeature({ feature: questsFeature, shouldEvaluate: !!user, @@ -260,23 +271,6 @@ function HubPage({ ); const hasCoresAccess = useHasAccessToCores(); - const analyticsQueryKey = generateQueryKey( - RequestKey.UserPostsAnalytics, - user, - ); - const { data: analytics } = useQuery({ - queryKey: analyticsQueryKey, - queryFn: async () => { - const result = await requestMethod<{ - userPostsAnalytics: UserPostsAnalytics; - }>(USER_POSTS_ANALYTICS_QUERY); - - return result.userPostsAnalytics; - }, - staleTime: StaleTime.Default, - enabled: !!user, - }); - const topReaderQueryKey = generateQueryKey( RequestKey.TopReaderBadge, user, @@ -326,6 +320,8 @@ function HubPage({ const { featuredAchievements } = achievementSummary; const [featuredAchievement] = featuredAchievements; const { highlightedQuest } = questSummary; + const hasCommunityLeaderboards = + highestReputation.length > 0 || mostQuestsCompleted.length > 0; let mostEarnedBadgeSubtitle = 'Read in a topic more than once to see a favorite'; @@ -486,16 +482,18 @@ function HubPage({ } /> -
- {badgeCaseBadges.map((badge) => ( -
- -
- ))} +
+
+ {badgeCaseBadges.map((badge) => ( +
+ +
+ ))} +
); @@ -575,7 +573,12 @@ function HubPage({ value={awardSummary.favoriteAward?.count ?? 0} info="The award type you have collected the most." icon={ - + {awardSummary.favoriteAward?.name } subtitle={ - Gamification hub + The Hub @@ -651,7 +654,7 @@ function HubPage({ type={TypographyType.Title1} bold > - {firstName}, here's how your game is moving. + {firstName}, here's how you're doing.
-
+
+
@@ -858,72 +871,12 @@ function HubPage({
-
- - } - subtitle={ - - {achievementSummary.totalCount} total available - - } - /> - - } - subtitle={ - - {badgeSummary.uniqueTopics} distinct topics - - } - /> - - } - subtitle={ - - creator rewards - - } - /> -
-
@@ -933,7 +886,86 @@ function HubPage({ } /> - {highestReputation.length > 0 || mostQuestsCompleted.length > 0 ? ( + {questCompletionStats && ( +
+ + + {questCompletionStats.allTimeLeader?.questDescription ?? + 'Criteria will show once the first quest is completed'} + + + {questCompletionStats.allTimeLeader + ? formatQuestCompletionCount( + questCompletionStats.allTimeLeader.count, + ) + : 'Waiting on the first completion'} + +
+ } + /> + + + {questCompletionStats.weeklyLeader?.questDescription ?? + 'Criteria will show once a quest is completed this week'} + + + {questCompletionStats.weeklyLeader + ? formatQuestCompletionCount( + questCompletionStats.weeklyLeader.count, + ) + : 'No completed quests yet this week'} + +
+ } + /> + + all-time community total + + } + /> + + )} + {hasCommunityLeaderboards ? (
{highestReputation.length > 0 && ( {trophyCaseContent} - - - -
- -
- - } - /> - - } - /> - - } - /> - - } - /> -
-
@@ -1087,11 +1064,27 @@ export async function getStaticProps(): Promise< mostQuestsCompleted: UserLeaderboard[]; }>(MOST_QUESTS_COMPLETED_QUERY, { limit: leaderboardLimit }), ]); + let questCompletionStats: QuestCompletionStats | null = null; + + try { + const statsRes = await gqlClient.request<{ + questCompletionStats: QuestCompletionStats | null; + }>(QUEST_COMPLETION_STATS_QUERY); + + questCompletionStats = statsRes.questCompletionStats ?? null; + } catch (statsError: unknown) { + const error = statsError as GraphQLError; + + if (isQuestCompletionStatsSchemaMissing(error)) { + questCompletionStats = null; + } + } return { props: { highestReputation: highestReputationRes.highestReputation ?? [], mostQuestsCompleted: mostQuestsCompletedRes.mostQuestsCompleted ?? [], + questCompletionStats, }, revalidate: 3600, }; @@ -1115,6 +1108,7 @@ export async function getStaticProps(): Promise< props: { highestReputation: [], mostQuestsCompleted: [], + questCompletionStats: null, }, revalidate: 300, }; @@ -1124,6 +1118,7 @@ export async function getStaticProps(): Promise< props: { highestReputation: [], mostQuestsCompleted: [], + questCompletionStats: null, }, revalidate: 300, }; From 85f90d2a1ebff8f37b6006be18194838ecff55c0 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 23 Mar 2026 17:52:50 +0100 Subject: [PATCH 3/9] rename to game center --- ....spec.ts => GameCenterStaticProps.spec.ts} | 10 +++---- .../lib/{hub.spec.ts => gameCenter.spec.ts} | 4 +-- packages/webapp/lib/{hub.ts => gameCenter.ts} | 28 +++++++++++-------- .../pages/{hub => game-center}/index.tsx | 28 +++++++++---------- 4 files changed, 37 insertions(+), 33 deletions(-) rename packages/webapp/__tests__/{HubStaticProps.spec.ts => GameCenterStaticProps.spec.ts} (90%) rename packages/webapp/lib/{hub.spec.ts => gameCenter.spec.ts} (99%) rename packages/webapp/lib/{hub.ts => gameCenter.ts} (93%) rename packages/webapp/pages/{hub => game-center}/index.tsx (98%) diff --git a/packages/webapp/__tests__/HubStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts similarity index 90% rename from packages/webapp/__tests__/HubStaticProps.spec.ts rename to packages/webapp/__tests__/GameCenterStaticProps.spec.ts index cf4970c1245..eb243d055d7 100644 --- a/packages/webapp/__tests__/HubStaticProps.spec.ts +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -1,12 +1,12 @@ import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; import { HIGHEST_REPUTATION_QUERY, MOST_QUESTS_COMPLETED_QUERY, QUEST_COMPLETION_STATS_QUERY, - type QuestCompletionStats, } from '@dailydotdev/shared/src/graphql/leaderboard'; -import { getStaticProps as getHubStaticProps } from '../pages/hub/index'; +import { getStaticProps as getGameCenterStaticProps } from '../pages/game-center/index'; jest.mock('@dailydotdev/shared/src/graphql/common', () => { const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); @@ -63,7 +63,7 @@ const createMissingSchemaError = (message: string) => ({ }, }); -describe('hub static props', () => { +describe('game center static props', () => { beforeEach(() => { mockRequest.mockReset(); }); @@ -85,7 +85,7 @@ describe('hub static props', () => { return Promise.reject(new Error('Unexpected query')); }); - const result = await getHubStaticProps(); + const result = await getGameCenterStaticProps(); expect(result).toMatchObject({ props: { @@ -117,7 +117,7 @@ describe('hub static props', () => { return Promise.reject(new Error('Unexpected query')); }); - const result = await getHubStaticProps(); + const result = await getGameCenterStaticProps(); expect(result).toMatchObject({ props: { diff --git a/packages/webapp/lib/hub.spec.ts b/packages/webapp/lib/gameCenter.spec.ts similarity index 99% rename from packages/webapp/lib/hub.spec.ts rename to packages/webapp/lib/gameCenter.spec.ts index 727da2f6f97..9382454acf2 100644 --- a/packages/webapp/lib/hub.spec.ts +++ b/packages/webapp/lib/gameCenter.spec.ts @@ -17,7 +17,7 @@ import { getBadgeSummary, getQuestSummary, getTopReaderTopicLabel, -} from './hub'; +} from './gameCenter'; const createQuest = ( overrides: Partial & { questId: string; name: string }, @@ -67,7 +67,7 @@ const createAchievement = ( ...overrides, }); -describe('hub helpers', () => { +describe('game center helpers', () => { it('builds quest summaries and prioritizes claimable quests', () => { const dashboard: QuestDashboard = { level: { diff --git a/packages/webapp/lib/hub.ts b/packages/webapp/lib/gameCenter.ts similarity index 93% rename from packages/webapp/lib/hub.ts rename to packages/webapp/lib/gameCenter.ts index b9f8e34a757..58b0ee40cc1 100644 --- a/packages/webapp/lib/hub.ts +++ b/packages/webapp/lib/gameCenter.ts @@ -30,7 +30,7 @@ const getQuestProgressRatio = (quest: UserQuest): number => { const getQuestRewardTotal = (quest: UserQuest): number => quest.rewards.reduce((total, reward) => total + reward.amount, 0); -export type HubQuestBucketSummary = { +export type GameCenterQuestBucketSummary = { all: UserQuest[]; regular: UserQuest[]; plus: UserQuest[]; @@ -42,13 +42,15 @@ export type HubQuestBucketSummary = { completionRate: number; }; -export type HubQuestSummary = HubQuestBucketSummary & { - daily: HubQuestBucketSummary; - weekly: HubQuestBucketSummary; +export type GameCenterQuestSummary = GameCenterQuestBucketSummary & { + daily: GameCenterQuestBucketSummary; + weekly: GameCenterQuestBucketSummary; highlightedQuest: UserQuest | null; }; -const getQuestBucketSummary = (bucket?: QuestBucket): HubQuestBucketSummary => { +const getQuestBucketSummary = ( + bucket?: QuestBucket, +): GameCenterQuestBucketSummary => { const regular = bucket?.regular ?? []; const plus = bucket?.plus ?? []; const all = [...regular, ...plus]; @@ -98,7 +100,7 @@ const getHighlightedQuest = (quests: UserQuest[]): UserQuest | null => { export const getQuestSummary = ( dashboard?: QuestDashboard, -): HubQuestSummary => { +): GameCenterQuestSummary => { const daily = getQuestBucketSummary(dashboard?.daily); const weekly = getQuestBucketSummary(dashboard?.weekly); const all = [...daily.all, ...weekly.all]; @@ -148,7 +150,7 @@ const dedupeAchievements = ( }); }; -export type HubAchievementSummary = { +export type GameCenterAchievementSummary = { unlockedCount: number; totalCount: number; totalPoints: number; @@ -161,7 +163,7 @@ export type HubAchievementSummary = { export const getAchievementSummary = ( achievements?: UserAchievement[], trackedAchievement?: UserAchievement | null, -): HubAchievementSummary => { +): GameCenterAchievementSummary => { const allAchievements = achievements ?? []; const unlocked = allAchievements.filter( (achievement) => achievement.unlockedAt !== null, @@ -241,7 +243,7 @@ const sortAwardsByCount = ( }); }; -export type HubAwardSummary = { +export type GameCenterAwardSummary = { awards: UserProductSummary[]; totalAwards: number; uniqueAwards: number; @@ -250,7 +252,7 @@ export type HubAwardSummary = { export const getAwardSummary = ( awards?: UserProductSummary[], -): HubAwardSummary => { +): GameCenterAwardSummary => { const allAwards = sortAwardsByCount(awards ?? []); return { @@ -261,7 +263,7 @@ export const getAwardSummary = ( }; }; -export type HubBadgeSummary = { +export type GameCenterBadgeSummary = { totalBadges: number; uniqueTopics: number; latestBadge: TopReader | null; @@ -269,7 +271,9 @@ export type HubBadgeSummary = { mostEarnedBadgeCount: number; }; -export const getBadgeSummary = (badges?: TopReader[]): HubBadgeSummary => { +export const getBadgeSummary = ( + badges?: TopReader[], +): GameCenterBadgeSummary => { const allBadges = badges ?? []; const latestBadge = [...allBadges].sort( diff --git a/packages/webapp/pages/hub/index.tsx b/packages/webapp/pages/game-center/index.tsx similarity index 98% rename from packages/webapp/pages/hub/index.tsx rename to packages/webapp/pages/game-center/index.tsx index e7b582eaa23..f7f2e734e16 100644 --- a/packages/webapp/pages/hub/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -90,9 +90,9 @@ import { getBadgeSummary, getQuestSummary, getTopReaderTopicLabel, -} from '../../lib/hub'; +} from '../../lib/gameCenter'; -type HubPageProps = { +type GameCenterPageProps = { highestReputation: UserLeaderboard[]; mostQuestsCompleted: UserLeaderboard[]; questCompletionStats: QuestCompletionStats | null; @@ -216,7 +216,7 @@ const TrophyCard = ({ ); }; -const seoTitles = getPageSeoTitles('The Hub'); +const seoTitles = getPageSeoTitles('Game Center'); const seo: NextSeoProps = { title: seoTitles.title, openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, @@ -226,11 +226,11 @@ const seo: NextSeoProps = { noindex: true, }; -function HubPage({ +function GameCenterPage({ highestReputation, mostQuestsCompleted, questCompletionStats, -}: HubPageProps): ReactElement { +}: GameCenterPageProps): ReactElement { const { user } = useAuthContext(); const { value: isQuestsFeatureEnabled } = useConditionalFeature({ feature: questsFeature, @@ -274,7 +274,7 @@ function HubPage({ const topReaderQueryKey = generateQueryKey( RequestKey.TopReaderBadge, user, - 'hub:100', + 'game-center:100', ); const { data: topReaderBadges = [], isPending: isBadgesPending } = useQuery({ queryKey: topReaderQueryKey, @@ -630,7 +630,7 @@ function HubPage({ color={TypographyColor.Primary} className="flex-1" > - The Hub + Game Center @@ -660,7 +660,7 @@ function HubPage({ type={TypographyType.Body} color={TypographyColor.Tertiary} > - This hub pulls together your quest progress, achievement + The Game Center pulls together your quest progress, achievement milestones, recent badges, creator rewards, and a few community benchmarks so you can see both momentum and upside at a glance. @@ -991,7 +991,7 @@ function HubPage({ ) : ( )} @@ -1044,16 +1044,16 @@ function HubPage({ ); } -const getHubLayout: typeof getLayout = (...props) => +const getGameCenterLayout: typeof getLayout = (...props) => getFooterNavBarLayout(getLayout(...props)); -HubPage.getLayout = getHubLayout; -HubPage.layoutProps = { screenCentered: false, seo }; +GameCenterPage.getLayout = getGameCenterLayout; +GameCenterPage.layoutProps = { screenCentered: false, seo }; -export default HubPage; +export default GameCenterPage; export async function getStaticProps(): Promise< - GetStaticPropsResult + GetStaticPropsResult > { try { const [highestReputationRes, mostQuestsCompletedRes] = await Promise.all([ From 3b65e3de86c08ffedc0b41e09071a0d91505bf0b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:58:44 +0000 Subject: [PATCH 4/9] revert: keep main version of QuestButton router push Co-authored-by: Amar Trebinjac --- .../shared/src/components/quest/QuestButton.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/shared/src/components/quest/QuestButton.tsx b/packages/shared/src/components/quest/QuestButton.tsx index a646a1c7750..29193ac66e6 100644 --- a/packages/shared/src/components/quest/QuestButton.tsx +++ b/packages/shared/src/components/quest/QuestButton.tsx @@ -262,18 +262,6 @@ type QuestDestination = { const HOT_TAKES_MODAL_PATH = '/?openModal=hottakes'; -const getQuestDestinationHref = (path: string): string => { - if (/^https?:\/\//.test(path)) { - return path; - } - - if (path === '/') { - return webappUrl; - } - - return `${webappUrl}${path.replace(/^\//, '')}`; -}; - const getQuestDestination = ( quest: UserQuest['quest'], ): QuestDestination | null => { @@ -1590,7 +1578,7 @@ export const QuestButton = ({ const handleDestinationClick = useCallback( async (destination: QuestDestination) => { setIsOpen(false); - await router.push(getQuestDestinationHref(destination.path)); + await router.push(`${webappUrl}${destination.path.replace(/^\//, '')}`); }, [router], ); From 5e00b8c0946027dd0cf7f21b519eb72aad51cf2b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:21:50 +0000 Subject: [PATCH 5/9] fix: resolve build failures in game-center page - Fix Prettier formatting in pages/game-center/index.tsx (line wrap) - Add lib/**/*.ts to tsconfig.eslint.json so next lint can parse gameCenter.spec.ts Co-authored-by: Amar Trebinjac --- packages/webapp/pages/game-center/index.tsx | 8 ++++---- packages/webapp/tsconfig.eslint.json | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index f7f2e734e16..98227f4d12c 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -660,10 +660,10 @@ function GameCenterPage({ type={TypographyType.Body} color={TypographyColor.Tertiary} > - The Game Center pulls together your quest progress, achievement - milestones, recent badges, creator rewards, and a few - community benchmarks so you can see both momentum and upside - at a glance. + The Game Center pulls together your quest progress, + achievement milestones, recent badges, creator rewards, and + a few community benchmarks so you can see both momentum and + upside at a glance. diff --git a/packages/webapp/tsconfig.eslint.json b/packages/webapp/tsconfig.eslint.json index f6da372082b..6e6b8ab1499 100644 --- a/packages/webapp/tsconfig.eslint.json +++ b/packages/webapp/tsconfig.eslint.json @@ -15,6 +15,8 @@ "__tests__/**/*.ts", "__tests__/**/*.tsx", "__mocks__/**/*.ts", - "__mocks__/**/*.tsx" + "__mocks__/**/*.tsx", + "lib/**/*.ts", + "lib/**/*.tsx" ] } From fa90939514876b08a833f895e2474cfc7426d3bf Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:34:59 +0000 Subject: [PATCH 6/9] fix: prettier formatting in users.spec.ts Co-authored-by: Amar Trebinjac --- packages/shared/src/graphql/users.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/graphql/users.spec.ts b/packages/shared/src/graphql/users.spec.ts index 00d2b0554ff..2c2049591af 100644 --- a/packages/shared/src/graphql/users.spec.ts +++ b/packages/shared/src/graphql/users.spec.ts @@ -2,7 +2,9 @@ import { TOP_READER_BADGE, TOP_READER_BADGE_BY_ID } from './users'; describe('top reader badge queries', () => { it('includes the badge owner in the list query', () => { - expect(TOP_READER_BADGE).toContain('topReaderBadge(limit: $limit, userId: $userId)'); + expect(TOP_READER_BADGE).toContain( + 'topReaderBadge(limit: $limit, userId: $userId)', + ); expect(TOP_READER_BADGE).toContain('user {'); expect(TOP_READER_BADGE).toContain('name'); expect(TOP_READER_BADGE).toContain('username'); From c027219b21994d7839073eae5799fa39d8731382 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 23 Mar 2026 19:24:25 +0100 Subject: [PATCH 7/9] cleanup --- packages/shared/src/graphql/leaderboard.ts | 4 ++- packages/shared/src/graphql/quests.ts | 4 +-- .../shared/src/hooks/useClaimQuestReward.ts | 36 +++++++++++++++++-- .../UsersLeaderboardStaticProps.spec.ts | 22 ++++++++++++ packages/webapp/pages/users/[id].tsx | 11 ++++-- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/graphql/leaderboard.ts b/packages/shared/src/graphql/leaderboard.ts index 9c673de067d..f0ec8adae61 100644 --- a/packages/shared/src/graphql/leaderboard.ts +++ b/packages/shared/src/graphql/leaderboard.ts @@ -60,6 +60,8 @@ export enum LeaderboardType { HighestLevel = 'highestLevel', } +export const MOST_QUESTS_COMPLETED_LIMIT = 10; + export type QuestCompletionLeader = { questId: string; questName: string; @@ -153,7 +155,7 @@ export const MOST_ACHIEVEMENT_POINTS_QUERY = gql` `; export const MOST_QUESTS_COMPLETED_QUERY = gql` - query MostQuestsCompleted($limit: Int = 100) { + query MostQuestsCompleted($limit: Int = ${MOST_QUESTS_COMPLETED_LIMIT}) { mostQuestsCompleted(limit: $limit) { ...LeaderboardFragment } diff --git a/packages/shared/src/graphql/quests.ts b/packages/shared/src/graphql/quests.ts index 04e28217705..510719145a2 100644 --- a/packages/shared/src/graphql/quests.ts +++ b/packages/shared/src/graphql/quests.ts @@ -70,7 +70,7 @@ export interface QuestDashboardData { } export interface ClaimQuestRewardData { - claimQuestReward: QuestDashboard; + claimQuestReward: Pick; } export interface QuestUpdate { @@ -216,8 +216,6 @@ export const CLAIM_QUEST_REWARD_MUTATION = gql` xpInLevel xpToNextLevel } - currentStreak - longestStreak daily { regular { userQuestId diff --git a/packages/shared/src/hooks/useClaimQuestReward.ts b/packages/shared/src/hooks/useClaimQuestReward.ts index bb33399f103..efbc0d83b0c 100644 --- a/packages/shared/src/hooks/useClaimQuestReward.ts +++ b/packages/shared/src/hooks/useClaimQuestReward.ts @@ -1,7 +1,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuthContext } from '../contexts/AuthContext'; import { useLogContext } from '../contexts/LogContext'; -import type { ClaimQuestRewardData, QuestType } from '../graphql/quests'; +import type { + ClaimQuestRewardData, + QuestDashboard, + QuestType, +} from '../graphql/quests'; import { CLAIM_QUEST_REWARD_MUTATION } from '../graphql/quests'; import { LogEvent, TargetType } from '../lib/log'; import { generateQueryKey, RequestKey } from '../lib/query'; @@ -31,7 +35,7 @@ export const useClaimQuestReward = () => { return result.claimQuestReward; }, - onSuccess: async (questDashboard, { userQuestId, questId, questType }) => { + onSuccess: async (claimResult, { userQuestId, questId, questType }) => { logEvent({ event_name: LogEvent.ClaimQuest, target_id: questId, @@ -43,7 +47,33 @@ export const useClaimQuestReward = () => { }), }); - queryClient.setQueryData(questDashboardKey, questDashboard); + let didUpdateQuestDashboard = false; + + queryClient.setQueryData( + questDashboardKey, + (currentDashboard) => { + if (!currentDashboard) { + return currentDashboard; + } + + didUpdateQuestDashboard = true; + + return { + ...currentDashboard, + level: claimResult.level, + daily: claimResult.daily, + weekly: claimResult.weekly, + }; + }, + ); + + if (!didUpdateQuestDashboard) { + await queryClient.invalidateQueries({ + queryKey: questDashboardKey, + exact: true, + }); + } + await refetchBoot?.(); }, }); diff --git a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts index ff075435cbc..d3d2bab0d08 100644 --- a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts +++ b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts @@ -4,6 +4,8 @@ import { HIGHEST_LEVEL_QUERY, LEADERBOARD_QUERY, LeaderboardType, + MOST_QUESTS_COMPLETED_LIMIT, + MOST_QUESTS_COMPLETED_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; import { getStaticProps as getUsersStaticProps } from '../pages/users'; import { getStaticProps as getLeaderboardDetailStaticProps } from '../pages/users/[id]'; @@ -132,4 +134,24 @@ describe('leaderboard static props', () => { revalidate: 60, }); }); + + it('should request a smaller limit for most quests completed detail', async () => { + mockRequest.mockResolvedValue({ + mostQuestsCompleted: [], + }); + + const result = await getLeaderboardDetailStaticProps({ + params: { id: LeaderboardType.MostQuestsCompleted }, + } as never); + + expect(mockRequest).toHaveBeenCalledWith(MOST_QUESTS_COMPLETED_QUERY, { + limit: MOST_QUESTS_COMPLETED_LIMIT, + }); + expect(result).toMatchObject({ + props: { + leaderboardType: LeaderboardType.MostQuestsCompleted, + userItems: [], + }, + }); + }); }); diff --git a/packages/webapp/pages/users/[id].tsx b/packages/webapp/pages/users/[id].tsx index de8cc1bcf83..7f066ea3be1 100644 --- a/packages/webapp/pages/users/[id].tsx +++ b/packages/webapp/pages/users/[id].tsx @@ -11,6 +11,7 @@ import { LeaderboardType, leaderboardQueries, leaderboardTypeToTitle, + MOST_QUESTS_COMPLETED_LIMIT, } from '@dailydotdev/shared/src/graphql/leaderboard'; import { useRouter } from 'next/router'; import { BreadCrumbs } from '@dailydotdev/shared/src/components/header'; @@ -38,6 +39,11 @@ interface PageProps extends DynamicSeoProps { companyItems?: CompanyLeaderboard[]; } +const getLeaderboardLimit = (leaderboardType: LeaderboardType): number => + leaderboardType === LeaderboardType.MostQuestsCompleted + ? MOST_QUESTS_COMPLETED_LIMIT + : 100; + const isHighestLevelSchemaMissing = (error: GraphQLError): boolean => { return ( error?.response?.errors?.some( @@ -156,6 +162,7 @@ export async function getStaticProps({ const leaderboardType = id as LeaderboardType; const title = leaderboardTypeToTitle[leaderboardType]; const isCompany = isCompanyLeaderboard(leaderboardType); + const leaderboardLimit = getLeaderboardLimit(leaderboardType); const getSeoProps = () => { const seoTitles = getPageSeoTitles(`${title} - Developer leaderboard`); @@ -163,7 +170,7 @@ export async function getStaticProps({ return { title: seoTitles.title, openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, - description: `Check out the top 100 ${ + description: `Check out the top ${leaderboardLimit} ${ isCompany ? 'companies' : 'developers' } for ${title.toLowerCase()} on daily.dev.`, }; @@ -173,7 +180,7 @@ export async function getStaticProps({ const query = leaderboardQueries[leaderboardType]; const res = await gqlClient.request<{ [key: string]: UserLeaderboard[] | CompanyLeaderboard[]; - }>(query, { limit: 100 }); + }>(query, { limit: leaderboardLimit }); const items = res[leaderboardType] || []; From 47d8315adc2efac4665447015fed1663a8eec62e Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 24 Mar 2026 20:53:50 +0100 Subject: [PATCH 8/9] add experiment --- .../__tests__/GameCenterStaticProps.spec.ts | 136 +++++++++++++++++- packages/webapp/pages/game-center/index.tsx | 15 +- 2 files changed, 145 insertions(+), 6 deletions(-) diff --git a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts index eb243d055d7..2bc9a4a84d0 100644 --- a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -1,3 +1,6 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useQuery } from '@tanstack/react-query'; import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; @@ -6,7 +9,15 @@ import { MOST_QUESTS_COMPLETED_QUERY, QUEST_COMPLETION_STATS_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; -import { getStaticProps as getGameCenterStaticProps } from '../pages/game-center/index'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useProfileAchievements } from '@dailydotdev/shared/src/hooks/profile/useProfileAchievements'; +import { useTrackedAchievement } from '@dailydotdev/shared/src/hooks/profile/useTrackedAchievement'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { useHasAccessToCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; +import { useQuestDashboard } from '@dailydotdev/shared/src/hooks/useQuestDashboard'; +import GameCenterPage, { + getStaticProps as getGameCenterStaticProps, +} from '../pages/game-center/index'; jest.mock('@dailydotdev/shared/src/graphql/common', () => { const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); @@ -19,7 +30,72 @@ jest.mock('@dailydotdev/shared/src/graphql/common', () => { }; }); +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query'); + + return { + ...actual, + useQuery: jest.fn(), + }; +}); + +jest.mock('@dailydotdev/shared/src/contexts/AuthContext', () => ({ + ...jest.requireActual('@dailydotdev/shared/src/contexts/AuthContext'), + useAuthContext: jest.fn(), +})); + +jest.mock( + '@dailydotdev/shared/src/hooks/profile/useProfileAchievements', + () => ({ + useProfileAchievements: jest.fn(), + }), +); + +jest.mock( + '@dailydotdev/shared/src/hooks/profile/useTrackedAchievement', + () => ({ + useTrackedAchievement: jest.fn(), + }), +); + +jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('@dailydotdev/shared/src/hooks/useCoresFeature', () => ({ + useHasAccessToCores: jest.fn(), +})); + +jest.mock('@dailydotdev/shared/src/hooks/useQuestDashboard', () => ({ + useQuestDashboard: jest.fn(), +})); + +jest.mock('../components/ProtectedPage', () => ({ + __esModule: true, + default: ({ + children, + fallback, + shouldFallback, + }: { + children: React.ReactNode; + fallback?: React.ReactNode; + shouldFallback?: boolean; + }) => (shouldFallback ? fallback : children), +})); + +jest.mock('../pages/404', () => ({ + __esModule: true, + default: () => '404 page', +})); + const mockRequest = gqlClient.request as jest.Mock; +const mockUseQuery = useQuery as jest.Mock; +const mockUseAuthContext = useAuthContext as jest.Mock; +const mockUseProfileAchievements = useProfileAchievements as jest.Mock; +const mockUseTrackedAchievement = useTrackedAchievement as jest.Mock; +const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseHasAccessToCores = useHasAccessToCores as jest.Mock; +const mockUseQuestDashboard = useQuestDashboard as jest.Mock; const highestReputation = [ { @@ -128,3 +204,61 @@ describe('game center static props', () => { }); }); }); + +describe('game center client gating', () => { + beforeEach(() => { + mockUseAuthContext.mockReturnValue({ + user: { + id: 'user-1', + name: 'Test User', + username: 'test-user', + }, + }); + mockUseProfileAchievements.mockReturnValue({ + achievements: [], + unlockedCount: 0, + totalCount: 0, + isPending: false, + }); + mockUseTrackedAchievement.mockReturnValue({ + trackedAchievement: null, + isPending: false, + isTrackPending: false, + isUntrackPending: false, + trackAchievement: jest.fn(), + untrackAchievement: jest.fn(), + }); + mockUseHasAccessToCores.mockReturnValue(false); + mockUseQuestDashboard.mockReturnValue({ + data: undefined, + isPending: false, + }); + mockUseQuery.mockReturnValue({ + data: [], + isPending: false, + error: null, + }); + }); + + it('should render the 404 page when the quest feature is disabled', () => { + mockUseConditionalFeature + .mockReturnValueOnce({ + value: false, + isLoading: false, + }) + .mockReturnValueOnce({ + value: false, + isLoading: false, + }); + + render( + React.createElement(GameCenterPage, { + highestReputation: [], + mostQuestsCompleted: [], + questCompletionStats: null, + }), + ); + + expect(screen.getByText('404 page')).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 98227f4d12c..9347d7e8624 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -83,6 +83,7 @@ import { getLayout as getFooterNavBarLayout } from '../../components/layouts/Foo import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; import ProtectedPage from '../../components/ProtectedPage'; +import Custom404Seo from '../404'; import { defaultOpenGraph } from '../../next-seo'; import { getAchievementSummary, @@ -232,10 +233,11 @@ function GameCenterPage({ questCompletionStats, }: GameCenterPageProps): ReactElement { const { user } = useAuthContext(); - const { value: isQuestsFeatureEnabled } = useConditionalFeature({ - feature: questsFeature, - shouldEvaluate: !!user, - }); + const { value: isQuestsFeatureEnabled, isLoading: isQuestsFeatureLoading } = + useConditionalFeature({ + feature: questsFeature, + shouldEvaluate: !!user, + }); const { value: isAchievementTrackingEnabled } = useConditionalFeature({ feature: achievementTrackingWidgetFeature, shouldEvaluate: !!user, @@ -619,7 +621,10 @@ function GameCenterPage({ } return ( - + : } + >
Date: Tue, 24 Mar 2026 21:02:40 +0100 Subject: [PATCH 9/9] nullability fix --- packages/webapp/pages/game-center/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 9347d7e8624..f1095aa07cd 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -1103,11 +1103,11 @@ export async function getStaticProps(): Promise< }>; }; }; + const errorCode = error?.response?.errors?.[0]?.extensions?.code; if ( - [ApiError.NotFound, ApiError.Forbidden].includes( - error?.response?.errors?.[0]?.extensions?.code, - ) + errorCode && + [ApiError.NotFound, ApiError.Forbidden].includes(errorCode) ) { return { props: {