mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
5e60eaf9c0
commit
6e241c16a4
3 files changed
+75
-14
No files matched your search
+21
-6
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user