diff --git a/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx b/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx index 3afa319a09c..1bb47b35cfb 100644 --- a/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx +++ b/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx @@ -114,7 +114,7 @@ const defaultProfileAchievementsHook = { achievements: [mockTrackedAchievement], unlockedCount: 0, totalCount: 1, - totalPoints: 0, + totalRewardValue: 0, isPending: false, isError: false, }; diff --git a/packages/shared/src/components/modals/AchievementCompletionModal.tsx b/packages/shared/src/components/modals/AchievementCompletionModal.tsx index 4c4a092daff..7b5cfaf752c 100644 --- a/packages/shared/src/components/modals/AchievementCompletionModal.tsx +++ b/packages/shared/src/components/modals/AchievementCompletionModal.tsx @@ -22,6 +22,8 @@ import { } from '../typography/Typography'; import { Checkbox } from '../fields/Checkbox'; import { getTargetCount } from '../../graphql/user/achievements'; +import { formatAchievementReward } from '../../lib/achievements'; +import { useAchievementRewardDisplay } from '../../hooks/useAchievementRewardDisplay'; import { sortLockedAchievements } from './achievement/sortAchievements'; const SPARKLE_DURATION_MS = 4500; @@ -54,6 +56,7 @@ export const AchievementCompletionModal = ({ const { trackedAchievement, trackAchievement } = useTrackedAchievement( user?.id, ); + const { showAchievementXp } = useAchievementRewardDisplay(); const isOptedOut = checkHasCompleted(ActionType.DisableAchievementCompletion); @@ -80,8 +83,10 @@ export const AchievementCompletionModal = ({ ); const lockedAchievements = useMemo(() => { - return achievements ? sortLockedAchievements(achievements) : []; - }, [achievements]); + return achievements + ? sortLockedAchievements(achievements, showAchievementXp) + : []; + }, [achievements, showAchievementXp]); useEffect(() => { if (!showSparkles) { @@ -209,7 +214,10 @@ export const AchievementCompletionModal = ({ {unlockedAchievement.achievement.description}
- +{unlockedAchievement.achievement.points} points + {formatAchievementReward(unlockedAchievement.achievement, { + showAchievementXp, + signed: true, + })}
@@ -353,7 +361,13 @@ export const AchievementCompletionModal = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - {userAchievement.achievement.points} pts + {formatAchievementReward( + userAchievement.achievement, + { + showAchievementXp, + short: !showAchievementXp, + }, + )}
diff --git a/packages/shared/src/components/modals/AchievementPickerModal.tsx b/packages/shared/src/components/modals/AchievementPickerModal.tsx index ddfc5e3e94f..759219ac3c7 100644 --- a/packages/shared/src/components/modals/AchievementPickerModal.tsx +++ b/packages/shared/src/components/modals/AchievementPickerModal.tsx @@ -16,7 +16,9 @@ import type { UserAchievement } from '../../graphql/user/achievements'; import { getTargetCount } from '../../graphql/user/achievements'; import { sortLockedAchievements } from './achievement/sortAchievements'; import { useLogContext } from '../../contexts/LogContext'; +import { formatAchievementReward } from '../../lib/achievements'; import { LogEvent, TargetType } from '../../lib/log'; +import { useAchievementRewardDisplay } from '../../hooks/useAchievementRewardDisplay'; export interface AchievementPickerModalProps extends ModalProps { achievements: UserAchievement[]; @@ -36,10 +38,11 @@ export const AchievementPickerModal = ({ const [isTracking, setIsTracking] = useState(false); const [isUntracking, setIsUntracking] = useState(false); const { logEvent } = useLogContext(); + const { showAchievementXp } = useAchievementRewardDisplay(); const lockedAchievements = useMemo(() => { - return sortLockedAchievements(achievements); - }, [achievements]); + return sortLockedAchievements(achievements, showAchievementXp); + }, [achievements, showAchievementXp]); const handleTrack = async (achievementId: string) => { setIsTracking(true); @@ -165,7 +168,10 @@ export const AchievementPickerModal = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - {userAchievement.achievement.points} pts + {formatAchievementReward(userAchievement.achievement, { + showAchievementXp, + short: !showAchievementXp, + })}
showcaseAchievements.map((sa) => sa.achievement.id), @@ -54,9 +60,12 @@ export const AchievementShowcaseModal = ({ if (aSelected !== bSelected) { return aSelected ? -1 : 1; } - return b.achievement.points - a.achievement.points; + return ( + getAchievementRewardValue(b.achievement, showAchievementXp) - + getAchievementRewardValue(a.achievement, showAchievementXp) + ); }); - }, [unlockedAchievements, initialSelectedIds]); + }, [initialSelectedIds, showAchievementXp, unlockedAchievements]); const toggleSelection = (achievementId: string) => { setSelectedIds((prev) => { @@ -168,7 +177,10 @@ export const AchievementShowcaseModal = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - {userAchievement.achievement.points} pts + {formatAchievementReward(userAchievement.achievement, { + showAchievementXp, + short: !showAchievementXp, + })}
, + showAchievementXp: boolean, ): UserAchievement[] => { return [...achievements].sort((a, b) => { const myA = myMap.get(a.achievement.id); @@ -54,7 +60,10 @@ const sortByMyStatus = ( if (rarityA !== rarityB) { return rarityA - rarityB; } - return b.achievement.points - a.achievement.points; + return ( + getAchievementRewardValue(b.achievement, showAchievementXp) - + getAchievementRewardValue(a.achievement, showAchievementXp) + ); } const targetA = getTargetCount(a.achievement); @@ -75,6 +84,7 @@ export const CompareAchievementsModal = ({ const { user: loggedUser } = useAuthContext(); const { achievements: myAchievements, isPending } = useProfileAchievements(loggedUser); + const { showAchievementXp } = useAchievementRewardDisplay(); const { sorted, myMap, theirMap } = useMemo(() => { const empty = { @@ -106,14 +116,17 @@ export const CompareAchievementsModal = ({ ); return { - sorted: sortByMyStatus(allAchievements, my), + sorted: sortByMyStatus(allAchievements, my, showAchievementXp), myMap: my, theirMap: theirs, }; - }, [myAchievements, profileAchievements]); + }, [myAchievements, profileAchievements, showAchievementXp]); - const handleClose = (event?: React.MouseEvent | React.KeyboardEvent): void => + const handleClose = ( + event: React.MouseEvent | React.KeyboardEvent, + ): void => { onRequestClose?.(event); + }; return (
- + - {ua.achievement.points} pts + {formatAchievementReward(ua.achievement, { + showAchievementXp, + short: !showAchievementXp, + })}
{ const targetCount = getTargetCount(achievement.achievement); @@ -13,6 +14,7 @@ const getProgressRatio = (achievement: UserAchievement): number => { export const sortLockedAchievements = ( achievements: UserAchievement[], + showAchievementXp = false, ): UserAchievement[] => { return achievements .filter((achievement) => !achievement.unlockedAt) @@ -26,6 +28,9 @@ export const sortLockedAchievements = ( return b.progress - a.progress; } - return b.achievement.points - a.achievement.points; + return ( + getAchievementRewardValue(b.achievement, showAchievementXp) - + getAchievementRewardValue(a.achievement, showAchievementXp) + ); }); }; diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncModal.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncModal.tsx index 9c1b6480071..53b3a8de7a8 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncModal.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncModal.tsx @@ -16,6 +16,14 @@ import type { AchievementSyncResult, UserAchievement, } from '../../../../graphql/user/achievements'; +import { + formatAchievementReward, + formatAchievementRewardAmount, + getAchievementMetricLabel, + getAchievementRewardTotal, + getAchievementRewardValue, +} from '../../../../lib/achievements'; +import { useAchievementRewardDisplay } from '../../../../hooks/useAchievementRewardDisplay'; import { useLogContext } from '../../../../contexts/LogContext'; import { LogEvent } from '../../../../lib/log'; @@ -43,6 +51,7 @@ const sparkles = Array.from({ length: 60 }, (_, i) => ({ interface AchievementSyncModalProps extends Omit { result: AchievementSyncResult | null; + baseRewardValue: number; isPending: boolean; } @@ -56,10 +65,12 @@ const AchievementRevealCard = ({ achievement, stackIndex, isExiting, + showAchievementXp, }: { achievement: UserAchievement; stackIndex: number; isExiting: boolean; + showAchievementXp: boolean; }): ReactElement => { const isTop = stackIndex === 0; const position = @@ -98,7 +109,10 @@ const AchievementRevealCard = ({ {achievement.achievement.description} - +{achievement.achievement.points} points + {formatAchievementReward(achievement.achievement, { + showAchievementXp, + signed: true, + })}
); @@ -106,11 +120,13 @@ const AchievementRevealCard = ({ export const AchievementSyncModal = ({ result, + baseRewardValue, isPending, onRequestClose, ...rest }: AchievementSyncModalProps): ReactElement => { const { logEvent } = useLogContext(); + const { showAchievementXp } = useAchievementRewardDisplay(); const [contentVisible, setContentVisible] = useState(false); const [isReady, setIsReady] = useState(false); const [score, setScore] = useState(0); @@ -141,6 +157,17 @@ export const AchievementSyncModal = ({ ); }, [currentIndex, result]); + const rewardValueGained = useMemo(() => { + if (!result) { + return 0; + } + + return getAchievementRewardTotal( + result.newlyUnlockedAchievements, + showAchievementXp, + ); + }, [result, showAchievementXp]); + useEffect(() => { if (!result) { setScore(0); @@ -152,15 +179,13 @@ export const AchievementSyncModal = ({ return; } - const baseScore = result.totalPoints - result.pointsGained; - - setScore(baseScore); + setScore(baseRewardValue); setCurrentIndex(0); setIsFading(false); setIsRevealComplete(result.newlyUnlockedAchievements.length === 0); setShowSparkles(false); setSparklesFalling(false); - }, [result]); + }, [baseRewardValue, result]); useEffect(() => { if (!result || isPending || !isReady || isRevealComplete) { @@ -169,7 +194,7 @@ export const AchievementSyncModal = ({ if (currentIndex >= result.newlyUnlockedAchievements.length) { setIsRevealComplete(true); - if (result.pointsGained > 0) { + if (rewardValueGained > 0) { setShowSparkles(true); } return undefined; @@ -183,7 +208,10 @@ export const AchievementSyncModal = ({ setScore( (value) => value + - result.newlyUnlockedAchievements[currentIndex].achievement.points, + getAchievementRewardValue( + result.newlyUnlockedAchievements[currentIndex].achievement, + showAchievementXp, + ), ); setIsScoreShaking(true); setCurrentIndex((value) => value + 1); @@ -194,7 +222,15 @@ export const AchievementSyncModal = ({ clearTimeout(fadeTimer); clearTimeout(nextTimer); }; - }, [currentIndex, isPending, isReady, isRevealComplete, result]); + }, [ + currentIndex, + isPending, + isReady, + isRevealComplete, + result, + rewardValueGained, + showAchievementXp, + ]); useEffect(() => { if (!isScoreShaking) { @@ -230,11 +266,11 @@ export const AchievementSyncModal = ({ logEvent({ event_name: LogEvent.CompleteSyncAchievements, extra: JSON.stringify({ - points_gained: result.pointsGained, + reward_gained: rewardValueGained, newly_unlocked: result.newlyUnlockedAchievements.length, }), }); - }, [isRevealComplete, logEvent, result]); + }, [isRevealComplete, logEvent, result, rewardValueGained]); return ( - Achievement points + {getAchievementMetricLabel(showAchievementXp)} ))}
@@ -340,8 +377,14 @@ export const AchievementSyncModal = ({ Sync complete - {result.pointsGained > 0 - ? `Congrats! +${result.pointsGained} points earned.` + {rewardValueGained > 0 + ? `Congrats! ${formatAchievementRewardAmount( + rewardValueGained, + { + showAchievementXp, + signed: true, + }, + )} earned.` : 'No new achievements unlocked this time.'} diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncPromptCheck.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncPromptCheck.tsx index 9aa31117791..edfdd02143e 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncPromptCheck.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementSyncPromptCheck.tsx @@ -3,11 +3,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import type { PublicProfile } from '../../../../lib/user'; import { useAuthContext } from '../../../../contexts/AuthContext'; import { useAchievementSync } from '../../../../hooks/profile/useAchievementSync'; +import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchievements'; import { useActions } from '../../../../hooks'; import { useLazyModal } from '../../../../hooks/useLazyModal'; import { ActionType } from '../../../../graphql/actions'; import { LazyModal } from '../../../../components/modals/common/types'; import type { AchievementSyncResult } from '../../../../graphql/user/achievements'; +import { getAchievementRewardTotal } from '../../../../lib/achievements'; import { AchievementSyncModal } from './AchievementSyncModal'; import { ACHIEVEMENTS_LAUNCH_DATE } from '../achievements/constants'; @@ -24,6 +26,10 @@ export function AchievementSyncPromptCheck({ isOwner && new Date(user.createdAt) < ACHIEVEMENTS_LAUNCH_DATE; const { syncStatus, syncAchievements, isSyncing, isStatusPending } = useAchievementSync(user); + const { + achievements, + showAchievementXp, + } = useProfileAchievements(user, isOwner); const { isActionsFetched, checkHasCompleted } = useActions(); const { openModal } = useLazyModal(); const [syncResult, setSyncResult] = useState( @@ -81,6 +87,11 @@ export function AchievementSyncPromptCheck({ isOpen={isSyncModalOpen} onRequestClose={() => setIsSyncModalOpen(false)} result={syncResult} + baseRewardValue={getAchievementRewardTotal( + achievements?.filter((achievement) => achievement.unlockedAt !== null) ?? + [], + showAchievementXp, + )} isPending={isSyncing} /> ); diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx index f3b4ad2bd77..907e7270513 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx @@ -129,7 +129,7 @@ describe('AchievementsWidget', () => { achievements, unlockedCount: 6, totalCount: achievements.length, - totalPoints: 220, + totalRewardValue: 220, isPending: false, isError: false, }); diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index 2e246ff8c46..7ad0ade2d40 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -14,6 +14,7 @@ import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchi import { ClickableText } from '../../../../components/buttons/ClickableText'; import { MedalBadgeIcon } from '../../../../components/icons'; import { LazyImage } from '../../../../components/LazyImage'; +import { getAchievementRewardValue } from '../../../../lib/achievements'; import { getAchievementRarityTier, rarityGlowClasses, @@ -21,6 +22,7 @@ import { import { RaritySparkles } from '../achievements/RaritySparkles'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { AchievementCard } from '../achievements/AchievementCard'; +import { useAchievementRewardDisplay } from '../../../../hooks/useAchievementRewardDisplay'; interface AchievementsWidgetProps { user: PublicProfile; @@ -45,6 +47,7 @@ function RecentAchievements({ user: PublicProfile; }): ReactElement | null { const { achievements, isPending } = useProfileAchievements(user); + const { showAchievementXp } = useAchievementRewardDisplay(); const rarestUnlocked = achievements ?.filter((a) => a.unlockedAt !== null) @@ -55,9 +58,11 @@ function RecentAchievements({ return rarityA - rarityB; } - const pointsDelta = b.achievement.points - a.achievement.points; - if (pointsDelta !== 0) { - return pointsDelta; + const rewardDelta = + getAchievementRewardValue(b.achievement, showAchievementXp) - + getAchievementRewardValue(a.achievement, showAchievementXp); + if (rewardDelta !== 0) { + return rewardDelta; } const unlockedDateA = a.unlockedAt ? new Date(a.unlockedAt).getTime() : 0; diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx index f135a067809..08b3a08c207 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx @@ -16,6 +16,7 @@ const createLockedAchievement = ( type: AchievementType.Milestone, criteria: { targetCount: 1 }, points: 10, + xp: 25, rarity: null, unit: null, }, @@ -40,6 +41,7 @@ const renderCard = ( isOwner onTrack={jest.fn()} onUntrack={jest.fn()} + showXpValue={false} {...props} /> , @@ -47,6 +49,27 @@ const renderCard = ( }; describe('AchievementCard — stop tracking', () => { + it('renders achievement points when quests is disabled', () => { + renderCard(); + + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.queryByText('XP')).not.toBeInTheDocument(); + expect(screen.queryByText('25')).not.toBeInTheDocument(); + }); + + it('renders achievement xp when quests is enabled', () => { + renderCard({ showXpValue: true }); + + const xpLabel = screen.getByText('XP'); + + expect(xpLabel.parentElement).toHaveTextContent('25 XP'); + expect(xpLabel).toHaveClass( + 'font-inherit', + 'text-accent-avocado-default', + ); + expect(screen.queryByText('10')).not.toBeInTheDocument(); + }); + it('renders "Track" button when the achievement is not tracked', () => { renderCard({ isTracked: false }); expect(screen.getByRole('button', { name: /track/i })).toBeInTheDocument(); diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx index e04f8eaedee..65c7b7cca0c 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx @@ -21,8 +21,10 @@ import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; import { LazyImage } from '../../../../components/LazyImage'; import { ProgressBar } from '../../../../components/fields/ProgressBar'; import HoverCard from '../../../../components/cards/common/HoverCard'; +import { getAchievementRewardValue } from '../../../../lib/achievements'; import { anchorDefaultRel } from '../../../../lib/strings'; import { PinIcon } from '../../../../components/icons'; +import { useAchievementRewardDisplay } from '../../../../hooks/useAchievementRewardDisplay'; import { AchievementRarityTier, getAchievementRarityTier, @@ -38,6 +40,7 @@ interface AchievementCardProps { onTrack?: (achievementId: string) => Promise; onUntrack?: () => Promise; isUntrackPending?: boolean; + showXpValue?: boolean; } export function AchievementCard({ @@ -48,13 +51,19 @@ export function AchievementCard({ onTrack, onUntrack, isUntrackPending = false, + showXpValue, }: AchievementCardProps): ReactElement { const { achievement, progress, unlockedAt } = userAchievement; + const { showAchievementXp } = useAchievementRewardDisplay(); const targetCount = getTargetCount(achievement); const isUnlocked = unlockedAt !== null; const progressPercentage = Math.min((progress / targetCount) * 100, 100); const showProgress = achievement.type === AchievementType.Milestone && !isUnlocked; + const shouldShowXpValue = showXpValue ?? showAchievementXp; + const shouldShowXpLabel = + shouldShowXpValue && achievement.xp != null; + const rewardValue = getAchievementRewardValue(achievement, shouldShowXpValue); const rarityTier = isUnlocked ? getAchievementRarityTier(achievement.rarity) : null; @@ -129,7 +138,15 @@ export function AchievementCard({ } bold > - {achievement.points} + {rewardValue} + {shouldShowXpLabel && ( + <> + {' '} + + XP + + + )} diff --git a/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx b/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx index 8d71b630627..eea76524a17 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx @@ -24,9 +24,11 @@ import { useLazyModal } from '../../../../hooks/useLazyModal'; import { ActionType } from '../../../../graphql/actions'; import { LazyModal } from '../../../../components/modals/common/types'; import { achievementTrackingWidgetFeature } from '../../../../lib/featureManagement'; +import { getAchievementRewardValue } from '../../../../lib/achievements'; import { useLogContext } from '../../../../contexts/LogContext'; import { LogEvent, TargetType } from '../../../../lib/log'; import { ACHIEVEMENTS_LAUNCH_DATE } from './constants'; +import { useAchievementRewardDisplay } from '../../../../hooks/useAchievementRewardDisplay'; type FilterType = 'all' | 'unlocked' | 'locked'; type SyncOrigin = 'achievements_list' | 'sync_prompt_modal'; @@ -78,10 +80,12 @@ export function AchievementsList({ const { syncStatus, syncAchievements, isSyncing, isStatusPending } = useAchievementSync(user); const { logEvent } = useLogContext(); + const { showAchievementXp } = useAchievementRewardDisplay(); const [syncResult, setSyncResult] = useState( null, ); const [isSyncModalOpen, setIsSyncModalOpen] = useState(false); + const [syncBaseRewardValue, setSyncBaseRewardValue] = useState(0); const { isActionsFetched, checkHasCompleted } = useActions(); const { openModal } = useLazyModal(); @@ -106,6 +110,19 @@ export function AchievementsList({ extra: JSON.stringify({ origin }), }); + setSyncBaseRewardValue( + achievements + .filter((achievement) => achievement.unlockedAt !== null) + .reduce( + (total, achievement) => + total + + getAchievementRewardValue( + achievement.achievement, + showAchievementXp, + ), + 0, + ), + ); setSyncResult(null); setIsSyncModalOpen(true); @@ -116,7 +133,15 @@ export function AchievementsList({ setIsSyncModalOpen(false); } }, - [isOwner, isSyncing, logEvent, syncAchievements, syncStatus?.canSync], + [ + achievements, + isOwner, + isSyncing, + logEvent, + showAchievementXp, + syncAchievements, + syncStatus?.canSync, + ], ); useEffect(() => { @@ -180,14 +205,17 @@ export function AchievementsList({ if (!a.unlockedAt && b.unlockedAt) { return 1; } - // Among unlocked, sort by rarity (rarest first), then points (highest first) + // Among unlocked, sort by rarity (rarest first), then reward value (highest first) if (a.unlockedAt && b.unlockedAt) { const rarityA = a.achievement.rarity ?? Infinity; const rarityB = b.achievement.rarity ?? Infinity; if (rarityA !== rarityB) { return rarityA - rarityB; } - return b.achievement.points - a.achievement.points; + return ( + getAchievementRewardValue(b.achievement, showAchievementXp) - + getAchievementRewardValue(a.achievement, showAchievementXp) + ); } // Among locked, sort by progress percentage (highest first) const targetA = getTargetCount(a.achievement); @@ -204,7 +232,7 @@ export function AchievementsList({ return sorted.filter((a) => a.unlockedAt !== null); } return sorted.filter((a) => a.unlockedAt === null); - }, [achievements, filter]); + }, [achievements, filter, showAchievementXp]); const unlockedCount = achievements.filter( (a) => a.unlockedAt !== null, @@ -288,6 +316,7 @@ export function AchievementsList({ isOpen={isSyncModalOpen} onRequestClose={() => setIsSyncModalOpen(false)} result={syncResult} + baseRewardValue={syncBaseRewardValue} isPending={isSyncing} /> )} diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx index bc053202735..df5da340d34 100644 --- a/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx @@ -72,7 +72,6 @@ export function ProfileAchievements({ achievements, unlockedCount, totalCount, - totalPoints, isPending, isError, } = useProfileAchievements(user); @@ -148,14 +147,14 @@ export function ProfileAchievements({ color={TypographyColor.Primary} bold > - {totalPoints.toLocaleString()} + {unlockedCount}/{totalCount} - ({unlockedCount}/{totalCount}) + unlocked {loggedUser && !isOwner && ( diff --git a/packages/shared/src/graphql/leaderboard.ts b/packages/shared/src/graphql/leaderboard.ts index f0ec8adae61..98835051202 100644 --- a/packages/shared/src/graphql/leaderboard.ts +++ b/packages/shared/src/graphql/leaderboard.ts @@ -88,6 +88,17 @@ export const leaderboardTypeToTitle: Record = { [LeaderboardType.HighestLevel]: 'Highest level', }; +export const getLeaderboardTitle = ( + type: LeaderboardType, + showAchievementXp = false, +): string => { + if (type === LeaderboardType.MostAchievementPoints && showAchievementXp) { + return 'Most achievement XP'; + } + + return leaderboardTypeToTitle[type]; +}; + export const isCompanyLeaderboard = (type: LeaderboardType): boolean => type === LeaderboardType.MostVerifiedUsers; diff --git a/packages/shared/src/graphql/user/achievements.ts b/packages/shared/src/graphql/user/achievements.ts index 947a047365c..65656e6101e 100644 --- a/packages/shared/src/graphql/user/achievements.ts +++ b/packages/shared/src/graphql/user/achievements.ts @@ -20,6 +20,7 @@ export interface Achievement { type: AchievementType; criteria?: AchievementCriteria; points: number; + xp?: number | null; rarity: number | null; unit: string | null; } @@ -52,8 +53,6 @@ export interface AchievementSyncStatus { } export interface AchievementSyncResult extends AchievementSyncStatus { - pointsGained: number; - totalPoints: number; newlyUnlockedAchievements: UserAchievement[]; closeAchievements: UserAchievement[]; } @@ -95,6 +94,7 @@ const ACHIEVEMENT_FRAGMENT = gql` targetCount } points + xp rarity unit } @@ -180,8 +180,6 @@ export const SYNC_ACHIEVEMENTS_MUTATION = gql` remainingSyncs canSync syncedAchievements - pointsGained - totalPoints newlyUnlockedAchievements { achievement { ...AchievementFragment diff --git a/packages/shared/src/hooks/profile/useProfileAchievements.ts b/packages/shared/src/hooks/profile/useProfileAchievements.ts index 6f13871f177..c9cada733e9 100644 --- a/packages/shared/src/hooks/profile/useProfileAchievements.ts +++ b/packages/shared/src/hooks/profile/useProfileAchievements.ts @@ -2,12 +2,15 @@ import { useQuery } from '@tanstack/react-query'; import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; import type { UserAchievement } from '../../graphql/user/achievements'; import { getUserAchievements } from '../../graphql/user/achievements'; +import { getAchievementRewardTotal } from '../../lib/achievements'; +import { useAchievementRewardDisplay } from '../useAchievementRewardDisplay'; interface UseProfileAchievementsResult { achievements: UserAchievement[] | undefined; unlockedCount: number; totalCount: number; - totalPoints: number; + totalRewardValue: number; + showAchievementXp: boolean; isPending: boolean; isError: boolean; } @@ -21,6 +24,7 @@ export function useProfileAchievements( user ?? undefined, 'profile', ); + const { showAchievementXp } = useAchievementRewardDisplay(); const { data, isPending, isError } = useQuery({ queryKey, @@ -38,16 +42,17 @@ export function useProfileAchievements( const unlocked = data?.filter((a) => a.unlockedAt !== null) ?? []; const unlockedCount = unlocked.length; const totalCount = data?.length ?? 0; - const totalPoints = unlocked.reduce( - (sum, a) => sum + (a.achievement.points ?? 0), - 0, + const totalRewardValue = getAchievementRewardTotal( + unlocked, + showAchievementXp, ); return { achievements: data, unlockedCount, totalCount, - totalPoints, + totalRewardValue, + showAchievementXp, isPending, isError, }; diff --git a/packages/shared/src/hooks/useAchievementRewardDisplay.ts b/packages/shared/src/hooks/useAchievementRewardDisplay.ts new file mode 100644 index 00000000000..d46abfbe1c1 --- /dev/null +++ b/packages/shared/src/hooks/useAchievementRewardDisplay.ts @@ -0,0 +1,16 @@ +import { + useFeature, + useFeaturesReadyContext, +} from '../components/GrowthBookProvider'; +import { questsFeature } from '../lib/featureManagement'; + +export const useAchievementRewardDisplay = (): { + showAchievementXp: boolean; +} => { + const { ready } = useFeaturesReadyContext(); + const isQuestsFeatureEnabled = useFeature(questsFeature); + + return { + showAchievementXp: ready && isQuestsFeatureEnabled === true, + }; +}; diff --git a/packages/shared/src/lib/achievements.spec.ts b/packages/shared/src/lib/achievements.spec.ts index d50fd0cc66f..0f14e05b5b8 100644 --- a/packages/shared/src/lib/achievements.spec.ts +++ b/packages/shared/src/lib/achievements.spec.ts @@ -1,9 +1,103 @@ import { + formatAchievementReward, + formatAchievementRewardAmount, + getAchievementLeaderboardTitle, + getAchievementMetricLabel, + getAchievementRewardLabel, + getAchievementRewardTotal, + getAchievementRewardValue, hasCompletedAllAchievements, shouldShowAchievementTracker, } from './achievements'; describe('achievements', () => { + it('should use xp values when quests mode is enabled', () => { + expect( + getAchievementRewardValue({ points: 50, xp: 120 }, true), + ).toBe(120); + expect( + getAchievementRewardValue({ points: 50, xp: null }, true), + ).toBe(50); + expect( + getAchievementRewardValue({ points: 50, xp: 120 }, false), + ).toBe(50); + }); + + it('should format achievement reward labels and totals', () => { + expect( + getAchievementRewardLabel({ showAchievementXp: true }), + ).toBe('XP'); + expect( + getAchievementRewardLabel({ showAchievementXp: false, amount: 1 }), + ).toBe('point'); + expect( + getAchievementRewardLabel({ + showAchievementXp: false, + amount: 2, + short: true, + }), + ).toBe('pts'); + expect( + formatAchievementRewardAmount(120, { + showAchievementXp: true, + signed: true, + }), + ).toBe('+120 XP'); + expect( + formatAchievementReward({ points: 50, xp: 120 }, { showAchievementXp: true }), + ).toBe('120 XP'); + expect( + getAchievementRewardTotal( + [ + { + achievement: { + id: 'a', + name: 'A', + description: 'A', + image: '', + type: 'instant' as never, + points: 5, + xp: 10, + rarity: null, + unit: null, + }, + progress: 1, + unlockedAt: new Date().toISOString(), + createdAt: null, + updatedAt: null, + }, + { + achievement: { + id: 'b', + name: 'B', + description: 'B', + image: '', + type: 'instant' as never, + points: 10, + xp: 25, + rarity: null, + unit: null, + }, + progress: 1, + unlockedAt: new Date().toISOString(), + createdAt: null, + updatedAt: null, + }, + ], + true, + ), + ).toBe(35); + }); + + it('should return quests-aware achievement labels', () => { + expect(getAchievementMetricLabel(true)).toBe('Achievement XP'); + expect(getAchievementMetricLabel(false)).toBe('Achievement points'); + expect(getAchievementLeaderboardTitle(true)).toBe('Most achievement XP'); + expect(getAchievementLeaderboardTitle(false)).toBe( + 'Most achievement points', + ); + }); + it('should mark achievements as completed only when all are unlocked', () => { expect( hasCompletedAllAchievements({ unlockedCount: 10, totalCount: 10 }), diff --git a/packages/shared/src/lib/achievements.ts b/packages/shared/src/lib/achievements.ts index d4f78a671b8..e2d263af7fa 100644 --- a/packages/shared/src/lib/achievements.ts +++ b/packages/shared/src/lib/achievements.ts @@ -1,9 +1,89 @@ +import type { + Achievement, + UserAchievement, +} from '../graphql/user/achievements'; + interface AchievementTrackerVisibilityParams { isExperimentEnabled: boolean; unlockedCount: number; totalCount: number; } +interface FormatAchievementRewardOptions { + showAchievementXp: boolean; + short?: boolean; + signed?: boolean; +} + +export const getAchievementRewardValue = ( + achievement: Pick, + showAchievementXp: boolean, +): number => + showAchievementXp && achievement.xp != null + ? achievement.xp + : achievement.points; + +export const getAchievementRewardLabel = ({ + showAchievementXp, + amount, + short = false, +}: { + showAchievementXp: boolean; + amount?: number; + short?: boolean; +}): string => { + if (showAchievementXp) { + return 'XP'; + } + + if (short) { + return 'pts'; + } + + return amount === 1 ? 'point' : 'points'; +}; + +export const formatAchievementRewardAmount = ( + amount: number, + { showAchievementXp, short = false, signed = false }: FormatAchievementRewardOptions, +): string => { + const prefix = signed ? '+' : ''; + return `${prefix}${amount.toLocaleString()} ${getAchievementRewardLabel({ + showAchievementXp, + amount, + short, + })}`; +}; + +export const formatAchievementReward = ( + achievement: Pick, + options: FormatAchievementRewardOptions, +): string => + formatAchievementRewardAmount( + getAchievementRewardValue(achievement, options.showAchievementXp), + options, + ); + +export const getAchievementRewardTotal = ( + achievements: UserAchievement[], + showAchievementXp: boolean, +): number => + achievements.reduce( + (sum, achievement) => + sum + + getAchievementRewardValue(achievement.achievement, showAchievementXp), + 0, + ); + +export const getAchievementMetricLabel = ( + showAchievementXp: boolean, +): string => (showAchievementXp ? 'Achievement XP' : 'Achievement points'); + +export const getAchievementLeaderboardTitle = ( + showAchievementXp: boolean, +): string => + showAchievementXp ? 'Most achievement XP' : 'Most achievement points'; + export const hasCompletedAllAchievements = ({ unlockedCount, totalCount, diff --git a/packages/webapp/lib/gameCenter.spec.ts b/packages/webapp/lib/gameCenter.spec.ts index c7e66f2babc..847139a329a 100644 --- a/packages/webapp/lib/gameCenter.spec.ts +++ b/packages/webapp/lib/gameCenter.spec.ts @@ -48,6 +48,7 @@ const createAchievement = ( id: string; name: string; points?: number; + xp?: number; }, ): UserAchievement => ({ achievement: { @@ -58,6 +59,7 @@ const createAchievement = ( type: AchievementType.Milestone, criteria: { targetCount: 10 }, points: overrides.points ?? 100, + xp: overrides.xp ?? 200, rarity: 10, unit: 'posts', }, @@ -230,7 +232,7 @@ describe('game center helpers', () => { expect(summary.unlockedCount).toBe(2); expect(summary.totalCount).toBe(3); - expect(summary.totalPoints).toBe(320); + expect(summary.totalRewardValue).toBe(320); expect(summary.nextToUnlock?.achievement.id).toBe('tracked'); expect(summary.latestUnlocked?.achievement.id).toBe('latest'); expect(summary.rarestUnlocked?.achievement.id).toBe('rare'); @@ -239,6 +241,28 @@ describe('game center helpers', () => { ).toEqual(['tracked', 'latest', 'rare']); }); + it('uses xp values for achievement summaries when quests mode is enabled', () => { + const unlocked = createAchievement({ + id: 'unlocked', + name: 'Unlocked', + points: 100, + xp: 250, + unlockedAt: '2025-03-10T00:00:00.000Z', + }); + const tracked = createAchievement({ + id: 'tracked', + name: 'Tracked', + points: 50, + xp: 175, + progress: 9, + }); + + const summary = getAchievementSummary([tracked, unlocked], tracked, true); + + expect(summary.totalRewardValue).toBe(250); + expect(summary.nextToUnlock?.achievement.id).toBe('tracked'); + }); + it('builds badge summaries from recent top-reader badges', () => { const badges: TopReader[] = [ { diff --git a/packages/webapp/lib/gameCenter.ts b/packages/webapp/lib/gameCenter.ts index 402a0dffbd9..a2483da2a11 100644 --- a/packages/webapp/lib/gameCenter.ts +++ b/packages/webapp/lib/gameCenter.ts @@ -8,6 +8,10 @@ import type { 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'; +import { + getAchievementRewardTotal, + getAchievementRewardValue, +} from '@dailydotdev/shared/src/lib/achievements'; const getDateValue = (value?: string | Date | null): number => { if (!value) { @@ -187,7 +191,7 @@ const dedupeAchievements = ( export type GameCenterAchievementSummary = { unlockedCount: number; totalCount: number; - totalPoints: number; + totalRewardValue: number; latestUnlocked: UserAchievement | null; rarestUnlocked: UserAchievement | null; nextToUnlock: UserAchievement | null; @@ -197,6 +201,7 @@ export type GameCenterAchievementSummary = { export const getAchievementSummary = ( achievements?: UserAchievement[], trackedAchievement?: UserAchievement | null, + showAchievementXp = false, ): GameCenterAchievementSummary => { const allAchievements = achievements ?? []; const unlocked = allAchievements.filter( @@ -237,7 +242,10 @@ export const getAchievementSummary = ( return right.progress - left.progress; } - return right.achievement.points - left.achievement.points; + return ( + getAchievementRewardValue(right.achievement, showAchievementXp) - + getAchievementRewardValue(left.achievement, showAchievementXp) + ); })[0] ?? null; const featuredAchievements = dedupeAchievements([ @@ -250,10 +258,7 @@ export const getAchievementSummary = ( return { unlockedCount: unlocked.length, totalCount: allAchievements.length, - totalPoints: unlocked.reduce( - (total, achievement) => total + (achievement.achievement.points ?? 0), - 0, - ), + totalRewardValue: getAchievementRewardTotal(unlocked, showAchievementXp), latestUnlocked, rarestUnlocked, nextToUnlock, diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index e9f4ac3fb53..277c55f92e8 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -277,8 +277,13 @@ function GameCenterPage({ getAchievementSummary( achievements, trackedAchievementState.trackedAchievement, + isQuestsFeatureEnabled === true, ), - [achievements, trackedAchievementState.trackedAchievement], + [ + achievements, + isQuestsFeatureEnabled, + trackedAchievementState.trackedAchievement, + ], ); const hasCoresAccess = useHasAccessToCores(); const showLevelSystem = !optOutLevelSystem; diff --git a/packages/webapp/pages/users.tsx b/packages/webapp/pages/users.tsx index 1da3bfcaa0f..c47f8c0c201 100644 --- a/packages/webapp/pages/users.tsx +++ b/packages/webapp/pages/users.tsx @@ -4,6 +4,7 @@ import type { GetStaticPropsResult } from 'next'; import type { NextSeoProps } from 'next-seo/lib/types'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { + getLeaderboardTitle, HIGHEST_LEVEL_QUERY, LEADERBOARD_QUERY, LeaderboardType, @@ -151,7 +152,10 @@ const LeaderboardPage = ({ /> { if ( @@ -90,7 +105,7 @@ const LeaderboardDetailPage = ({ if ( isLoading || - !title || + !displayTitle || (isLevelLeaderboard && (isQuestsFeatureLoading || isQuestsFeatureEnabled !== true)) ) { @@ -99,6 +114,7 @@ const LeaderboardDetailPage = ({ return ( + {displayTitle !== title && }
@@ -106,19 +122,19 @@ const LeaderboardDetailPage = ({ Leaderboard / - {title} + {displayTitle}
{isCompany ? ( ) : (