fix(dashboard): stop the recent-achievements card flashing in then retracting

On a cold dashboard load the recent-achievements card briefly showed content
(or its empty state) and then vanished. Two causes:

- The layout-toggle gate read `DEFAULT_DASHBOARD_LAYOUT` while the real layout
  was still loading; achievements (and recent workouts) default to visible, so
  the card painted during the load window and was retracted once a layout that
  hid it arrived. Gate both layout-toggle-only cards on the resolved layout.
- The card itself rendered its empty / content branch before its query settled,
  so the "no achievements yet" state flashed ahead of the real unlocks. Render a
  skeleton while the query is pending and commit to a content shape only once it
  resolves.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 16:44:46 +02:00
1 parent 5e60eaf9c0
commit 6e241c16a4
3 files changed
+75 -14

No files matched your search

+21 -6
View File
@@ -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
@@ -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("<RecentAchievementsCard>", () => {
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();
@@ -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() {
</Link>
</div>
{recent.length === 0 ? (
{isPending ? (
<ul
data-slot="recent-achievements-skeleton"
aria-hidden="true"
className="space-y-2 motion-reduce:animate-none"
>
{Array.from({ length: RECENT_LIMIT }).map((_, idx) => (
<li
key={`achievement-skeleton-${idx}`}
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-muted/60 h-7 w-7 shrink-0 animate-pulse rounded-md motion-reduce:animate-none" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="bg-muted/60 h-3 w-1/2 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-2.5 w-3/4 animate-pulse rounded motion-reduce:animate-none" />
</div>
</li>
))}
</ul>
) : recent.length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">
{t("achievements.dashboardCard.empty")}
</p>