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
This commit is contained in:
Maurice
2026-08-28 21:09:05 +02:00
committed by Maurice
parent 71ff191bb4
commit 8f6d5fd21f
2 changed files with 82 additions and 6 deletions
@@ -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<void>((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(<DashboardPage />);
// 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<void>((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(<DashboardPage />);
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);
});
});
+33 -6
View File
@@ -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 {
</button>
</div>
)}
{/* 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 && <SpotlightSkeleton />}
{spotlight && (
<BoardingPassHero
trip={spotlight}
@@ -234,6 +240,13 @@ function DashboardPageDesktop(): React.ReactElement {
onDelete={() => setDeleteTrip(trip)}
/>
))}
{isLoading && gridTrips.length === 0 && (
<>
<TripCardSkeleton />
<TripCardSkeleton />
<TripCardSkeleton />
</>
)}
{tripFilter === 'planned' && !isLoading && (
<button type="button" className="add-trip-card" onClick={() => { setEditingTrip(null); setShowForm(true) }}>
<div>
@@ -479,6 +492,11 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
const atlasTemplateM =
[dash.mobile.tripsTotal && '1fr', dash.mobile.daysTraveled && '1fr'].filter(Boolean).join(' ') || '1fr'
// stats stays null until travelStats() answers, and that call carries no loading
// flag of its own, so every tile below used to render its zero fallback in the
// meantime. On a slow connection that reads as "no trips", which is the whole of
// #2115. A dash placeholder says "not yet" where a 0 says "none".
const statsPending = stats === null
const countries = stats?.countries || []
const distanceKm = stats?.totalDistanceKm || 0
const distance = convertDistance(distanceKm, distanceUnit)
@@ -492,7 +510,10 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{showAtlas && (
<div className="atlas-card passport">
<div className="label">{t('dashboard.atlas.countriesVisited')}</div>
<div className="value mono">{countries.length} <span className="unit text-[oklch(1_0_0_/_.55)]">{t('dashboard.atlas.ofTotal', { total: 195 })}</span></div>
<div className="value mono">
{statsPending ? <Skeleton width={44} height={30} radius={6} /> : countries.length}
{' '}<span className="unit text-[oklch(1_0_0_/_.55)]">{t('dashboard.atlas.ofTotal', { total: 195 })}</span>
</div>
<div className="passport-flags">
{countries.slice(0, 5).map((c, i) => (
<span key={i} className="flag" title={c}>
@@ -508,8 +529,8 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{showTrips && (
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.tripsTotal')}</div>
<div className="value mono">{stats?.totalTrips ?? 0}</div>
<div className="delta">{t('dashboard.atlas.placesMapped', { count: stats?.totalPlaces ?? 0 })}</div>
<div className="value mono">{statsPending ? <Skeleton width={44} height={30} radius={6} /> : stats.totalTrips ?? 0}</div>
<div className="delta">{statsPending ? <Skeleton width={90} height={12} /> : t('dashboard.atlas.placesMapped', { count: stats.totalPlaces ?? 0 })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<polyline points="0,30 12,26 22,28 32,18 44,22 56,10 68,14 80,4" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -519,7 +540,10 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{showDays && (
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.daysTraveled')}</div>
<div className="value mono">{stats?.totalDays ?? 0} <span className="unit">{t('dashboard.atlas.daysUnit')}</span></div>
<div className="value mono">
{statsPending ? <Skeleton width={44} height={30} radius={6} /> : stats.totalDays ?? 0}
{' '}<span className="unit">{t('dashboard.atlas.daysUnit')}</span>
</div>
<div className="delta">{t('dashboard.atlas.acrossAllTrips')}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<path d="M0 30 Q10 24 20 26 T40 20 T60 14 T80 10" fill="none" strokeWidth="2" strokeLinecap="round" />
@@ -530,8 +554,11 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{showDistance && (
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.distanceFlown')}</div>
<div className="value mono">{distanceText} <span className="unit">{distanceLabel}</span></div>
<div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<div className="value mono">
{statsPending ? <Skeleton width={60} height={30} radius={6} /> : distanceText}
{' '}<span className="unit">{distanceLabel}</span>
</div>
<div className="delta">{statsPending ? <Skeleton width={110} height={12} /> : t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
<circle cx="40" cy="18" r="14" fill="none" strokeWidth="2" strokeDasharray="58 88" strokeLinecap="round" transform="rotate(-90 40 18)" />