diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index d0734c600..17b4b852e 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-098 +// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-101 import React from 'react'; import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -156,6 +156,28 @@ describe('DayDetailPanel', () => { expect(spinner.querySelector('.animate-spin')).not.toBeNull(); }); + it('FE-PLANNER-DAYDETAIL-101: a late forecast for the previous day never replaces the one for the current day', async () => { + const { delay } = await import('msw'); + server.use( + http.get('/api/weather/detailed', async ({ request }) => { + const date = new URL(request.url).searchParams.get('date'); + if (date === '2025-06-15') { + await delay(400); + return HttpResponse.json({ main: 'Rain', temp: 11, temp_min: 9, temp_max: 13, description: 'rain' }); + } + return HttpResponse.json({ main: 'Clear', temp: 27, temp_min: 20, temp_max: 30, description: 'sunny' }); + }), + ); + const next = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Day two' }); + const { rerender } = render(); + rerender(); + + expect(await screen.findByText(/27°C/)).toBeInTheDocument(); + await act(async () => { await new Promise((r) => setTimeout(r, 600)); }); + expect(screen.queryByText(/11°C/)).toBeNull(); + expect(screen.getByText(/27°C/)).toBeInTheDocument(); + }); + it('FE-PLANNER-DAYDETAIL-010: weather data renders temperature in Celsius', async () => { server.use( http.get('/api/weather/detailed', () => diff --git a/client/src/components/Planner/LocationSelect.test.tsx b/client/src/components/Planner/LocationSelect.test.tsx index f625ebdca..5589815cd 100644 --- a/client/src/components/Planner/LocationSelect.test.tsx +++ b/client/src/components/Planner/LocationSelect.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-024 +// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-025 import { useState } from 'react'; import { delay, http, HttpResponse } from 'msw'; import userEvent from '@testing-library/user-event'; @@ -341,4 +341,27 @@ describe('LocationSelect', () => { await user.keyboard('{ArrowDown}{Enter}'); expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Bach Suites Saigon' })); }); + + it('FE-PLANNER-LOCSEL-025: an answer for text the user has since changed never replaces the newer results', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/maps/search', async ({ request }) => { + const { query } = (await request.json()) as { query: string }; + if (query === 'Gare') { + await delay(600); + return HttpResponse.json({ places: [GARE] }); + } + return HttpResponse.json({ places: [LYON] }); + })); + + render(); + const input = screen.getByRole('textbox'); + await user.type(input, 'Gare'); + await settle(400); + await user.type(input, ' de Lyon'); + expect(await screen.findByText('Gare de Lyon')).toBeInTheDocument(); + + await settle(800); + expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument(); + expect(screen.getByText('Gare de Lyon')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx index 3d8dde4e5..2e5150fe5 100644 --- a/client/src/components/Planner/LocationSelect.tsx +++ b/client/src/components/Planner/LocationSelect.tsx @@ -57,20 +57,27 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl setResults([]) return } + // Clearing the timer does nothing to a request already out: an answer for + // text the user has since changed, or that lands after a pick, is dropped. + let stale = false debounceRef.current = setTimeout(async () => { setLoading(true) try { const data = await mapsApi.search(trimmed, placeLang, locationBias) + if (stale) return setResults(data.places || []) setHighlight(-1) } catch { - setResults([]) + if (!stale) setResults([]) } finally { - setLoading(false) + if (!stale) setLoading(false) } }, 320) - return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } - }, [query, value, locale]) + return () => { + stale = true + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [query, value, locale, placeLang]) const pick = (r: any) => { const lat = Number(r.lat) diff --git a/client/src/components/Planner/useDayDetail.ts b/client/src/components/Planner/useDayDetail.ts index 942ac8d55..65f4a2606 100644 --- a/client/src/components/Planner/useDayDetail.ts +++ b/client/src/components/Planner/useDayDetail.ts @@ -47,19 +47,26 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat: const isForDay = (a: Accommodation) => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false + // Both effects drop an answer that arrives after the day changed: the panel + // stays mounted across a day switch, and the previous day's late response + // would otherwise overwrite the new day's forecast and hotel. useEffect(() => { if (!day?.date || !lat || !lng) { setWeather(null); return } + let cancelled = false setLoading(true) weatherApi.getDetailed(lat, lng, day.date, language) - .then(data => setWeather(data.error ? null : data)) - .catch(() => setWeather(null)) - .finally(() => setLoading(false)) + .then(data => { if (!cancelled) setWeather(data.error ? null : data) }) + .catch(() => { if (!cancelled) setWeather(null) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } }, [day?.date, lat, lng, language]) useEffect(() => { if (!tripId) return + let cancelled = false accommodationsApi.list(tripId) .then(data => { + if (cancelled) return const all: Accommodation[] = data.accommodations || [] setAccommodations(all) const allForDay = all.filter(isForDay) @@ -67,6 +74,7 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat: setAccommodation(allForDay[0] || null) }) .catch(() => {}) + return () => { cancelled = true } }, [tripId, day?.id]) useEffect(() => { if (day) setHotelDayRange(defaultHotelDayRange(day)) }, [day?.id])