From 93cce57306b2c0505967106a3d34f26b01e48232 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 20:20:27 +0200 Subject: [PATCH] fix(dashboard): trip counters and upcoming bookings refresh after a trip is created, copied, redated, archived or deleted The travel stats and the upcoming bookings were only fetched when the dashboard mounted, so "Trips" and "Days" kept their old values after a new or duplicated trip and the sidebar still listed booking dates from before a date change until the page was left. Every successful trip mutation now reloads both, on desktop and mobile alike since they share the hook. Refs #2604 --- .../src/pages/dashboard/useDashboard.test.tsx | 65 +++++++++++++++++++ client/src/pages/dashboard/useDashboard.ts | 16 ++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/client/src/pages/dashboard/useDashboard.test.tsx b/client/src/pages/dashboard/useDashboard.test.tsx index 01f9591b2..17cb741fc 100644 --- a/client/src/pages/dashboard/useDashboard.test.tsx +++ b/client/src/pages/dashboard/useDashboard.test.tsx @@ -370,4 +370,69 @@ describe('useDashboard', () => { act(() => result.current.setTripFilter('archive')); expect(result.current.gridTrips[0].cover_image).toBe('/uploads/covers/new-rome.jpg'); }); + it('FE-HOOK-DASH-027: creating, copying or redating a trip refreshes the stats and upcoming bookings (#2604)', async () => { + let statsCalls = 0; + let upcomingCalls = 0; + server.use( + http.get('/api/auth/travel-stats', () => { statsCalls += 1; return HttpResponse.json({ totalTrips: 1 + statsCalls, totalDays: 10 * statsCalls }); }), + http.get('/api/reservations/upcoming', () => { + upcomingCalls += 1; + return HttpResponse.json({ reservations: [{ id: 5, trip_id: 101, title: 'Louvre', type: 'ticket', reservation_time: upcomingCalls > 1 ? '2026-07-03T10:00' : '2026-07-01T10:00' }] }); + }), + http.post('/api/trips', () => HttpResponse.json({ trip: buildTrip({ id: 200, title: 'Iceland' }) })), + http.post('/api/trips/:id/copy', () => HttpResponse.json({ trip: { ...PARIS, id: 300, title: 'Paris (copy)' } })), + http.put('/api/trips/:id', () => HttpResponse.json({ trip: { ...PARIS, start_date: '2026-07-03', end_date: '2026-07-12' } })), + ); + const { result } = await mountLoaded(); + await waitFor(() => expect(result.current.stats?.totalTrips).toBe(2)); + + await act(async () => { await result.current.handleCreate({ title: 'Iceland' }); }); + await waitFor(() => expect(result.current.stats?.totalTrips).toBe(3)); + + act(() => { result.current.setCopyTrip(PARIS); }); + await act(async () => { await result.current.confirmCopy(); }); + await waitFor(() => expect(result.current.stats?.totalTrips).toBe(4)); + + act(() => { result.current.setEditingTrip(PARIS); }); + await act(async () => { await result.current.handleUpdate({ title: 'Paris Adventure', start_date: '2026-07-03', end_date: '2026-07-12' }); }); + await waitFor(() => expect(result.current.stats?.totalTrips).toBe(5)); + expect(result.current.upcoming[0].reservation_time).toBe('2026-07-03T10:00'); + expect(upcomingCalls).toBe(4); + }); + + it('FE-HOOK-DASH-028: deleting, archiving and restoring a trip refresh the stats too (#2604)', async () => { + let statsCalls = 0; + server.use( + http.get('/api/auth/travel-stats', () => { statsCalls += 1; return HttpResponse.json({ totalTrips: statsCalls }); }), + http.delete('/api/trips/:id', () => HttpResponse.json({ success: true })), + http.put('/api/trips/:id', () => HttpResponse.json({ trip: { ...PARIS, is_archived: 1 } })), + ); + const { result } = await mountLoaded(); + await waitFor(() => expect(statsCalls).toBe(1)); + + act(() => { result.current.setDeleteTrip(TOKYO); }); + await act(async () => { await result.current.confirmDelete(); }); + await waitFor(() => expect(statsCalls).toBe(2)); + + await act(async () => { await result.current.handleArchive(101); }); + await waitFor(() => expect(statsCalls).toBe(3)); + + await act(async () => { await result.current.handleUnarchive(101); }); + await waitFor(() => expect(statsCalls).toBe(4)); + }); + + it('FE-HOOK-DASH-029: a failed mutation does not refetch the side data', async () => { + let statsCalls = 0; + server.use( + http.get('/api/auth/travel-stats', () => { statsCalls += 1; return HttpResponse.json({ totalTrips: statsCalls }); }), + http.post('/api/trips/:id/copy', () => HttpResponse.json({}, { status: 500 })), + ); + const { result } = await mountLoaded(); + await waitFor(() => expect(statsCalls).toBe(1)); + + act(() => { result.current.setCopyTrip(PARIS); }); + await act(async () => { await result.current.confirmCopy(); }); + + expect(statsCalls).toBe(1); + }); }); diff --git a/client/src/pages/dashboard/useDashboard.ts b/client/src/pages/dashboard/useDashboard.ts index b72df493e..66fb8e518 100644 --- a/client/src/pages/dashboard/useDashboard.ts +++ b/client/src/pages/dashboard/useDashboard.ts @@ -67,11 +67,15 @@ export function useDashboard() { useEffect(() => { void loadTrips() }, []) // Travel stats + upcoming reservations power the atlas row and the sidebar. - // Both are best-effort: a failure just leaves that section empty. - useEffect(() => { + // Both are best-effort: a failure just leaves that section empty. Every trip + // mutation below reloads them, so the counters and the booking dates don't + // lag behind a created, copied or redated trip until the next visit. #2604 + const loadSideData = () => { authApi.travelStats().then(setStats).catch(() => {}) reservationsApi.upcoming().then((r: { reservations: UpcomingReservation[] }) => setUpcoming(r.reservations || [])).catch(() => {}) - }, []) + } + + useEffect(() => { loadSideData() }, []) const loadTrips = async () => { setIsLoading(true) @@ -124,6 +128,7 @@ export function useDashboard() { const data = await tripsApi.create(tripData) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.created')) + loadSideData() return data } catch (err: unknown) { throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError'))) @@ -136,6 +141,7 @@ export function useDashboard() { const data = await tripRepo.update(editingTrip.id, tripData) setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t))) toast.success(t('dashboard.toast.updated')) + loadSideData() } catch (err: unknown) { throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError'))) } @@ -153,6 +159,7 @@ export function useDashboard() { setTrips(prev => prev.filter(t => t.id !== deleteTrip.id)) setArchivedTrips(prev => prev.filter(t => t.id !== deleteTrip.id)) toast.success(t('dashboard.toast.deleted')) + loadSideData() } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('dashboard.toast.deleteError'))) } @@ -165,6 +172,7 @@ export function useDashboard() { setTrips(prev => prev.filter(t => t.id !== id)) setArchivedTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.archived')) + loadSideData() } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('dashboard.toast.archiveError'))) } @@ -176,6 +184,7 @@ export function useDashboard() { setArchivedTrips(prev => prev.filter(t => t.id !== id)) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.restored')) + loadSideData() } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('dashboard.toast.restoreError'))) } @@ -188,6 +197,7 @@ export function useDashboard() { const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` }) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.copied')) + loadSideData() } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('dashboard.toast.copyError'))) }