mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
fix(planner): drop search and day-panel answers that arrive too late
The desktop day panel stays mounted across a day switch, so a forecast or the stays still loading for the previous day overwrote the new day's once they arrived. The location search in the transport form showed results for text the user had already changed, or refilled the list after a pick. Both now drop an answer for a request that no longer matches.
This commit is contained in:
@@ -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(<DayDetailPanel {...defaultProps} days={[day, next]} lat={48.8566} lng={2.3522} />);
|
||||
rerender(<DayDetailPanel {...defaultProps} day={next} days={[day, next]} lat={48.8566} lng={2.3522} />);
|
||||
|
||||
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', () =>
|
||||
|
||||
@@ -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(<Host />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user