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])