diff --git a/client/src/api/schoolHolidayCatalog.ts b/client/src/api/schoolHolidayCatalog.ts new file mode 100644 index 000000000..645207f36 --- /dev/null +++ b/client/src/api/schoolHolidayCatalog.ts @@ -0,0 +1,13 @@ +import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared' +import apiClient from './client' + +const base = '/school-holiday-catalog' +export const schoolHolidayCatalogApi = { + catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)), + region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country), + deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`), + createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }), +} diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 6be9a96ad..ad5f1ccb0 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -473,7 +473,7 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => { const user = userEvent.setup(); const urls: (string | null)[] = []; - server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls)); + server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls)); render(); await screen.findByText('Installed on the server'); @@ -483,9 +483,9 @@ describe('AddonManager', () => { await user.click(screen.getByRole('button', { name: 'llama3:8b' })); expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b'); - // qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull" + // qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull" await user.click(screen.getByRole('button', { name: 'Use' })); - expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b'); expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled(); }); @@ -545,7 +545,7 @@ describe('AddonManager', () => { addonsRoute([llmAddon({ provider: 'local' })]), http.get('/api/admin/llm/local/models', () => { modelCalls += 1; - return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] }); + return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] }); }), http.post('/api/admin/llm/local/pull', async ({ request }) => { pulled = await request.json(); @@ -565,8 +565,8 @@ describe('AddonManager', () => { expect(screen.getByText('starting…')).toBeInTheDocument(); await screen.findByText('Model pulled'); - expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' }); - expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' }); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b'); await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled()); }); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 05010f7dd..c9b041a88 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -308,7 +308,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1' * one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled * automatically, so the Qwen3 family works without any tuning. A host only needs one. */ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [ - { id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false }, + { id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true }, ] /** diff --git a/client/src/components/Admin/CategoryManager.tsx b/client/src/components/Admin/CategoryManager.tsx index b26af8629..db20979ac 100644 --- a/client/src/components/Admin/CategoryManager.tsx +++ b/client/src/components/Admin/CategoryManager.tsx @@ -192,7 +192,7 @@ export default function CategoryManager() {

{t('categories.title')}

-

{t('categories.subtitle')}

+

{t('categories.subtitle')}

} +
+ {state.catalog.countries.length > 0 &&
+
{t('schoolCatalog.country')} + setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} /> +
+ + +
} + {!adding && state.catalog.countries.length === 0 && } + {adding &&
{ + event.preventDefault() + if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') } + }}> + + +
+
} + {selectedCountry && <> + {regions.length === 0 &&

{t('schoolCatalog.empty')}

} + + {regions.length === 0 && } + } +
+ {state.editor && state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />} + setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} /> + +} diff --git a/client/src/components/Admin/SchoolHolidayRegionEditor.tsx b/client/src/components/Admin/SchoolHolidayRegionEditor.tsx new file mode 100644 index 000000000..08ffeeb8a --- /dev/null +++ b/client/src/components/Admin/SchoolHolidayRegionEditor.tsx @@ -0,0 +1,75 @@ +import { useId, useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared' +import { useTranslation } from '../../i18n' +import Modal from '../shared/Modal' +import ConfirmDialog from '../shared/ConfirmDialog' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' + +export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content' +export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50' + +export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: { + region: SchoolHolidayRegionDetail + busy: boolean + offline?: boolean + error: string + onClose: () => void + onSave: (body: SchoolHolidayRegionRequest) => void +}) { + const { t } = useTranslation() + const fieldId = useId() + const [name, setName] = useState(region.name) + const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index }))) + const [nextKey, setNextKey] = useState(holidays.length) + const [discard, setDiscard] = useState(false) + const [invalid, setInvalid] = useState(false) + const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) } + const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays) + const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } } + + return <> + +
{ + event.preventDefault() + const parsed = schoolHolidayRegionRequestSchema.safeParse(body) + setInvalid(!parsed.success) + if (parsed.success) onSave(parsed.data) + }}> +
+ +

{t('schoolCatalog.periodHint')}

