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 ? (
) : (