diff --git a/packages/shared/src/components/DataTile.tsx b/packages/shared/src/components/DataTile.tsx index 99fcc8b40a..0ef463ea9c 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 ( @@ -41,10 +43,14 @@ 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 30878f5d64..6ac4982cdd 100644 --- a/packages/shared/src/components/quest/QuestButton.spec.tsx +++ b/packages/shared/src/components/quest/QuestButton.spec.tsx @@ -188,6 +188,8 @@ const questDashboard = { xpInLevel: 250, xpToNextLevel: 150, }, + currentStreak: 0, + longestStreak: 0, daily: { regular: [ { diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index d4b9285b48..5333ff54c9 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -519,6 +519,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 694f116283..f0ec8adae6 100644 --- a/packages/shared/src/graphql/leaderboard.ts +++ b/packages/shared/src/graphql/leaderboard.ts @@ -56,9 +56,25 @@ export enum LeaderboardType { MostReadingDays = 'mostReadingDays', MostVerifiedUsers = 'mostVerifiedUsers', MostAchievementPoints = 'mostAchievementPoints', + MostQuestsCompleted = 'mostQuestsCompleted', HighestLevel = 'highestLevel', } +export const MOST_QUESTS_COMPLETED_LIMIT = 10; + +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', @@ -68,6 +84,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 +154,35 @@ export const MOST_ACHIEVEMENT_POINTS_QUERY = gql` ${LEADERBOARD_FRAGMENT} `; +export const MOST_QUESTS_COMPLETED_QUERY = gql` + query MostQuestsCompleted($limit: Int = ${MOST_QUESTS_COMPLETED_LIMIT}) { + mostQuestsCompleted(limit: $limit) { + ...LeaderboardFragment + } + } + ${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) { @@ -172,6 +218,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 84d53f0029..510719145a 100644 --- a/packages/shared/src/graphql/quests.ts +++ b/packages/shared/src/graphql/quests.ts @@ -59,6 +59,8 @@ export interface QuestLevel { export interface QuestDashboard { level: QuestLevel; + currentStreak: number; + longestStreak: number; daily: QuestBucket; weekly: QuestBucket; } @@ -68,7 +70,7 @@ export interface QuestDashboardData { } export interface ClaimQuestRewardData { - claimQuestReward: QuestDashboard; + claimQuestReward: Pick; } export interface QuestUpdate { @@ -107,6 +109,8 @@ export const QUEST_DASHBOARD_QUERY = gql` xpInLevel xpToNextLevel } + currentStreak + longestStreak 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 0000000000..2c2049591a --- /dev/null +++ b/packages/shared/src/graphql/users.spec.ts @@ -0,0 +1,21 @@ +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 4e57e15fc9..7627a688cd 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/shared/src/hooks/useClaimQuestReward.ts b/packages/shared/src/hooks/useClaimQuestReward.ts index bb33399f10..efbc0d83b0 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__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts new file mode 100644 index 0000000000..2bc9a4a84d --- /dev/null +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -0,0 +1,264 @@ +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'; +import { + HIGHEST_REPUTATION_QUERY, + MOST_QUESTS_COMPLETED_QUERY, + QUEST_COMPLETION_STATS_QUERY, +} from '@dailydotdev/shared/src/graphql/leaderboard'; +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'); + + return { + ...actual, + gqlClient: { + request: jest.fn(), + }, + }; +}); + +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 = [ + { + 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('game center 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 getGameCenterStaticProps(); + + 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 getGameCenterStaticProps(); + + expect(result).toMatchObject({ + props: { + highestReputation, + mostQuestsCompleted, + questCompletionStats: null, + }, + }); + }); +}); + +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/__tests__/UsersLeaderboardStaticProps.spec.ts b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts index ff075435cb..d3d2bab0d0 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/lib/gameCenter.spec.ts b/packages/webapp/lib/gameCenter.spec.ts new file mode 100644 index 0000000000..9382454acf --- /dev/null +++ b/packages/webapp/lib/gameCenter.spec.ts @@ -0,0 +1,270 @@ +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 './gameCenter'; + +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('game center helpers', () => { + it('builds quest summaries and prioritizes claimable quests', () => { + const dashboard: QuestDashboard = { + level: { + level: 7, + totalXp: 1450, + xpInLevel: 150, + xpToNextLevel: 250, + }, + currentStreak: 4, + longestStreak: 9, + 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/gameCenter.ts b/packages/webapp/lib/gameCenter.ts new file mode 100644 index 0000000000..58b0ee40cc --- /dev/null +++ b/packages/webapp/lib/gameCenter.ts @@ -0,0 +1,320 @@ +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 GameCenterQuestBucketSummary = { + all: UserQuest[]; + regular: UserQuest[]; + plus: UserQuest[]; + totalCount: number; + completedCount: number; + claimableCount: number; + inProgressCount: number; + lockedCount: number; + completionRate: number; +}; + +export type GameCenterQuestSummary = GameCenterQuestBucketSummary & { + daily: GameCenterQuestBucketSummary; + weekly: GameCenterQuestBucketSummary; + highlightedQuest: UserQuest | null; +}; + +const getQuestBucketSummary = ( + bucket?: QuestBucket, +): GameCenterQuestBucketSummary => { + 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, +): GameCenterQuestSummary => { + 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 GameCenterAchievementSummary = { + 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, +): GameCenterAchievementSummary => { + 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 GameCenterAwardSummary = { + awards: UserProductSummary[]; + totalAwards: number; + uniqueAwards: number; + favoriteAward: UserProductSummary | null; +}; + +export const getAwardSummary = ( + awards?: UserProductSummary[], +): GameCenterAwardSummary => { + 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 GameCenterBadgeSummary = { + totalBadges: number; + uniqueTopics: number; + latestBadge: TopReader | null; + mostEarnedBadge: TopReader | null; + mostEarnedBadgeCount: number; +}; + +export const getBadgeSummary = ( + badges?: TopReader[], +): GameCenterBadgeSummary => { + 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/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx new file mode 100644 index 0000000000..f1095aa07c --- /dev/null +++ b/packages/webapp/pages/game-center/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 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 { 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 { 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, +} 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 { 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 { + 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, +} 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 Custom404Seo from '../404'; +import { defaultOpenGraph } from '../../next-seo'; +import { + getAchievementSummary, + getAwardSummary, + getBadgeSummary, + getQuestSummary, + getTopReaderTopicLabel, +} from '../../lib/gameCenter'; + +type GameCenterPageProps = { + highestReputation: UserLeaderboard[]; + mostQuestsCompleted: UserLeaderboard[]; + questCompletionStats: QuestCompletionStats | null; +}; + +type SectionProps = { + title: string; + description: string; + action?: ReactElement; +}; + +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, + 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('Game Center'); +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 GameCenterPage({ + highestReputation, + mostQuestsCompleted, + questCompletionStats, +}: GameCenterPageProps): ReactElement { + const { user } = useAuthContext(); + const { value: isQuestsFeatureEnabled, isLoading: isQuestsFeatureLoading } = + 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 topReaderQueryKey = generateQueryKey( + RequestKey.TopReaderBadge, + user, + 'game-center: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; + const hasCommunityLeaderboards = + highestReputation.length > 0 || mostQuestsCompleted.length > 0; + 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 ( + : } + > +
+ + + Game Center + + + +
+
+
+
+
+
+
+
+ + Progress snapshot + + + {firstName}, here's how you're doing. + + + 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. + +
+ +
+ + + +
+ +
+
+ + 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 + + + )} +
+
+
+ + + +
+ + + Open full leaderboards + + + + } + /> + {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 && ( + + )} + {mostQuestsCompleted.length > 0 && ( + + )} +
+ ) : ( + + )} + + + + +
+ + + View all achievements + + + + ) : undefined + } + /> + + {achievementShelfContent} +
+ + + +
+ + + {badgeCaseContent} +
+ + + +
+ + + {trophyCaseContent} +
+ + +
+ ); +} + +const getGameCenterLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +GameCenterPage.getLayout = getGameCenterLayout; +GameCenterPage.layoutProps = { screenCentered: false, seo }; + +export default GameCenterPage; + +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 }), + ]); + 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, + }; + } catch (err: unknown) { + const error = err as { + response?: { + errors?: Array<{ + extensions?: { + code?: ApiError; + }; + }>; + }; + }; + const errorCode = error?.response?.errors?.[0]?.extensions?.code; + + if ( + errorCode && + [ApiError.NotFound, ApiError.Forbidden].includes(errorCode) + ) { + return { + props: { + highestReputation: [], + mostQuestsCompleted: [], + questCompletionStats: null, + }, + revalidate: 300, + }; + } + + return { + props: { + highestReputation: [], + mostQuestsCompleted: [], + questCompletionStats: null, + }, + revalidate: 300, + }; + } +} diff --git a/packages/webapp/pages/users/[id].tsx b/packages/webapp/pages/users/[id].tsx index de8cc1bcf8..7f066ea3be 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] || []; diff --git a/packages/webapp/tsconfig.eslint.json b/packages/webapp/tsconfig.eslint.json index f6da372082..6e6b8ab149 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" ] }