fix(dashboard): keep a trip the hero fell back to in the grid

The hero picks the trip that is running, else the next one coming up, else
just trips[0] — and that pick was then removed from the grid below. The first
two cases are deliberate: the trip is already on screen, showing it again
right underneath would be noise. The trips[0] fallback is a different thing.
It fires precisely when nothing qualifies, so it borrows a trip that belongs
in the grid, and removing it there leaves the user with a hero and an empty
list. Someone whose trips are all finished sees "No trips yet" under a card
of the trip they just came back from.

Split the two: only a trip that earned the hero is taken out of the grid.
Undated trips were caught by this too, and worse — getTripStatus returns
null rather than 'past' for them, so a lone dateless trip appeared in no
tab at all.

The empty state was the second half of the report. It equated "planned is
empty" with "you have no trips" and told people to create their first one.
Gate it on there being no trip at all, which is the condition the mobile
dashboard already uses.

Mobile shares useDashboard, so it is fixed along with the desktop.

Closes #1706
This commit is contained in:
Maurice
2026-07-26 12:32:24 +02:00
committed by Maurice
parent 2ce40724d7
commit b5ebffa115
3 changed files with 71 additions and 5 deletions
+58 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/helpers/render';
import { render, screen, waitFor, within } from '../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
@@ -943,4 +943,61 @@ describe('DashboardPage', () => {
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
});
});
describe('FE-PAGE-DASH-035: A trip the hero fell back to still appears in the grid (#1706)', () => {
const onlyTrips = (trips: unknown[]) =>
server.use(
http.get('/api/trips', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [] });
return HttpResponse.json({ trips });
}),
);
// The grid, not the hero — the hero renders the same title and would mask the bug.
const grid = () => document.querySelector('.trips') as HTMLElement;
it('lists a finished trip under Completed when it is the only one', async () => {
onlyTrips([buildTrip({ title: 'Lisbon 2025', start_date: '2025-05-01', end_date: '2025-05-08' })]);
const user = userEvent.setup();
render(<DashboardPage />);
await user.click(screen.getByText('Completed'));
await waitFor(() => expect(within(grid()).getByText('Lisbon 2025')).toBeInTheDocument());
});
it('lists a dateless trip under Planned when it is the only one', async () => {
onlyTrips([buildTrip({ title: 'Someday Iceland', start_date: null, end_date: null })]);
render(<DashboardPage />);
await waitFor(() => expect(within(grid()).getByText('Someday Iceland')).toBeInTheDocument());
expect(screen.queryByText('No trips yet')).not.toBeInTheDocument();
});
it('does not claim "No trips yet" when the only trip is simply finished', async () => {
onlyTrips([buildTrip({ title: 'Lisbon 2025', start_date: '2025-05-01', end_date: '2025-05-08' })]);
render(<DashboardPage />);
await waitFor(() => expect(screen.getAllByText('Lisbon 2025').length).toBeGreaterThan(0));
expect(screen.queryByText('No trips yet')).not.toBeInTheDocument();
});
it('still says "No trips yet" for an account with no trips at all', async () => {
onlyTrips([]);
render(<DashboardPage />);
await waitFor(() => expect(screen.getByText('No trips yet')).toBeInTheDocument());
});
it('still keeps a running trip out of the grid, so the hero does not double up', async () => {
onlyTrips([
buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' }),
buildTrip({ title: 'Lisbon 2025', start_date: '2025-05-01', end_date: '2025-05-08' }),
]);
render(<DashboardPage />);
await waitFor(() => expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0));
expect(within(grid()).queryByText('Paris Adventure')).not.toBeInTheDocument();
});
});
});
+4 -1
View File
@@ -220,7 +220,10 @@ function DashboardPageDesktop(): React.ReactElement {
/>
)}
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
{/* "No trips yet" only when there really are none — a user whose trips are
all finished has a hero, and telling them to create their first trip is
simply wrong (#1706). Same condition the mobile dashboard already uses. */}
{gridTrips.length === 0 && !spotlight && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
<EmptyState scene="dashboard" title={t('dashboard.emptyTitle')} />
</div>
+9 -3
View File
@@ -93,11 +93,17 @@ export function useDashboard() {
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
// A trip the hero features on its own merit: one that is running, else the next
// one coming up. Only that one is taken out of the grid below, so the same trip
// isn't shown twice.
const featured = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
|| trips[0]
|| null
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
// With neither, the hero still shows something rather than sitting empty — but
// that trip is only borrowed for the header and must stay in the grid, or a user
// whose trips are all finished (or undated) sees "No trips yet". #1706
const spotlight = featured || trips[0] || null
const rest = featured ? trips.filter(t => t.id !== featured.id) : trips
// Pull the spotlight trip's members + places so the boarding pass can show
// real buddies and place thumbnails instead of placeholders.