mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
Reference in New Issue
Block a user