From 8f6d5fd21f306961c8cbd72f427837da1f203f2a Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 21:09:05 +0200 Subject: [PATCH] fix(dashboard): stand in for the trips and the stats while they load The dashboard rendered its finished empty state while it was still fetching: no hero, no cards, and a stats row reading zero everywhere. On a slow connection that reads as "my trips are gone" rather than "not here yet", which is what the report describes. The stats row was the worse half. It comes from a separate call that carries no loading flag at all, so every tile fell back to its zero until the response landed. A dash placeholder says "not yet" where a 0 says "none". The skeletons already existed. SpotlightSkeleton and TripCardSkeleton were written for this page, described as matching its layout, and never wired up: JourneyDetailPage was the only consumer of that module. Fixes #2115 --- .../src/pages/DashboardPage.desktop.test.tsx | 49 +++++++++++++++++++ client/src/pages/DashboardPage.tsx | 39 ++++++++++++--- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/client/src/pages/DashboardPage.desktop.test.tsx b/client/src/pages/DashboardPage.desktop.test.tsx index a2423c5cc..0a31839f0 100644 --- a/client/src/pages/DashboardPage.desktop.test.tsx +++ b/client/src/pages/DashboardPage.desktop.test.tsx @@ -395,4 +395,53 @@ describe('DashboardPage (desktop)', () => { } }); + // #2115 — the stats call carries no loading flag of its own, so every tile fell + // back to zero until it answered. On a slow connection that reads as "my trips + // are gone" rather than "not loaded yet". + it('FE-PAGE-DESKDASH-028: the stats tiles show placeholders instead of zeros before the numbers arrive', async () => { + let release: (() => void) | null = null; + const held = new Promise((resolve) => { release = resolve; }); + server.use(http.get('/api/auth/travel-stats', async () => { + await held; + return HttpResponse.json({ totalTrips: 12, totalPlaces: 40, totalDays: 30, totalDistanceKm: 5000, countries: ['FR'] }); + })); + + const { container } = render(); + + // The tile is on screen and reads as pending, not as a real zero. + const tiles = await waitFor(() => { + const found = container.querySelectorAll('.atlas-card'); + expect(found.length).toBeGreaterThan(0); + return found; + }); + expect(container.querySelectorAll('.atlas-card .trek-skeleton').length).toBeGreaterThan(0); + for (const tile of Array.from(tiles)) { + expect((tile.querySelector('.value')?.textContent || '').trim()).not.toBe('0'); + } + + release!(); + expect(await screen.findByText('12')).toBeInTheDocument(); + expect(container.querySelectorAll('.atlas-card .trek-skeleton').length).toBe(0); + }); + + it('FE-PAGE-DESKDASH-029: the trip grid stands in for itself while the trips load', async () => { + let release: (() => void) | null = null; + const held = new Promise((resolve) => { release = resolve; }); + server.use(http.get('/api/trips', async ({ request }) => { + await held; + const url = new URL(request.url); + return HttpResponse.json({ trips: url.searchParams.get('archived') ? [] : [buildTrip({ id: 101, title: 'Kyoto' })] }); + })); + + const { container } = render(); + + await waitFor(() => expect(container.querySelectorAll('.trips .trek-skeleton').length).toBeGreaterThan(0)); + // The finished empty state must not show while the answer is still in flight. + expect(screen.queryByText(/no trips yet/i)).toBeNull(); + + release!(); + expect(await screen.findByText('Kyoto')).toBeInTheDocument(); + expect(container.querySelectorAll('.trips .trek-skeleton').length).toBe(0); + }); + }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index d1d5d1a70..28076ad6d 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -8,6 +8,7 @@ import CopyTripDialog from '../components/shared/CopyTripDialog' import CustomSelect from '../components/shared/CustomSelect' import PlaceAvatar from '../components/shared/PlaceAvatar' import EmptyState from '../components/shared/EmptyState' +import { Skeleton, SpotlightSkeleton, TripCardSkeleton } from '../components/shared/Skeleton' import MobileTopBar from '../components/Layout/MobileTopBar' import { useDashboard } from './dashboard/useDashboard' import { @@ -164,6 +165,11 @@ function DashboardPageDesktop(): React.ReactElement { )} + {/* The hero and the cards below stand in for themselves while the trips load. + Without them the page rendered its finished empty state: no hero, no + cards, and a stats row reading zero, which reads as "your trips are + gone" rather than "not here yet" on a slow connection (#2115). */} + {isLoading && !spotlight && gridTrips.length === 0 && } {spotlight && ( setDeleteTrip(trip)} /> ))} + {isLoading && gridTrips.length === 0 && ( + <> + + + + + )} {tripFilter === 'planned' && !isLoading && (