diff --git a/src/app/page.tsx b/src/app/page.tsx index 8e1113e51..0631e6550 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -571,15 +571,30 @@ export default function DashboardPage() { const showSleepChart = isChartVisible("sleep") && hasSleep; const showStepsChart = isChartVisible("steps") && hasSteps; const showMedicationsCard = isChartVisible("medications"); + // `layoutData` is undefined until the real layout (snapshot or legacy + // widgets) resolves; `resolveDashboardLayout(undefined)` falls back to + // DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default. + // Gating layout-toggle-only cards on that fallback flashed them in for + // the load window and then retracted them for any user who had turned + // the widget OFF (their real layout arriving after first paint). The + // data-driven tiles tolerate the fallback because they also gate on a + // data floor; the achievements + recent-workouts cards have no floor, + // so wait for the real layout before committing them. + const layoutResolved = layoutData != null; // v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself - // self-handles the empty state (CTA → /achievements), so we only need - // the layout-toggle gate here. No data-floor check (the empty card is - // intentional — the maintainer wants the user to discover the feature). - const showAchievementsCard = isChartVisible("achievements"); + // self-handles the loading skeleton + empty state (CTA → /achievements), + // so we only need the layout-toggle gate here. No data-floor check (the + // empty card is intentional — the maintainer wants the user to discover + // the feature). + const showAchievementsCard = layoutResolved && isChartVisible("achievements"); // v1.4.32 — recent workouts dashboard tile. Self-gates on the // workouts query response so we only need the layout toggle here; - // the tile renders an Apple-Health-onboarding hint when empty. - const showRecentWorkoutsTile = isChartVisible("recentWorkouts"); + // the tile renders an Apple-Health-onboarding hint when empty. Same + // layout-resolved gate as the achievements card — default-visible with + // no data floor, so it would otherwise flash in then retract for a user + // who hid it. + const showRecentWorkoutsTile = + layoutResolved && isChartVisible("recentWorkouts"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the diff --git a/src/components/gamification/__tests__/recent-achievements-card.test.tsx b/src/components/gamification/__tests__/recent-achievements-card.test.tsx index d1bed87c8..10c85fabf 100644 --- a/src/components/gamification/__tests__/recent-achievements-card.test.tsx +++ b/src/components/gamification/__tests__/recent-achievements-card.test.tsx @@ -18,21 +18,24 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ isAuthenticated: true }), })); -let mockData: { - summary: unknown; - achievements: AchievementProgress[]; - metrics: unknown; -} = { +let mockData: + | { + summary: unknown; + achievements: AchievementProgress[]; + metrics: unknown; + } + | undefined = { summary: {}, achievements: [], metrics: {}, }; +let mockIsPending = false; // v1.4.34 IW-F-Perf — the card now reads through the shared // `useAchievementsQuery` hook; the test mocks the hook directly so the // network layer + TanStack provider scaffolding stays out of scope. vi.mock("@/lib/queries/use-achievements-query", () => ({ - useAchievementsQuery: () => ({ data: mockData }), + useAchievementsQuery: () => ({ data: mockData, isPending: mockIsPending }), })); import { @@ -50,6 +53,7 @@ function render() { beforeEach(() => { vi.clearAllMocks(); + mockIsPending = false; }); const baseAchievement: AchievementProgress = { @@ -127,6 +131,22 @@ describe("pickRecentUnlocks", () => { }); describe("", () => { + it("renders a skeleton (no content, no empty state) while the query is pending", () => { + // The flash this guards against: before the query settles the card + // used to paint the empty / content branch and then retract it once + // the real payload arrived. The skeleton commits to neither. + mockIsPending = true; + mockData = undefined; + const html = render(); + expect(html).toContain('data-slot="recent-achievements-skeleton"'); + // Neither the empty-state copy nor any content item paints while + // pending — nothing to retract. + expect(html).not.toContain("No achievements yet"); + expect(html).not.toContain('data-slot="recent-achievement-item"'); + // The card header still anchors the footprint. + expect(html).toContain("Recent unlocks"); + }); + it("shows the empty-state CTA when no unlocks exist", () => { mockData = { summary: {}, achievements: [], metrics: {} }; const html = render(); diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx index 7556c3c2f..e213a69f7 100644 --- a/src/components/gamification/recent-achievements-card.tsx +++ b/src/components/gamification/recent-achievements-card.tsx @@ -81,7 +81,14 @@ const RECENT_LIMIT = 3; export function RecentAchievementsCard() { const { t } = useTranslations(); - const { data } = useAchievementsQuery(); + // `isPending` (no data yet, fetch in flight or gated) drives the + // loading branch. Rendering the empty / content branch before the + // query settles caused an appear-then-retract flash on a cold load: + // the card painted its "no achievements yet" empty state (or stale + // content) for the fetch window, then swapped once the real payload + // landed. A skeleton holds the card's footprint without committing to + // a content shape it may have to retract. + const { data, isPending } = useAchievementsQuery(); const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT); @@ -105,7 +112,26 @@ export function RecentAchievementsCard() { - {recent.length === 0 ? ( + {isPending ? ( + + ) : recent.length === 0 ? (

{t("achievements.dashboardCard.empty")}