refactor(gamification): collapse achievement consumers onto shared hook

v1.4.34 IW-F-Perf — migrate `<RecentAchievementsCard>`, the
`/achievements` mother page, and `<AchievementUnlockNotifier>` from
ad-hoc `useQuery` blocks onto `useAchievementsQuery()`.

Pre-change the dashboard cold mount fired
`/api/gamification/achievements` twice — once for the card, once for
the notifier — because the notifier carried a per-user discriminator
on its queryKey and TanStack treated it as a fresh cache cell. The
shared hook routes all three consumers through the same
`queryKeys.gamificationAchievements()` slot, so a single network
call now backs every surface. The notifier keeps its 2-minute
refetch cadence; long-open tabs still surface new-unlock toasts and
the card picks up the same payload from the shared cache without
issuing a parallel request.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:40:16 +02:00
1 parent ca7ca0e7d6
commit 3b77b6a073
3 files changed
+23 -67

No files matched your search

+6 -19
View File
@@ -1,6 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
@@ -36,20 +35,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import {
ACHIEVEMENT_CATEGORY_ORDER,
type AchievementCategory,
type AchievementMetrics,
type AchievementProgress,
type AchievementSummary,
} from "@/lib/gamification/achievements";
interface AchievementsData {
summary: AchievementSummary;
achievements: AchievementProgress[];
metrics: AchievementMetrics;
}
const iconMap: Record<string, LucideIcon> = {
Activity,
Flame,
@@ -271,16 +263,11 @@ export default function AchievementsPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: ["gamification", "achievements"],
queryFn: async () => {
const res = await fetch("/api/gamification/achievements");
if (!res.ok) throw new Error("Failed to load achievements");
const json = await res.json();
return json.data as AchievementsData;
},
enabled: isAuthenticated,
});
// v1.4.34 IW-F-Perf — mother page rides the shared cache slot
// alongside `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` so the three consumers never trigger
// more than one network call on a cold dashboard mount.
const { data, isLoading } = useAchievementsQuery();
if (authLoading || isLoading) {
return (
@@ -1,18 +1,13 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Sparkles, Trophy } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import type { AchievementProgress } from "@/lib/gamification/achievements";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
const STORAGE_KEY_PREFIX = "healthlog-achievements-seen";
interface AchievementsPayload {
achievements: AchievementProgress[];
}
interface AchievementUnlockNotifierProps {
userId: string;
}
@@ -66,23 +61,16 @@ export function AchievementUnlockNotifier({
const localStateReadyRef = useRef(false);
const hasSnapshotRef = useRef(false);
const { data } = useQuery({
queryKey: ["gamification", "achievements", "unlock-notifier", userId],
queryFn: async () => {
try {
const response = await fetch("/api/gamification/achievements");
if (!response.ok) {
return { achievements: [] } satisfies AchievementsPayload;
}
const json = await response.json();
return (json.data ?? { achievements: [] }) as AchievementsPayload;
} catch {
return { achievements: [] } satisfies AchievementsPayload;
}
},
staleTime: 60 * 1000,
// v1.4.34 IW-F-Perf — the notifier and `<RecentAchievementsCard>`
// both ride the shared `["gamification", "achievements"]` cache cell.
// Before, the notifier carried a per-user discriminator on its key,
// which made TanStack treat it as a fresh cell and fire a second
// network call alongside the dashboard tile. The shared hook owns the
// refetch cadence; the notifier supplies the polling interval so
// long-open tabs still surface new unlocks without forcing the card
// to re-render on the same cadence.
const { data } = useAchievementsQuery({
refetchInterval: 2 * 60 * 1000,
refetchOnWindowFocus: false,
});
useEffect(() => {
@@ -1,6 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
@@ -20,14 +19,10 @@ import {
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import type {
AchievementProgress,
AchievementSummary,
AchievementMetrics,
} from "@/lib/gamification/achievements";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import type { AchievementProgress } from "@/lib/gamification/achievements";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -46,12 +41,6 @@ const iconMap: Record<string, LucideIcon> = {
SkipForward,
};
interface AchievementsData {
summary: AchievementSummary;
achievements: AchievementProgress[];
metrics: AchievementMetrics;
}
/**
* Pure helper — pick the N most-recently unlocked achievements.
* Achievements without a `completedAt` (legacy unlocks pre-persistence)
@@ -84,23 +73,15 @@ const RECENT_LIMIT = 3;
* (`achievements` widget id).
*
* Reuses the same `/api/gamification/achievements` endpoint as the
* dedicated page; TanStack Query dedupes the request when both this
* card and the page render in the same session.
* dedicated page and the unlock notifier; v1.4.34 IW-F-Perf folds all
* three consumers onto the shared `useAchievementsQuery()` hook so a
* single cache slot and a single network call back this dashboard
* tile, the mother page, and the toast notifier.
*/
export function RecentAchievementsCard() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data } = useQuery({
queryKey: ["gamification", "achievements"],
queryFn: async () => {
const res = await fetch("/api/gamification/achievements");
if (!res.ok) throw new Error("Failed to load achievements");
const json = await res.json();
return json.data as AchievementsData;
},
enabled: isAuthenticated,
});
const { data } = useAchievementsQuery();
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);