+ {holidays.map((holiday, index) =>
+
+ +
+ setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} /> + +
+
+
+
{t('schoolCatalog.start')} + setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} /> +
+
{t('schoolCatalog.end')} + setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} /> +
+
+
)} + + {invalid &&

{t('schoolCatalog.invalid')}

} + {error &&

{error}

} +
+
+ + +
+
+
+ setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} /> + +} diff --git a/client/src/components/Admin/useSchoolHolidayCatalog.ts b/client/src/components/Admin/useSchoolHolidayCatalog.ts new file mode 100644 index 000000000..a78a2846c --- /dev/null +++ b/client/src/components/Admin/useSchoolHolidayCatalog.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared' +import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo' +import { useTranslation } from '../../i18n' +import { useNetworkMode } from '../../hooks/useNetworkMode' + +export function useSchoolHolidayCatalog() { + const { t } = useTranslation() + const { offline } = useNetworkMode() + const [catalog, setCatalog] = useState({ countries: [], regions: [] }) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [editor, setEditor] = useState(null) + const mounted = useRef(true) + const pending = useRef(false) + useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) + + const reload = useCallback(async () => { + catalogRepo.requireOnline() + const catalog = await catalogRepo.catalog() + if (mounted.current) setCatalog(catalog) + }, []) + + useEffect(() => { + let cancelled = false + if (offline) { setLoading(false); return } + setLoading(true) + catalogRepo.catalog().then(catalog => { + if (!cancelled) { setCatalog(catalog); setError('') } + }).catch(() => { if (!cancelled) setError(t('common.error')) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [offline, t]) + + async function run(action: () => Promise, after?: () => void) { + if (pending.current) return false + pending.current = true + setBusy(true) + setError('') + try { + catalogRepo.requireOnline() + await action() + if (mounted.current) after?.() + try { + await reload() + } catch { + if (mounted.current) setError(t('schoolCatalog.refreshError')) + } + return true + } catch (failure) { + if (mounted.current) { + const message = failure instanceof Error ? failure.message : '' + const http = failure as { response?: { data?: { error?: string } } } + setError(http.response?.data?.error || message || t('common.error')) + } + return false + } finally { + pending.current = false + if (mounted.current) setBusy(false) + } + } + + return { + catalog, loading, busy, error, offline, editor, setEditor, + refresh: () => run(async () => {}), + createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)), + deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)), + openRegion: (id: number) => run(async () => { + const region = await catalogRepo.region(id) + if (mounted.current) setEditor(region) + }), + saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run( + () => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body), + () => setEditor(null), + ), + deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)), + } +} diff --git a/client/src/components/Map/LocationButton.tsx b/client/src/components/Map/LocationButton.tsx index 236a6e076..f2591b192 100644 --- a/client/src/components/Map/LocationButton.tsx +++ b/client/src/components/Map/LocationButton.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { MAP_CONTROL_SHADOW } from './mapControlShadow' import { Navigation, LocateFixed, Locate } from 'lucide-react' import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation' import { useTranslation } from '../../i18n' @@ -77,7 +78,7 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom cursor: 'pointer', background: isActive ? '#3b82f6' : 'var(--bg-card, white)', color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'), - boxShadow: '0 2px 10px rgba(0,0,0,0.25)', + boxShadow: MAP_CONTROL_SHADOW, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 9b81bb3fe..2c7db30b8 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,5 +1,6 @@ import { Map as MapIcon, Satellite } from 'lucide-react' import { useTranslation } from '../../i18n' +import { MAP_CONTROL_SHADOW } from './mapControlShadow' export type BaseLayer = 'default' | 'satellite' @@ -18,7 +19,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo background: 'var(--sidebar-bg)', backdropFilter: 'blur(20px) saturate(180%)', WebkitBackdropFilter: 'blur(20px) saturate(180%)', - boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))', + boxShadow: MAP_CONTROL_SHADOW, }}> + + ) +} + +/** + * The trip's stages and its total distance, read off the overview the map is drawing. + * + * One component for both shells: the phone and the desktop place it differently but the + * rows are the same rows, and a second copy of them is a second thing to keep in step. + */ +export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelectDay, maxWidth = 320 }: { + overview: Overview + unit: DistanceUnit + selectedDayId?: number | null + onSelectDay?: (dayId: number) => void + /** How wide the card may grow before day names start to ellipsize. */ + maxWidth?: number +}) { + const { t } = useTranslation() + if (!overview.days.length) return null + + return ( +
+
+ + {t('map.overview.total')} + + {/* Same tier as the label beside it: the two read as one line rather than as a + heading with a number stuck under it. */} + + {formatDistance(overview.totalDistance / 1000, unit)} + {/* Still routing: the number is a partial sum, and saying so beats a total + that silently grows while you read it. */} + {overview.loading && {' '}…} + +
+
+ {overview.days.map(day => { + const label = day.title || t('dayplan.dayN', { n: day.dayNumber }) + const row = ( + <> + + + {label} + + + {day.modes.map(mode => { + const Icon = profileIcon(mode) + return + })} + + + {formatDistance(day.distance / 1000, unit)} + + + ) + const style = { + display: 'flex', alignItems: 'center', gap: 8, width: '100%', + padding: '7px 14px', textAlign: 'left' as const, + fontSize: 'calc(12px * var(--fs-scale-body, 1))', + background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent', + border: 'none', + } + return onSelectDay ? ( + + ) : ( +
{row}
+ ) + })} +
+
+ ) +} diff --git a/client/src/components/Map/dayRoutePlan.test.ts b/client/src/components/Map/dayRoutePlan.test.ts new file mode 100644 index 000000000..58a56bde5 --- /dev/null +++ b/client/src/components/Map/dayRoutePlan.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { buildDayRouteRuns } from './dayRoutePlan' +import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' +import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types' + +const at = (lat: number, lng: number, order: number, extra: Record = {}) => + buildAssignment({ day_id: 1, order_index: order, place: buildPlace({ lat, lng }), ...extra }) + +const inputs = (over: Partial<{ + days: Day[]; assignments: AssignmentsMap; reservations: Reservation[] + accommodations: Accommodation[]; optimizeFromAccommodation: boolean | undefined +}> = {}) => ({ + days: [buildDay({ id: 1, day_number: 1 })], + assignments: {}, + reservations: [], + accommodations: [], + optimizeFromAccommodation: false, + ...over, +}) + +describe('buildDayRouteRuns', () => { + it('FE-MAP-DRP-001: orders a day\'s located stops into one run', () => { + const runs = buildDayRouteRuns(1, inputs({ + assignments: { '1': [at(48.86, 2.35, 1), at(45.76, 4.83, 0)] }, + })) + + expect(runs).toHaveLength(1) + // order_index, not the order they happen to sit in the array. + expect(runs[0].map(p => p.lat)).toEqual([45.76, 48.86]) + expect(runs[0].every(p => p.isPlace)).toBe(true) + }) + + it('FE-MAP-DRP-002: drops a stop with no coordinates', () => { + const runs = buildDayRouteRuns(1, inputs({ + assignments: { + '1': [ + at(48.86, 2.35, 0), + buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: null, lng: null }) }), + at(45.76, 4.83, 2), + ], + }, + })) + + expect(runs[0]).toHaveLength(2) + }) + + it('FE-MAP-DRP-003: a lone stop is no drive at all', () => { + expect(buildDayRouteRuns(1, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([]) + }) + + it('FE-MAP-DRP-004: carries the per-leg travel modes the router resolves against', () => { + const runs = buildDayRouteRuns(1, inputs({ + assignments: { + '1': [ + at(48.86, 2.35, 0, { leg_transport_mode: 'walking' }), + at(48.88, 2.36, 1, { incoming_leg_transport_mode: 'cycling' }), + ], + }, + })) + + expect(runs[0][0].leg_transport_mode).toBe('walking') + expect(runs[0][1].incoming_leg_transport_mode).toBe('cycling') + }) + + it('FE-MAP-DRP-005: two real places stay one run however far apart they are', () => { + // Paris → Tokyo is far past MAX_DRIVE_KM, but both are real places someone + // planned, so the run stands: the reachability guard only ever splits a leg + // that touches a booking endpoint (#2133). + const runs = buildDayRouteRuns(1, inputs({ + assignments: { '1': [at(48.86, 2.35, 0), at(35.68, 139.69, 1)] }, + })) + + expect(runs).toHaveLength(1) + }) + + it('FE-MAP-DRP-006: bookends the day with its accommodation when the setting is on', () => { + const accommodation = { + id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30, + start_day_id: 1, end_day_id: 1, + } as unknown as Accommodation + const runs = buildDayRouteRuns(1, inputs({ + assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] }, + accommodations: [accommodation], + optimizeFromAccommodation: true, + })) + + const flat = runs.flat() + expect(flat.some(p => p.lat === 48.80 && !p.isPlace)).toBe(true) + }) + + it('FE-MAP-DRP-007: leaves the hotel out when the setting is off', () => { + const accommodation = { + id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30, + start_day_id: 1, end_day_id: 1, + } as unknown as Accommodation + const runs = buildDayRouteRuns(1, inputs({ + assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] }, + accommodations: [accommodation], + optimizeFromAccommodation: false, + })) + + expect(runs.flat().some(p => p.lat === 48.80)).toBe(false) + }) + + it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => { + expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([]) + }) +}) diff --git a/client/src/components/Map/dayRoutePlan.ts b/client/src/components/Map/dayRoutePlan.ts new file mode 100644 index 000000000..72e43bfc0 --- /dev/null +++ b/client/src/components/Map/dayRoutePlan.ts @@ -0,0 +1,169 @@ +import { withHotelBookends } from './RouteCalculator' +import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge' +import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder' +import { withinDriveRange } from '../../utils/geo' +import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types' + +export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] + +/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */ +export interface DayRoutePoint { + lat: number + lng: number + isPlace: boolean + leg_transport_mode?: string | null + incoming_leg_transport_mode?: string | null +} + +/** Everything the plan depends on, passed in rather than read from the store, so the + * builder stays pure and one caller can run it over every day of a trip at once. */ +export interface DayRouteInputs { + days: Day[] + assignments: AssignmentsMap + reservations: Reservation[] + accommodations: Accommodation[] + optimizeFromAccommodation: boolean | undefined +} + +/** + * The runs of waypoints a day's route is drawn from, hotel bookends folded in. + * + * Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the + * same line the day view does. Every rule below is load-bearing and issue-numbered — + * a second copy of it would drift the moment either surface got the next fix. + */ +export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] { + const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input + const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) + const dayOrder = (id: number | null | undefined): number | null => { + if (id == null) return null + const d = allDays.find(x => x.id === id) + return d ? (d.day_number ?? allDays.indexOf(d)) : null + } + const thisOrder = dayOrder(dayId) + + // The order the day plan shows is the order the map has to draw, so take it from + // the same place the plan does rather than rebuilding it here. getTransportForDay + // brings the span filter, the hotel/assignment exclusions and the leg expansion with + // it; getMergedItems places each leg by its own saved position, falling back to its + // time. Notes carry no coordinates, so none are passed. + const dayTransports = thisOrder == null ? [] : getTransportForDay({ + reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)), + dayId, + dayAssignmentIds: da.map(a => a.id), + days: allDays, + }) + const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId }) + + type Entry = + | { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null } + | { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean } + const entries: Entry[] = merged.flatMap((item): Entry[] => { + if (item.type === 'place') { + const a = item.data + if (!a.place?.lat || !a.place?.lng) return [] + return [{ + kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null, + // Per-segment travel mode (#1281): mode of the leg leaving this place. + mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null, + // Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place. + incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null, + }] + } + if (item.type !== 'transport') return [] + const { from, to } = getTransportRouteEndpoints(item.data, dayId) + return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }] + }) + + // Group located places into driving runs. A transport WITH a location anchors the + // route to its departure point then breaks the run (you don't drive the flight); its + // arrival starts the next. A transport WITHOUT a location is ignored entirely. A run + // is only a real drive when it contains at least one actual place, or two back-to-back + // transports pair into a phantom [airport → airport] road route (#1394). A booking + // endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival + // sorted in among the day's local stops gets stapled to one of them (#2133). + const runs: DayRoutePoint[][] = [] + let currentRun: DayRoutePoint[] = [] + let runHasPlace = false + const closeRun = () => { + if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun) + currentRun = [] + runHasPlace = false + } + for (const entry of entries) { + if (entry.kind === 'place') { + const prev = currentRun[currentRun.length - 1] + // The open run may be nothing but a far-away arrival endpoint — break rather + // than draw the ocean. + if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun() + currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming }) + runHasPlace = true + } else if (entry.from || entry.to) { + const prev = currentRun[currentRun.length - 1] + if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false }) + closeRun() + if (entry.to) currentRun.push({ ...entry.to, isPlace: false }) + } + } + closeRun() + + // Bookend the route with the day's accommodation, so the drawn line matches the + // sidebar's hotel legs. + const day = allDays.find(d => d.id === dayId) + const bookends = day && optimizeFromAccommodation !== false + ? getDayBookendHotels(day, allDays, accommodations) + : null + const flatPts: DayRoutePoint[] = [] + for (const e of entries) { + if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming }) + else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) } + } + // A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode + // falls through to the day default for hotel-adjacent legs unless the place endpoint + // carries its own override. + const hotelPt = (a?: Accommodation): DayRoutePoint | null => + a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null + // Only draw a hotel bookend when the leg is a real drive: a place before check-in + // (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on + // an arrival/departure day (#1321, #2133) all draw no bookend. + const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to + const firstStop = entries.find(contributes) + const lastStop = [...entries].reverse().find(contributes) + const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => { + if (!e) return undefined + if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng } + const role: CarrierEdge = side === 'first' + ? (e.from ? 'departure' : 'arrival') + : (e.to ? 'arrival' : 'departure') + return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null } + } + // A carrier with a located endpoint today means the day changes geography by + // air/rail/sea — the check-in hotel is then the day's destination and the + // check-out hotel its origin, which the no-time bookend default respects (#2157). + const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId)) + const firstWay = flatPts[0] + const lastWay = flatPts[flatPts.length - 1] + const morningHotel = hotelPt(bookends?.morning) + const eveningHotel = hotelPt(bookends?.evening) + const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier) + && (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay)) + const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier) + && (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay)) + const runsWithHotel: DayRoutePoint[][] = withHotelBookends( + runs, + firstWay, + lastWay, + drawMorning ? morningHotel : null, + drawEvening ? eveningHotel : null, + ) + + // Transfer day with no activities: you check out of one accommodation and into + // another, so there are no waypoints for withHotelBookends to attach a leg to. + if (runsWithHotel.length === 0 && drawMorning && drawEvening) { + const m = hotelPt(bookends?.morning) + const e = hotelPt(bookends?.evening) + if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e]) + } + + return runsWithHotel +} diff --git a/client/src/components/Map/mapControlShadow.ts b/client/src/components/Map/mapControlShadow.ts new file mode 100644 index 000000000..3a24f123d --- /dev/null +++ b/client/src/components/Map/mapControlShadow.ts @@ -0,0 +1,10 @@ +/** + * The drop shadow every floating map control carries. + * + * Tighter and darker than `--sidebar-shadow`, which is built for a panel resting on + * the app background: over map tiles its 0.10 alpha spread over 32px all but + * disappears, so a round control read as painted onto the map rather than floating + * above it. This is the locate button's shadow, which was always the odd one out for + * exactly that reason — the others were the ones that needed to match it. + */ +export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)' diff --git a/client/src/components/Map/useTripRouteOverview.test.ts b/client/src/components/Map/useTripRouteOverview.test.ts new file mode 100644 index 000000000..fd7d2e596 --- /dev/null +++ b/client/src/components/Map/useTripRouteOverview.test.ts @@ -0,0 +1,139 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { dayColor } from '../Roadtrip/dayColors' +import { useTripRouteOverview } from './useTripRouteOverview' +import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' +import type { AssignmentsMap, RouteSegment } from '../../types' + +vi.mock('./RouteCalculator', async (importActual) => { + const actual = await importActual() + return { ...actual, calculateRouteWithLegs: vi.fn() } +}) + +const { calculateRouteWithLegs } = await import('./RouteCalculator') + +const leg = (distance: number, duration: number): RouteSegment => ({ + mid: [0, 0], from: [0, 0], to: [0, 0], + distance, duration, + distanceText: `${Math.round(distance / 1000)} km`, + durationText: '1 h', walkingText: '5 h', drivingText: '1 h', +}) + +const at = (lat: number, lng: number, order: number, dayId: number, extra: Record = {}) => + buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }), ...extra }) + +const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })] +const ASSIGNMENTS: AssignmentsMap = { + '1': [at(48.86, 2.35, 0, 1), at(48.88, 2.36, 1, 1)], + '2': [at(45.76, 4.83, 0, 2), at(45.78, 4.85, 1, 2)], +} + +const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days = DAYS) => + renderHook( + ({ assignments: a }: { assignments: AssignmentsMap }) => + useTripRouteOverview(7, days, a, [], [], 'driving', enabled), + { initialProps: { assignments } }, + ) + +beforeEach(() => { + vi.mocked(calculateRouteWithLegs).mockReset() + vi.mocked(calculateRouteWithLegs).mockResolvedValue({ + coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]], + distance: 12000, duration: 900, + legs: [leg(12000, 900)], + }) +}) + +describe('useTripRouteOverview', () => { + it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => { + const { result } = render(false) + + expect(result.current.days).toEqual([]) + expect(result.current.totalDistance).toBe(0) + expect(result.current.loading).toBe(false) + expect(calculateRouteWithLegs).not.toHaveBeenCalled() + }) + + it('FE-MAP-TRO-002: sums every day into the trip total', async () => { + const { result } = render() + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.days).toHaveLength(2) + expect(result.current.days.map(d => d.distance)).toEqual([12000, 12000]) + expect(result.current.totalDistance).toBe(24000) + expect(result.current.totalDuration).toBe(1800) + }) + + it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => { + const { result } = render() + + await waitFor(() => expect(result.current.loading).toBe(false)) + const colors = result.current.days.map(d => d.color) + // The same palette either way, so a day does not change colour when the trip is + // read as a road trip instead of an overview. + expect(colors[0]).toEqual(dayColor(1)) + expect(colors[1]).toEqual(dayColor(2)) + // Every drawn line is labelled with the colour of the day it belongs to. + expect(result.current.lineColors).toHaveLength(result.current.lines.length) + expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2) + }) + + it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => { + const { result } = render(true, { + '1': [at(48.86, 2.35, 0, 1, { leg_transport_mode: 'walking' }), at(48.88, 2.36, 1, 1)], + '2': ASSIGNMENTS['2'], + }) + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.days[0].modes).toEqual(['walking']) + expect(result.current.days[1].modes).toEqual(['driving']) + expect(result.current.days[0].segments[0].mode).toBe('walking') + }) + + it('FE-MAP-TRO-005: draws straight lines first and keeps them when routing refuses', async () => { + vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429')) + const { result } = render() + + await waitFor(() => expect(result.current.loading).toBe(false)) + // The shape of the trip is still on the map... + expect(result.current.lines).toHaveLength(2) + expect(result.current.lines[0]).toEqual([[48.86, 2.35], [48.88, 2.36]]) + // ...but a leg that never answered contributes no distance. + expect(result.current.totalDistance).toBe(0) + }) + + it('FE-MAP-TRO-006: a day with nothing to drive is left out', async () => { + const { result } = render(true, { '1': ASSIGNMENTS['1'], '2': [at(45.76, 4.83, 0, 2)] }) + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.days.map(d => d.dayId)).toEqual([1]) + }) + + it('FE-MAP-TRO-007: frames the whole trip, not one day of it', async () => { + const { result } = render() + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.focusPoints.length).toBe(result.current.lines.flat().length) + expect(result.current.focusPoints.length).toBeGreaterThan(2) + }) + + it('FE-MAP-TRO-008: renaming a place does not re-route the trip', async () => { + const { result, rerender } = render() + await waitFor(() => expect(result.current.loading).toBe(false)) + const calls = vi.mocked(calculateRouteWithLegs).mock.calls.length + + expect(calls).toBeGreaterThan(0) + + // A new object identity with the same geometry: the plan memo re-runs, the + // routing round must not. + rerender({ + assignments: { + ...ASSIGNMENTS, + '1': ASSIGNMENTS['1'].map(a => ({ ...a, place: { ...a.place, name: 'Renamed' } })), + }, + }) + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls) + }) +}) diff --git a/client/src/components/Map/useTripRouteOverview.ts b/client/src/components/Map/useTripRouteOverview.ts new file mode 100644 index 000000000..5487e3bb4 --- /dev/null +++ b/client/src/components/Map/useTripRouteOverview.ts @@ -0,0 +1,227 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator' +import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan' +import { resolveLegMode } from '../Planner/legMode' +import { useSettingsStore } from '../../store/settingsStore' +import { dayColor } from '../Roadtrip/dayColors' +import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types' + +/** One travel day of the overview: the roads it covers, in its own colour. */ +export interface TripOverviewDay { + dayId: number + dayNumber: number + date: string | null + title: string | null + /** The core and casing this day is drawn in — the road trip's palette, so a day is + * the same colour whichever way the trip is being read. */ + color: { line: string; casing: string } + /** One polyline per run of the day, `[lat, lng]`. */ + lines: [number, number][][] + segments: RouteSegment[] + /** Metres and seconds, summed over the day's legs. */ + distance: number + duration: number + /** The modes this day is actually travelled in, first use first. */ + modes: string[] +} + +export interface TripRouteOverview { + days: TripOverviewDay[] + /** Every day's polylines flattened in trip order — what the map draws. */ + lines: [number, number][][] + /** The colour of each entry of `lines`, same index. */ + lineColors: { line: string; casing: string }[] + segments: RouteSegment[] + /** Every drawn coordinate, so the map can frame the whole trip at once. */ + focusPoints: [number, number][] + totalDistance: number + totalDuration: number + /** True until every leg has answered — the totals are a partial sum until then. */ + loading: boolean +} + +const EMPTY: TripRouteOverview = { + days: [], lines: [], lineColors: [], segments: [], focusPoints: [], + totalDistance: 0, totalDuration: 0, loading: false, +} + +/** Neighbouring legs of one run that resolve to the same mode travel as one request, + * exactly as the single-day route builds them. */ +interface Chunk { points: DayRoutePoint[]; mode: string } + +function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] { + const chunks: Chunk[] = [] + let i = 0 + while (i < run.length - 1) { + const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode) + let end = i + 1 + while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++ + chunks.push({ points: run.slice(i, end + 1), mode }) + i = end + } + return chunks +} + +const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng]) + +/** + * Every travel day's route at once, each day in its own colour, with the trip's total + * distance (#1736). + * + * The geometry is the day view's own — `buildDayRouteRuns` is the same builder + * `useRouteCalculation` draws the selected day from, so a day looks identical whether + * you are looking at it alone or at the whole trip. Only the aggregation is new. + * + * Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and + * the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the + * day route and the sidebar connectors, so the day already on screen costs nothing. + */ +export function useTripRouteOverview( + tripId: number | null, + days: Day[], + assignments: AssignmentsMap, + reservations: Reservation[], + accommodations: Accommodation[], + profile: RouteProfileKey, + enabled: boolean, +): TripRouteOverview { + const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) + // Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300). + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const [result, setResult] = useState(EMPTY) + const abortRef = useRef(null) + + const plan = useMemo(() => { + if (!enabled) return [] + return [...days] + .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) + .map(day => ({ + day, + runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }), + })) + .filter(entry => entry.runs.length > 0) + }, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation]) + + // Only geometry and mode decide whether legs have to be fetched again: renaming a + // place or editing its notes must not fire a routing round. + const planKey = useMemo( + () => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs + .map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|')) + .join('/')}`).join(';'), + [plan], + ) + + useEffect(() => { + abortRef.current?.abort() + if (!plan.length) { setResult(EMPTY); return } + + const controller = new AbortController() + abortRef.current = controller + + const colorFor = (day: Day) => dayColor(day.day_number ?? 0) + + // Chunk every day up front, so the routed answers can be filed by position and + // reassembled in order however they come back. + const chunked = plan.map(({ day, runs }) => { + const dayDefaultMode = day.default_transport_mode || profile + return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) } + }) + + const assemble = ( + routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][], + ): TripRouteOverview => { + const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => { + const lines: [number, number][][] = [] + const segments: RouteSegment[] = [] + runs.forEach((chunks, r) => { + const polyline: [number, number][] = [] + chunks.forEach((chunk, c) => { + const answer = routed[d][r][c] + const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points) + for (const point of coords) { + // Drop the point shared with the previous chunk so concatenated legs + // don't leave a duplicate at each junction. + const last = polyline[polyline.length - 1] + if (last && last[0] === point[0] && last[1] === point[1]) continue + polyline.push(point) + } + if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode }) + }) + if (polyline.length >= 2) lines.push(polyline) + }) + const modes: string[] = [] + for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode) + return { + dayId: day.id, + dayNumber: day.day_number ?? 0, + date: day.date ?? null, + title: day.title ?? null, + color: colorFor(day), + lines, + segments, + distance: segments.reduce((sum, s) => sum + s.distance, 0), + duration: segments.reduce((sum, s) => sum + s.duration, 0), + modes, + } + }) + const lines = overviewDays.flatMap(d => d.lines) + return { + days: overviewDays, + lines, + lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)), + segments: overviewDays.flatMap(d => d.segments), + focusPoints: lines.flat(), + totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0), + totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0), + loading: false, + } + } + + const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as + ({ coordinates: [number, number][]; legs: RouteSegment[] }) | null))) + + // Straight lines first so the shape of the trip is on screen immediately, then the + // real roads replace them — the same two-step the single-day route draws with. + setResult({ ...assemble(routed), loading: true }) + + ;(async () => { + const tasks: (() => Promise)[] = [] + chunked.forEach(({ day, runs }, d) => { + runs.forEach((chunks, r) => { + chunks.forEach((chunk, c) => { + tasks.push(async () => { + try { + const answer = await calculateRouteWithLegs( + chunk.points.map(p => ({ lat: p.lat, lng: p.lng })), + { signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id }, + ) + routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs } + } catch { + // Routing refused this leg (usually a rate limit) — it keeps its + // straight line and contributes no distance, exactly as a day route's + // failed leg does. + } + }) + }) + }) + }) + + // Small pool on purpose, the same one the sidebar connectors use: a fortnight is + // dozens of legs and the routing host is often a shared OSRM. + let next = 0 + const worker = async () => { + while (next < tasks.length && !controller.signal.aborted) await tasks[next++]() + } + await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker)) + + if (!controller.signal.aborted) setResult(assemble(routed)) + })() + + return () => controller.abort() + // planKey is derived from the same inputs as plan, so keying on the string is + // equivalent while staying stable across unrelated renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [planKey, profile, tripId, distanceUnit]) + + return result +} diff --git a/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx b/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx index e61dbff9f..255fe328e 100644 --- a/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx +++ b/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx @@ -4,7 +4,7 @@ import type { RouteSegment } from '../../types' // Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt — // its legs carry the profile-true durationText anyway, the icon just signals that // the time came from a plugin router (e.g. EV routing with charge time folded in). -function profileIcon(profile: string) { +export function profileIcon(profile: string) { if (profile === 'walking') return Footprints if (profile.startsWith('plugin:')) return Zap return Car diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 8258f2d42..b129bea73 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-093 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -558,6 +558,35 @@ describe('ReservationModal', () => { }); }); + it('FE-PLANNER-RESMODAL-094: an outside pointer closes the file picker while an inside pointer keeps it open', async () => { + const res = buildReservation({ id: 5 }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + + const pickerItem = screen.getByText('invoice.pdf'); + fireEvent.pointerDown(pickerItem); + expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-RESMODAL-095: closing and reopening the modal resets the file picker', async () => { + const res = buildReservation({ id: 5 }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + const { rerender } = render(); + + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); + + rerender(); + rerender(); + + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-RESMODAL-040: removing pending file removes it from list', async () => { render(); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 92034200a..6fb092ffa 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -84,6 +84,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const [linkedFileIds, setLinkedFileIds] = useState([]) // Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves. const [travelerIds, setTravelerIds] = useState>(new Set()) + const filePickerRef = useRef(null) + + useEffect(() => { + if (!showFilePicker) return + const closeOnOutsidePointer = (event: PointerEvent) => { + if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false) + } + document.addEventListener('pointerdown', closeOnOutsidePointer) + return () => document.removeEventListener('pointerdown', closeOnOutsidePointer) + }, [showFilePicker]) + + useEffect(() => { + if (!isOpen) setShowFilePicker(false) + }, [isOpen]) const assignmentOptions = useMemo( () => buildAssignmentOptions(days, assignments, t, locale), @@ -731,7 +745,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p {uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')} } {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( -
+
} {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( -
+
updatePlan({ school_holidays_enabled: !plan.school_holidays_enabled })} ariaLabel={t('vacay.schoolHolidays')} />
+ {schoolCountryError &&

{schoolCountryError}

} {plan.school_holidays_enabled && ( <> {schoolHolidayCalendars.map(cal => ( @@ -452,18 +454,18 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string) * apart from "this country has no regions"; on the list alone both are empty. */ function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') { - const [loaded, setLoaded] = useState<{ country: string; options: Option[] }>({ country: '', options: [] }) + const [loaded, setLoaded] = useState<{ country: string; options: Option[]; failed?: boolean }>({ country: '', options: [] }) useEffect(() => { if (!country) return const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions let stale = false - load(country).then(options => { if (!stale) setLoaded({ country, options }) }) + load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) }) return () => { stale = true } }, [country, calendarType]) const ready = loaded.country === country - return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready } + return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready } } function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: { @@ -480,7 +482,7 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd const [baseRegion] = cal.region.split('|') const country = baseRegion.split('-')[0] const region = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : '' - const { regions } = useRegionOptions(country, calendarType) + const { regions, regionError } = useRegionOptions(country, calendarType) useEffect(() => { setLabel(cal.label || '') }, [cal.label]) @@ -501,8 +503,9 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd
onUpdate({ region: v })} /> + {regionError &&

{t('schoolCatalog.loadError')}

} {regions.length > 0 && ( - onUpdate({ region: v })} /> + option.value === country) ? country : '')} placeholder={t('vacay.selectRegion')} options={regions} onChange={v => onUpdate({ region: v })} /> )}
) @@ -522,12 +525,12 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o const [baseRegion] = region.split('|') const country = baseRegion.split('-')[0] || '' const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : '' - const { regions, loadingRegions } = useRegionOptions(country, calendarType) + const { regions, loadingRegions, regionError } = useRegionOptions(country, calendarType) // Adding is blocked while the region list is on its way: an empty list would // otherwise pass as "this country needs no region" and create a calendar that // draws nothing (#1813). The button shows a spinner so the wait is visible. - const canAdd = Boolean(country) && !loadingRegions && (regions.length === 0 || selectedRegion !== '') + const canAdd = Boolean(country) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region)) return (
@@ -544,8 +547,9 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
+ {regionError &&

{t('schoolCatalog.loadError')}

} {regions.length > 0 && ( - + option.value === country) ? country : '')} placeholder={t('vacay.selectRegion')} options={regions} onChange={setRegion} /> )}