diff --git a/client/src/components/Planner/bookings/bookingParts.test.tsx b/client/src/components/Planner/bookings/bookingParts.test.tsx index 97fec48d3..48972ef90 100644 --- a/client/src/components/Planner/bookings/bookingParts.test.tsx +++ b/client/src/components/Planner/bookings/bookingParts.test.tsx @@ -1,19 +1,39 @@ // FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-002 import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { WhenFields } from './bookingParts'; import type { BookingFacts } from './bookingFacts'; +import { WhenFields } from './bookingParts'; function facts(day: BookingFacts['day'], time: string | null = '15:35 → 09:55'): BookingFacts { return { - day, time, startTime: null, endTime: null, startDate: null, endDate: null, - cells: [], endpoints: [], legCodes: [], place: null, accommodation: null, linked: null, url: null, isHotel: false, + day, + time, + startTime: null, + endTime: null, + startDate: null, + endDate: null, + cells: [], + endpoints: [], + legCodes: [], + place: null, + accommodation: null, + linked: null, + url: null, + isHotel: false, }; } describe('WhenFields', () => { it('FE-PLANNER-BKPARTS-001: a long day title is cut, the date beside it never shrinks (#2590)', () => { - render(); + render( + + ); const title = screen.getByText('Germany Departure Flight with a much longer custom title → Korea Arrival'); const date = screen.getByText('Oct 21 → Oct 22'); diff --git a/client/src/components/Planner/reservationFormModel.test.ts b/client/src/components/Planner/reservationFormModel.test.ts index 9eae38102..e980949bc 100644 --- a/client/src/components/Planner/reservationFormModel.test.ts +++ b/client/src/components/Planner/reservationFormModel.test.ts @@ -181,16 +181,24 @@ describe('reservation dates', () => { expect(endTimeBeforeStartTime(overnight)).toBe(true); // Time zones can make a booking end "before" it starts, so the save stays open. expect(isEndBeforeStart(overnight)).toBe(false); - expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T09:00', reservation_end_time: '18:00' }))).toBe(false); - expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T18:00', reservation_end_time: '18:00' }))).toBe(false); + expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T09:00', reservation_end_time: '18:00' }))).toBe( + false + ); + expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T18:00', reservation_end_time: '18:00' }))).toBe( + false + ); // With the next day as end date the times are fine, and an end date of its own is checked by isEndBeforeStart. expect( - endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T18:00', end_date: '2026-11-25', reservation_end_time: '09:00' })) + endTimeBeforeStartTime( + form({ reservation_time: '2026-11-24T18:00', end_date: '2026-11-25', reservation_end_time: '09:00' }) + ) ).toBe(false); expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24', reservation_end_time: '09:00' }))).toBe(false); expect(endTimeBeforeStartTime(form({ reservation_end_time: '09:00' }))).toBe(false); expect( - endTimeBeforeStartTime(form({ type: 'hotel', reservation_time: '2026-11-24T18:00', reservation_end_time: '09:00' })) + endTimeBeforeStartTime( + form({ type: 'hotel', reservation_time: '2026-11-24T18:00', reservation_end_time: '09:00' }) + ) ).toBe(false); }); diff --git a/client/src/hooks/usePlanMapViewport.test.ts b/client/src/hooks/usePlanMapViewport.test.ts index 27a6a30db..88f320af0 100644 --- a/client/src/hooks/usePlanMapViewport.test.ts +++ b/client/src/hooks/usePlanMapViewport.test.ts @@ -22,7 +22,9 @@ describe('usePlanMapViewport', () => { }); it('raeumt ihn auch weg, sobald die Karte nicht mehr zu sehen ist, und nimmt dann nichts an', () => { - const { result, rerender } = renderHook(({ shown }) => usePlanMapViewport(5, shown), { initialProps: { shown: true } }); + const { result, rerender } = renderHook(({ shown }) => usePlanMapViewport(5, shown), { + initialProps: { shown: true }, + }); act(() => result.current(KYOTO)); rerender({ shown: false }); expect(useTripStore.getState().planMapViewport).toBeNull(); diff --git a/client/src/hooks/usePlanMapViewport.ts b/client/src/hooks/usePlanMapViewport.ts index 9e199cd9e..a154fb296 100644 --- a/client/src/hooks/usePlanMapViewport.ts +++ b/client/src/hooks/usePlanMapViewport.ts @@ -1,7 +1,12 @@ -import { useCallback, useEffect } from 'react' -import { useTripStore } from '../store/tripStore' +import { useCallback, useEffect } from 'react'; +import { useTripStore } from '../store/tripStore'; -interface Bounds { south: number; west: number; north: number; east: number } +interface Bounds { + south: number; + west: number; + north: number; + east: number; +} /** * Hands the plan map's viewport to the trip store, where the place search reads @@ -11,16 +16,19 @@ interface Bounds { south: number; west: number; north: number; east: number } * to lean on, and falls back to the day and the trip. */ export function usePlanMapViewport(tripId: number, shown = true): (bounds: Bounds) => void { - const setPlanMapViewport = useTripStore((s) => s.setPlanMapViewport) + const setPlanMapViewport = useTripStore((s) => s.setPlanMapViewport); useEffect(() => { - if (!shown) setPlanMapViewport(null) - return () => setPlanMapViewport(null) - }, [shown, tripId, setPlanMapViewport]) + if (!shown) setPlanMapViewport(null); + return () => setPlanMapViewport(null); + }, [shown, tripId, setPlanMapViewport]); - return useCallback((bounds: Bounds) => { - if (!shown || !Number.isFinite(tripId)) return - const { south, west, north, east } = bounds - setPlanMapViewport({ tripId, south, west, north, east }) - }, [shown, tripId, setPlanMapViewport]) + return useCallback( + (bounds: Bounds) => { + if (!shown || !Number.isFinite(tripId)) return; + const { south, west, north, east } = bounds; + setPlanMapViewport({ tripId, south, west, north, east }); + }, + [shown, tripId, setPlanMapViewport] + ); } diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx index e3ff774c3..f95bd07f0 100644 --- a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx +++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx @@ -121,7 +121,9 @@ describe('MReservationCardParts', () => { }); it('FE-COMP-MRESCARD-009: a booking with a linked expense says the expense is deleted with it (#2604)', () => { - render(); + render( + + ); expect(screen.getByText('reservations.confirm.deleteBodyWithExpense:LH 400')).toBeInTheDocument(); }); diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx index 026669198..0b89fc657 100644 --- a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx +++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx @@ -106,7 +106,9 @@ export function CardWhenAndCode({ {dayValue} {dayDate} - ) : dayValue} + ) : ( + dayValue + )} {timeValue} @@ -161,7 +163,10 @@ export function CardDeleteSheet({ title, card, t }: { title: string; card: CardS open={card.confirmingDelete} onClose={card.cancelDelete} title={t('reservations.confirm.deleteTitle')} - message={t(card.deleteTakesExpense ? 'reservations.confirm.deleteBodyWithExpense' : 'reservations.confirm.deleteBody', { name: title })} + message={t( + card.deleteTakesExpense ? 'reservations.confirm.deleteBodyWithExpense' : 'reservations.confirm.deleteBody', + { name: title } + )} confirmLabel={t('common.delete')} cancelLabel={t('common.cancel')} danger diff --git a/client/src/pages/tripPlanner/usePlannerTripView.test.ts b/client/src/pages/tripPlanner/usePlannerTripView.test.ts index 3d984b010..f024b26de 100644 --- a/client/src/pages/tripPlanner/usePlannerTripView.test.ts +++ b/client/src/pages/tripPlanner/usePlannerTripView.test.ts @@ -3,111 +3,135 @@ // The planner's view switches the account remembers per trip (#2639): road trip // mode, the Dawarich trail and the route overview. The tab keeps a copy that // answers before the settings arrive. -import { act, renderHook, waitFor } from '@testing-library/react' -import { http, HttpResponse } from 'msw' -import { server } from '../../../tests/helpers/msw/server' -import { resetAllStores, seedStore } from '../../../tests/helpers/store' -import { buildSettings } from '../../../tests/helpers/factories' -import { useSettingsStore } from '../../store/settingsStore' -import { usePlannerTripView } from './usePlannerTripView' +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { buildSettings } from '../../../tests/helpers/factories'; +import { server } from '../../../tests/helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useSettingsStore } from '../../store/settingsStore'; +import { usePlannerTripView } from './usePlannerTripView'; function captureSettingWrites() { - const writes: Array<{ key: string; value: unknown }> = [] - server.use(http.put('/api/settings', async ({ request }) => { - const body = await request.json() as { key: string; value: unknown } - writes.push(body) - return HttpResponse.json({ success: true, key: body.key, value: body.value }) - })) - return writes + const writes: Array<{ key: string; value: unknown }> = []; + server.use( + http.put('/api/settings', async ({ request }) => { + const body = (await request.json()) as { key: string; value: unknown }; + writes.push(body); + return HttpResponse.json({ success: true, key: body.key, value: body.value }); + }) + ); + return writes; } beforeEach(() => { - resetAllStores() -}) + resetAllStores(); +}); describe('usePlannerTripView', () => { it('FE-TP-TRIPVIEW-001: a fresh browser opens the trip with the switches the account has on', () => { seedStore(useSettingsStore, { settings: buildSettings({ planner_trip_views: { '42': { roadtrip: true } } }), isLoaded: true, - }) - expect(renderHook(() => usePlannerTripView(42, 'roadtrip')).result.current[0]).toBe(true) - expect(renderHook(() => usePlannerTripView(42, 'dawarich')).result.current[0]).toBe(false) - expect(renderHook(() => usePlannerTripView(43, 'roadtrip')).result.current[0]).toBe(false) - }) + }); + expect(renderHook(() => usePlannerTripView(42, 'roadtrip')).result.current[0]).toBe(true); + expect(renderHook(() => usePlannerTripView(42, 'dawarich')).result.current[0]).toBe(false); + expect(renderHook(() => usePlannerTripView(43, 'roadtrip')).result.current[0]).toBe(false); + }); it('FE-TP-TRIPVIEW-002: the tab copy answers until the settings are here, then the account decides', () => { - sessionStorage.setItem('trip-roadtrip-42', '1') - const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip')) - expect(result.current[0]).toBe(true) + sessionStorage.setItem('trip-roadtrip-42', '1'); + const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip')); + expect(result.current[0]).toBe(true); // Switched off on another device since this tab last looked. - act(() => { seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }) }) - expect(result.current[0]).toBe(false) - }) + act(() => { + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + }); + expect(result.current[0]).toBe(false); + }); it('FE-TP-TRIPVIEW-003: a switch is saved on the account next to the other trips, and only what is on', async () => { - const writes = captureSettingWrites() + const writes = captureSettingWrites(); seedStore(useSettingsStore, { settings: buildSettings({ planner_trip_views: { '7': { overview: true }, '42': { dawarich: true } } }), isLoaded: true, - }) - const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip')) + }); + const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip')); - act(() => { result.current[1]() }) - expect(result.current[0]).toBe(true) - expect(sessionStorage.getItem('trip-roadtrip-42')).toBe('1') - await waitFor(() => expect(writes).toHaveLength(1)) + act(() => { + result.current[1](); + }); + expect(result.current[0]).toBe(true); + expect(sessionStorage.getItem('trip-roadtrip-42')).toBe('1'); + await waitFor(() => expect(writes).toHaveLength(1)); expect(writes[0]).toEqual({ key: 'planner_trip_views', value: { '7': { overview: true }, '42': { dawarich: true, roadtrip: true } }, - }) + }); - act(() => { result.current[1]() }) - expect(result.current[0]).toBe(false) - await waitFor(() => expect(writes).toHaveLength(2)) - expect(writes[1].value).toEqual({ '7': { overview: true }, '42': { dawarich: true } }) - }) + act(() => { + result.current[1](); + }); + expect(result.current[0]).toBe(false); + await waitFor(() => expect(writes).toHaveLength(2)); + expect(writes[1].value).toEqual({ '7': { overview: true }, '42': { dawarich: true } }); + }); it('FE-TP-TRIPVIEW-004: a switch made before the settings arrived waits for them instead of erasing other trips', async () => { - const writes = captureSettingWrites() - server.use(http.get('/api/settings', () => HttpResponse.json({ - settings: buildSettings({ planner_trip_views: { '7': { overview: true } } }), - }))) - const { result } = renderHook(() => usePlannerTripView(42, 'dawarich')) + const writes = captureSettingWrites(); + server.use( + http.get('/api/settings', () => + HttpResponse.json({ + settings: buildSettings({ planner_trip_views: { '7': { overview: true } } }), + }) + ) + ); + const { result } = renderHook(() => usePlannerTripView(42, 'dawarich')); - act(() => { result.current[1]() }) - expect(result.current[0]).toBe(true) - await waitFor(() => expect(writes).toHaveLength(1)) - expect(writes[0].value).toEqual({ '7': { overview: true }, '42': { dawarich: true } }) - expect(result.current[0]).toBe(true) - }) + act(() => { + result.current[1](); + }); + expect(result.current[0]).toBe(true); + await waitFor(() => expect(writes).toHaveLength(1)); + expect(writes[0].value).toEqual({ '7': { overview: true }, '42': { dawarich: true } }); + expect(result.current[0]).toBe(true); + }); it('FE-TP-TRIPVIEW-005: a save that fails keeps the switch for the session', async () => { - let tried = false - server.use(http.put('/api/settings', () => { - tried = true - return HttpResponse.json({ error: 'nope' }, { status: 500 }) - })) - seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }) - const { result } = renderHook(() => usePlannerTripView(42, 'overview')) + let tried = false; + server.use( + http.put('/api/settings', () => { + tried = true; + return HttpResponse.json({ error: 'nope' }, { status: 500 }); + }) + ); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + const { result } = renderHook(() => usePlannerTripView(42, 'overview')); - act(() => { result.current[1]() }) - await waitFor(() => expect(tried).toBe(true)) - await act(async () => { await Promise.resolve() }) - expect(result.current[0]).toBe(true) - expect(sessionStorage.getItem('trip-overview-42')).toBe('1') - }) + act(() => { + result.current[1](); + }); + await waitFor(() => expect(tried).toBe(true)); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current[0]).toBe(true); + expect(sessionStorage.getItem('trip-overview-42')).toBe('1'); + }); it('FE-TP-TRIPVIEW-006: settings that cannot be loaded leave the tab copy in charge and write nothing', async () => { - const writes = captureSettingWrites() - server.use(http.get('/api/settings', () => HttpResponse.json({ error: 'offline' }, { status: 503 }))) - vi.spyOn(console, 'error').mockImplementation(() => {}) - const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip')) + const writes = captureSettingWrites(); + server.use(http.get('/api/settings', () => HttpResponse.json({ error: 'offline' }, { status: 503 }))); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip')); - act(() => { result.current[1]() }) - await act(async () => { await new Promise(r => setTimeout(r, 20)) }) - expect(result.current[0]).toBe(true) - expect(writes).toHaveLength(0) - }) -}) + act(() => { + result.current[1](); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(result.current[0]).toBe(true); + expect(writes).toHaveLength(0); + }); +}); diff --git a/client/src/pages/tripPlanner/usePlannerTripView.ts b/client/src/pages/tripPlanner/usePlannerTripView.ts index 57e2aaa04..cf5d8dd34 100644 --- a/client/src/pages/tripPlanner/usePlannerTripView.ts +++ b/client/src/pages/tripPlanner/usePlannerTripView.ts @@ -1,25 +1,29 @@ -import { useCallback, useState } from 'react' -import { PLANNER_TRIP_VIEWS_KEY, compactPlannerTripViews, type PlannerTripViewFlag } from '@trek/shared' -import { useSettingsStore } from '../../store/settingsStore' +import { PLANNER_TRIP_VIEWS_KEY, compactPlannerTripViews, type PlannerTripViewFlag } from '@trek/shared'; +import { useCallback, useState } from 'react'; +import { useSettingsStore } from '../../store/settingsStore'; function readSession(key: string): boolean | null { try { - const raw = sessionStorage.getItem(key) - return raw === '1' ? true : raw === '0' ? false : null + const raw = sessionStorage.getItem(key); + return raw === '1' ? true : raw === '0' ? false : null; } catch { - return null + return null; } } function writeSession(key: string, on: boolean): void { - try { sessionStorage.setItem(key, on ? '1' : '0') } catch { /* the account row still has it */ } + try { + sessionStorage.setItem(key, on ? '1' : '0'); + } catch { + /* the account row still has it */ + } } interface TabCopy { - key: string - on: boolean | null + key: string; + on: boolean | null; /** A switch made here that the account row has not taken yet. */ - pending: boolean + pending: boolean; } /** @@ -31,39 +35,39 @@ interface TabCopy { * decide, because a switch flipped on another device is newer than this tab. */ export function usePlannerTripView(tripId: number, flag: PlannerTripViewFlag): [boolean, () => void] { - const sessionKey = `trip-${flag}-${tripId}` - const saved = useSettingsStore(s => s.settings.planner_trip_views?.[String(tripId)]?.[flag] === true) - const loaded = useSettingsStore(s => s.isLoaded) - const [local, setLocal] = useState(() => ({ key: sessionKey, on: readSession(sessionKey), pending: false })) + const sessionKey = `trip-${flag}-${tripId}`; + const saved = useSettingsStore((s) => s.settings.planner_trip_views?.[String(tripId)]?.[flag] === true); + const loaded = useSettingsStore((s) => s.isLoaded); + const [local, setLocal] = useState(() => ({ key: sessionKey, on: readSession(sessionKey), pending: false })); // A different trip in the same planner reads its own copy instead of keeping this one. - const copy = local.key === sessionKey ? local : { key: sessionKey, on: readSession(sessionKey), pending: false } - const shown = loaded && !copy.pending ? saved : (copy.on ?? saved) + const copy = local.key === sessionKey ? local : { key: sessionKey, on: readSession(sessionKey), pending: false }; + const shown = loaded && !copy.pending ? saved : (copy.on ?? saved); const toggle = useCallback(() => { - const next = !shown - setLocal({ key: sessionKey, on: next, pending: true }) - writeSession(sessionKey, next) + const next = !shown; + setLocal({ key: sessionKey, on: next, pending: true }); + writeSession(sessionKey, next); void remember(tripId, flag, next).finally(() => { - setLocal(prev => (prev.key === sessionKey && prev.on === next ? { ...prev, pending: false } : prev)) - }) - }, [shown, sessionKey, tripId, flag]) + setLocal((prev) => (prev.key === sessionKey && prev.on === next ? { ...prev, pending: false } : prev)); + }); + }, [shown, sessionKey, tripId, flag]); - return [shown, toggle] + return [shown, toggle]; } async function remember(tripId: number, flag: PlannerTripViewFlag, on: boolean): Promise { - const store = useSettingsStore.getState() + const store = useSettingsStore.getState(); // The row holds every trip, so it is only written over once the account's // own copy is here; otherwise one switch would erase all the others. The // store takes the new value before the request goes out, so an offline save // still shows for the rest of the session. - if (!store.isLoaded) await store.loadSettings() - const { settings, isLoaded, updateSetting } = useSettingsStore.getState() - if (!isLoaded) return - const views = { ...settings.planner_trip_views } - views[String(tripId)] = { ...views[String(tripId)], [flag]: on } + if (!store.isLoaded) await store.loadSettings(); + const { settings, isLoaded, updateSetting } = useSettingsStore.getState(); + if (!isLoaded) return; + const views = { ...settings.planner_trip_views }; + views[String(tripId)] = { ...views[String(tripId)], [flag]: on }; try { - await updateSetting(PLANNER_TRIP_VIEWS_KEY, compactPlannerTripViews(views)) + await updateSetting(PLANNER_TRIP_VIEWS_KEY, compactPlannerTripViews(views)); } catch { // Offline or refused: the store and the tab copy keep the switch for this session. }