diff --git a/client/src/components/Journey/JourneyDetailPageDatePicker.tsx b/client/src/components/Journey/JourneyDetailPageDatePicker.tsx index 8da27b91..4b35ef0b 100644 --- a/client/src/components/Journey/JourneyDetailPageDatePicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageDatePicker.tsx @@ -15,7 +15,8 @@ export function DatePicker({ value, onChange, tripDates }: { }) const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate() - const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay() + // Monday-first, matching CustomDateTimePicker / VacayCalendar (getDay() is Sunday=0). + const firstDow = (new Date(viewMonth.year, viewMonth.month, 1).getDay() + 6) % 7 const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) const prevMonth = () => { @@ -68,7 +69,7 @@ export function DatePicker({ value, onChange, tripDates }: { {/* Weekday headers */}
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => ( + {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
{d}
))}
diff --git a/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx b/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx index 7c00f110..2273617c 100644 --- a/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx +++ b/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx @@ -10,6 +10,7 @@ import JourneyShareSection from './JourneyShareSection' import type { JourneyDetail } from '../../store/journeyStore' import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { AddTripDialog } from './JourneyDetailPageAddTripDialog' +import { normalizeImageFile } from '../../utils/convertHeic' export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: { journey: JourneyDetail @@ -49,7 +50,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, const file = e.target.files?.[0] if (!file) return const formData = new FormData() - formData.append('cover', file) + formData.append('cover', await normalizeImageFile(file)) try { await journeyApi.uploadCover(journey.id, formData) toast.success(t('journey.settings.coverUpdated')) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 73f50f1c..e86f75ee 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -447,7 +447,7 @@ export function MapViewGL({ geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) }, })) src.setData({ type: 'FeatureCollection', features }) - }, [route]) + }, [route, mapReady]) // Travel times now live in the day sidebar (per-segment connectors), not on the map. @@ -470,7 +470,7 @@ export function MapViewGL({ } catch { return [] } }) src.setData({ type: 'FeatureCollection', features }) - }, [places]) + }, [places, mapReady]) // Reservation overlay — mirrors the Leaflet ReservationOverlay: great- // circle arcs for flights/cruises, straight lines for trains/cars, diff --git a/client/src/components/Planner/FileImportModal.tsx b/client/src/components/Planner/FileImportModal.tsx index e5b452f9..2e752afa 100644 --- a/client/src/components/Planner/FileImportModal.tsx +++ b/client/src/components/Planner/FileImportModal.tsx @@ -31,7 +31,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini const loadTrip = useTripStore((s) => s.loadTrip) const fileInputRef = useRef(null) - const [file, setFile] = useState(null) + const [files, setFiles] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -51,7 +51,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini } const reset = () => { - setFile(null) + setFiles([]) setIsDragOver(false) setLoading(false) setError('') @@ -67,14 +67,14 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini if (initialFile) { const err = validateFile(initialFile) if (err) { - setFile(null) + setFiles([]) setError(err) } else { - setFile(initialFile) + setFiles([initialFile]) setError('') } } else { - setFile(null) + setFiles([]) setError('') } // validateFile uses t() which is stable — intentionally omitted from deps @@ -86,22 +86,32 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini onClose() } - const selectFile = (f: File) => { - const validationError = validateFile(f) - if (validationError) { - setError(validationError) - setFile(null) + const selectFiles = (incoming: File[]) => { + if (incoming.length === 0) return + const valid: File[] = [] + let firstError: string | null = null + for (const f of incoming) { + const validationError = validateFile(f) + if (validationError) { + firstError = firstError ?? validationError + continue + } + valid.push(f) + } + if (valid.length === 0) { + setError(firstError ?? '') + setFiles([]) return } - setFile(f) - setError('') + setFiles(valid) + setError(firstError ?? '') setSummary(null) } const handleInputChange = (e: React.ChangeEvent) => { - const f = e.target.files?.[0] + const list = e.target.files ? Array.from(e.target.files) : [] e.target.value = '' - if (f) selectFile(f) + if (list.length) selectFiles(list) } const handleDragOver = (e: React.DragEvent) => { @@ -116,71 +126,92 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) - const f = e.dataTransfer.files[0] - if (f) selectFile(f) + const list = Array.from(e.dataTransfer.files) + if (list.length) selectFiles(list) } const handleImport = async () => { - if (!file || loading) return - const ext = file.name.toLowerCase().split('.').pop() + if (files.length === 0 || loading) return setLoading(true) setError('') setSummary(null) - try { - if (ext === 'gpx') { - const result = await placesApi.importGpx(tripId, file, gpxOpts) - await loadTrip(tripId) - if (result.count === 0 && result.skipped > 0) { - toast.warning(t('places.importAllSkipped')) + let totalCreated = 0 + let totalSkipped = 0 + const createdIds: number[] = [] + const errors: string[] = [] + let mergedSummary: PlacesImportSummary | null = null + let importedGpx = false + let importedKml = false + + for (const f of files) { + const ext = f.name.toLowerCase().split('.').pop() + try { + if (ext === 'gpx') { + importedGpx = true + const result = await placesApi.importGpx(tripId, f, gpxOpts) + totalCreated += result.count ?? 0 + totalSkipped += result.skipped ?? 0 + if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id)) } else { - toast.success(t('places.gpxImported', { count: result.count })) - } - if (result.places?.length > 0) { - const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t('undo.importGpx'), async () => { - try { await placesApi.bulkDelete(tripId, importedIds) } catch {} - await loadTrip(tripId) - }) - } - handleClose() - } else { - const result = await placesApi.importMapFile(tripId, file, kmlOpts) - await loadTrip(tripId) - setSummary(result.summary || null) - if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) { - toast.warning(t('places.importAllSkipped')) - } else { - toast.success(t('places.kmlKmzImported', { count: result.count })) - } - if (result.summary?.errors?.length > 0) { - setError(result.summary.errors.join('\n')) - } - if (result.places?.length > 0) { - const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t('undo.importKeyholeMarkup'), async () => { - try { await placesApi.bulkDelete(tripId, importedIds) } catch {} - await loadTrip(tripId) - }) + importedKml = true + const result = await placesApi.importMapFile(tripId, f, kmlOpts) + totalCreated += result.count ?? 0 + if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id)) + const s = result.summary as PlacesImportSummary | undefined + if (s) { + mergedSummary = mergedSummary + ? { + totalPlacemarks: mergedSummary.totalPlacemarks + s.totalPlacemarks, + createdCount: mergedSummary.createdCount + s.createdCount, + skippedCount: mergedSummary.skippedCount + s.skippedCount, + warnings: [...mergedSummary.warnings, ...(s.warnings ?? [])], + errors: [...mergedSummary.errors, ...(s.errors ?? [])], + } + : s + totalSkipped += s.skippedCount ?? 0 + } } + } catch (err: any) { + const message = err?.response?.data?.error || t('places.importFileError') + errors.push(files.length > 1 ? `${f.name}: ${message}` : message) } - } catch (err: any) { - const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined - if (responseSummary) setSummary(responseSummary) - const message = err?.response?.data?.error || t('places.importFileError') - setError(message) - toast.error(message) - } finally { - setLoading(false) } + + await loadTrip(tripId) + + if (createdIds.length > 0) { + pushUndo?.(importedGpx && !importedKml ? t('undo.importGpx') : t('undo.importKeyholeMarkup'), async () => { + try { await placesApi.bulkDelete(tripId, createdIds) } catch {} + await loadTrip(tripId) + }) + } + + if (totalCreated > 0) { + const key = importedKml && !importedGpx ? 'places.kmlKmzImported' : 'places.gpxImported' + toast.success(t(key, { count: totalCreated })) + } else if (totalSkipped > 0 && errors.length === 0) { + toast.warning(t('places.importAllSkipped')) + } + + if (mergedSummary) setSummary(mergedSummary) + if (errors.length > 0) { + setError(errors.join('\n')) + toast.error(errors[0]) + } + + setLoading(false) + + // Close once everything succeeded and there's no KML summary left to surface. + if (errors.length === 0 && !mergedSummary) handleClose() } - const fileExt = file?.name.toLowerCase().split('.').pop() ?? '' - const isGpx = fileExt === 'gpx' - const isKml = fileExt === 'kml' || fileExt === 'kmz' + const exts = files.map(f => f.name.toLowerCase().split('.').pop() ?? '') + const isGpx = exts.includes('gpx') + const isKml = exts.some(e => e === 'kml' || e === 'kmz') const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths - const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected + const canImport = files.length > 0 && !loading && !gpxNoneSelected && !kmlNoneSelected if (!isOpen) return null @@ -206,6 +237,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini ref={fileInputRef} type="file" accept=".gpx,.kml,.kmz" + multiple style={{ display: 'none' }} onChange={handleInputChange} /> @@ -240,8 +272,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini {isDragOver ? ( {t('places.importFileDropActive')} - ) : file ? ( - {file.name} + ) : files.length > 0 ? ( + {files.map(f => f.name).join(', ')} ) : ( {t('places.importFileDropHere')} )} diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 4ddfae0e..2dea519d 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -225,13 +225,16 @@ describe('PlaceFormModal', () => { expect(screen.getByDisplayValue('48.8584')).toBeInTheDocument(); }); - it('FE-PLANNER-PLACEFORM-021: maps search error shows toast', async () => { + it('FE-PLANNER-PLACEFORM-021: maps search error surfaces the server-provided reason', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); + // The backend forwards the real upstream error (e.g. a Google Places API message); + // the modal must show it instead of a generic "search failed" so the cause is visible. server.use( - http.post('/api/maps/search', () => HttpResponse.json({ error: 'fail' }, { status: 500 })), + http.post('/api/maps/search', () => + HttpResponse.json({ error: 'Places API (New) has not been used in project 123 or it is disabled' }, { status: 403 })), ); render(); @@ -241,7 +244,7 @@ describe('PlaceFormModal', () => { await waitFor(() => { expect(addToast).toHaveBeenCalledWith( - expect.stringMatching(/search failed/i), + expect.stringMatching(/Places API \(New\) has not been used/i), 'error', undefined, ); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index ae70fa61..fc76fb79 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -10,6 +10,7 @@ import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers' +import { getApiErrorMessage } from '../../utils/apiError' import type { Place, Category, Assignment } from '../../types' // The submit payload mirrors the form, but lat/lng are parsed to numbers and @@ -188,7 +189,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const result = await mapsApi.search(mapsSearch, language) setMapsResults(result.places || []) } catch (err: unknown) { - toast.error(t('places.mapsSearchError')) + toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally { setIsSearchingMaps(false) } @@ -228,7 +229,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } catch (err) { console.error('Failed to fetch place details:', err) setMapsSearch(previousSearch) - toast.error(t('places.mapsSearchError')) + toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally { setIsSearchingMaps(false) } diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index f819a2a0..4ffa968b 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -222,7 +222,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr )} {isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} - className="modal-backdrop" + className="trek-modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}> @@ -240,7 +240,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr )} {isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} - className="modal-backdrop" + className="trek-modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index ed5bbac9..6ede18b7 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -260,7 +260,9 @@ describe('TripFormModal', () => { items: [{ type: 'image/png', getAsFile: () => file }], }, }); - expect(mockCreateObjectURL).toHaveBeenCalledWith(file); + // Cover selection now normalizes the file (HEIC -> JPEG) before previewing, so the + // createObjectURL call lands a microtask later; a non-HEIC file passes through unchanged. + await waitFor(() => expect(mockCreateObjectURL).toHaveBeenCalledWith(file)); Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: original }); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index ef1a528a..7fe17302 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -8,6 +8,7 @@ import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CustomDatePicker } from '../shared/CustomDateTimePicker' +import { normalizeImageFile } from '../../utils/convertHeic' import type { Trip } from '../../types' import type { TripCreateRequest } from '@trek/shared' @@ -141,15 +142,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } } - const handleCoverSelect = (file) => { + const handleCoverSelect = async (file) => { if (!file) return + // HEIC/HEIF from iOS can't be rendered or stored as-is — convert to JPEG first + const normalized = await normalizeImageFile(file) if (isEditing && trip?.id) { // Existing trip: upload immediately - uploadCoverNow(file) + uploadCoverNow(normalized) } else { // New trip: stage for upload after creation - setPendingCoverFile(file) - setCoverPreview(URL.createObjectURL(file)) + setPendingCoverFile(normalized) + setCoverPreview(URL.createObjectURL(normalized)) } } diff --git a/client/src/components/shared/Modal.test.tsx b/client/src/components/shared/Modal.test.tsx index 261b375a..be49756f 100644 --- a/client/src/components/shared/Modal.test.tsx +++ b/client/src/components/shared/Modal.test.tsx @@ -56,7 +56,7 @@ describe('Modal', () => { it('FE-COMP-MODAL-008: clicking the backdrop calls onClose', () => { render(

inner

); - const backdrop = document.querySelector('.modal-backdrop') as HTMLElement; + const backdrop = document.querySelector('.trek-modal-backdrop') as HTMLElement; // Simulate mousedown then click on the backdrop itself fireEvent.mouseDown(backdrop, { target: backdrop }); fireEvent.click(backdrop); diff --git a/client/src/components/shared/Modal.tsx b/client/src/components/shared/Modal.tsx index 48d97257..14033088 100644 --- a/client/src/components/shared/Modal.tsx +++ b/client/src/components/shared/Modal.tsx @@ -51,7 +51,7 @@ export default function Modal({ return ReactDOM.createPortal(
{ mouseDownTarget.current = e.target }} onClick={e => { diff --git a/client/src/index.css b/client/src/index.css index 09b16614..9eb16666 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -734,7 +734,7 @@ img[alt="TREK"] { .dark .min-h-screen { background-color: var(--bg-primary) !important; } /* Modal-Hintergrund */ -.dark .modal-backdrop { background: rgba(0,0,0,0.6); } +.dark .trek-modal-backdrop { background: rgba(0,0,0,0.6); } /* ── Dark: Fallback für Komponenten die noch nicht auf CSS-Variablen umgestellt sind ── */ .dark { @@ -766,8 +766,8 @@ img[alt="TREK"] { animation: slide-out-right 0.3s ease-in forwards; } -/* Modal-Hintergrund */ -.modal-backdrop { +/* Modal-Hintergrund (eigener Namespace, sonst blenden Content-Blocker .modal-backdrop aus) */ +.trek-modal-backdrop { backdrop-filter: blur(4px); } diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 6e5f2422..2fef96ed 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -141,7 +141,10 @@ export function useAtlas() { for (const f of geo.features) { const a2 = f.properties?.ISO_A2 const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3 - if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) { + // Only real 2-letter ISO codes: natural-earth uses subdivision-style + // values like "CN-TW" for Taiwan, which would otherwise overwrite the + // legitimate TWN->TW reverse mapping and break the country (#1049). + if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) { A2_TO_A3[a2] = a3 } } @@ -619,7 +622,7 @@ export function useAtlas() { try { const result = await mapsApi.search(bucketSearch, language) setBucketSearchResults(result.places || []) - } catch {} finally { setBucketSearching(false) } + } catch (err) { console.error('Bucket-list place search failed:', err) } finally { setBucketSearching(false) } } const handleSelectBucketPoi = (result: any) => { diff --git a/client/src/utils/apiError.ts b/client/src/utils/apiError.ts new file mode 100644 index 00000000..181cb42a --- /dev/null +++ b/client/src/utils/apiError.ts @@ -0,0 +1,9 @@ +/** + * Pulls the server-provided error string out of an axios-style error so the UI can + * surface the real reason (e.g. a Google Places API message such as "Places API (New) + * has not been used in project … or it is disabled") instead of a generic fallback. + */ +export function getApiErrorMessage(err: unknown, fallback: string): string { + const message = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error + return typeof message === 'string' && message.trim() ? message : fallback +} diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 744815c6..7e930e3e 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -117,7 +117,7 @@ export class PlacesController { if (!importWaypoints && !importRoutes && !importTracks) { throw new HttpException({ error: 'No import types selected' }, 400); } - const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks }); + const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks, defaultName: file.originalname }); if (!result) { throw new HttpException({ error: 'No matching places found in GPX file' }, 400); } diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 909f8483..eedf767d 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -52,7 +52,11 @@ export class PlacesService { return svc.deletePlacesMany(tripId, ids); } - importGpx(tripId: string, buffer: Buffer, opts: { importWaypoints: boolean; importRoutes: boolean; importTracks: boolean }) { + importGpx( + tripId: string, + buffer: Buffer, + opts: { importWaypoints: boolean; importRoutes: boolean; importTracks: boolean; defaultName?: string }, + ) { return svc.importGpx(tripId, buffer, opts); } diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 1620137a..c9cf091c 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -534,13 +534,18 @@ const geocodingInFlight = new Set(); const regionCache = new Map(); -async function reverseGeocodeRegion(lat: number, lng: number): Promise { - const key = roundKey(lat, lng); - if (regionCache.has(key)) return regionCache.get(key)!; +// A zoom-8 reverse geocode of a GB place only resolves to the constituent country +// (England/Scotland/Wales/Northern Ireland). Natural Earth's admin-1 polygons for GB +// are counties and boroughs, so those four codes match no polygon and never highlight. +const GB_CONSTITUENT_CODES = new Set(['GB-ENG', 'GB-SCT', 'GB-WLS', 'GB-NIR']); + +// Returns the OSM address object, {} for an "ok but empty" response (so it is cached as +// a definitive miss), or null for a transient failure (so it is retried next time). +async function fetchNominatimAddress(lat: number, lng: number, zoom: number): Promise | null> { await throttleNominatim(); try { const res = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`, + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=${zoom}&accept-language=en`, { headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' }, signal: AbortSignal.timeout(10_000), @@ -548,27 +553,52 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise }; - const countryCode = data.address?.country_code?.toUpperCase() || null; - // Try finest ISO level first (lvl6 = departments/provinces), then lvl5, then lvl4 (states/regions) - let regionCode = data.address?.['ISO3166-2-lvl6'] || data.address?.['ISO3166-2-lvl5'] || data.address?.['ISO3166-2-lvl4'] || null; - // Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility) - if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) { - regionCode = regionCode.replace(/[A-Z]$/i, ''); - } - const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null; - if (!countryCode || !regionName) { regionCache.set(key, null); return null; } - const info: RegionInfo = { - country_code: countryCode, - region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`, - region_name: regionName, - }; - regionCache.set(key, info); - return info; + return data.address ?? {}; } catch { return null; } } +function buildRegionInfo(address: Record, preferFinest: boolean): RegionInfo | null { + const countryCode = address.country_code?.toUpperCase() || null; + // Coarse path (almost every country) lands on the admin-1 level that matches Natural + // Earth directly; the finest path is used only to rescue codes that are too broad. + let regionCode = preferFinest + ? (address['ISO3166-2-lvl8'] || address['ISO3166-2-lvl7'] || address['ISO3166-2-lvl6'] || address['ISO3166-2-lvl5'] || null) + : (address['ISO3166-2-lvl6'] || address['ISO3166-2-lvl5'] || address['ISO3166-2-lvl4'] || null); + // Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility) + if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) { + regionCode = regionCode.replace(/[A-Z]$/i, ''); + } + const regionName = preferFinest + ? (address.city || address.county || address.state_district || address.borough || address.state || address.province || address.region || null) + : (address.state || address.province || address.region || address.county || address.city || null); + if (!countryCode || !regionName) return null; + return { + country_code: countryCode, + region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`, + region_name: regionName, + }; +} + +async function reverseGeocodeRegion(lat: number, lng: number): Promise { + const key = roundKey(lat, lng); + if (regionCache.has(key)) return regionCache.get(key)!; + const address = await fetchNominatimAddress(lat, lng, 8); + if (!address) return null; // transient failure — leave uncached so a later call retries + let info = buildRegionInfo(address, false); + // GB constituent-country codes map to no admin-1 polygon, so re-resolve them at a finer + // zoom where Nominatim exposes the county/borough code (GB-LND, GB-MAN, GB-CON, …) that + // the polygons actually carry. + if (info && info.country_code === 'GB' && GB_CONSTITUENT_CODES.has(info.region_code)) { + const finerAddress = await fetchNominatimAddress(lat, lng, 10); + const finer = finerAddress ? buildRegionInfo(finerAddress, true) : null; + if (finer && !GB_CONSTITUENT_CODES.has(finer.region_code)) info = finer; + } + regionCache.set(key, info); + return info; +} + export async function getVisitedRegions(userId: number): Promise<{ regions: Record }> { const trips = getUserTrips(userId); const tripIds = trips.map(t => t.id); diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index d5dd7810..ed91c3eb 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -346,6 +346,8 @@ export interface GpxImportOptions { importWaypoints?: boolean; importRoutes?: boolean; importTracks?: boolean; + /** Source filename used to name unnamed routes/tracks (keeps multiple imports distinct). */ + defaultName?: string; } export interface KmlImportOptions { @@ -354,7 +356,7 @@ export interface KmlImportOptions { } export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) { - const { importWaypoints = true, importRoutes = true, importTracks = true } = opts; + const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts; const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); const gpx = parsed?.gpx; @@ -363,6 +365,20 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt const str = (v: unknown) => (v != null ? String(v).trim() : null); const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; }; + // Routes and tracks rarely carry their own . Without one they all fall back to the + // same generic label, so name-based dedup drops every import after the first. Derive a + // base from the source filename (the requested behaviour) and suffix an index so multiple + // geometries from one file stay distinct. + const rawName = str(defaultName); + const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null; + let geoSeq = 0; + const geoName = (explicit: string | null, fallback: string): string => { + if (explicit) return explicit; + geoSeq++; + const base = baseName || fallback; + return geoSeq === 1 ? base : `${base} ${geoSeq}`; + }; + type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }; const waypoints: WaypointEntry[] = []; @@ -385,7 +401,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt if (pts.length === 0) continue; const hasAllEle = pts.every(p => p.ele !== null); const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: str(rte.name) || 'GPX Route', description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) }); + waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) }); } } @@ -405,7 +421,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt const start = trackPoints[0]; const hasAllEle = trackPoints.every(p => p.ele !== null); const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) }); + waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) }); } } diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index 5d024b7e..63ae51b7 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -122,12 +122,26 @@ export function generateDays(tripId: number | bigint | string, startDate: string del.run(dated[i].id); } - // Any remaining unused dateless days: keep as dateless, just renumber. + // Any remaining unused dateless days: drop the empty placeholders so day_count + // reflects the dated range, but keep ones that still hold content (assignments, + // notes, accommodations) — mirrors the dateless-path trimming above (#1083). // Base must be max(targetDates.length, dated.length) to avoid colliding with // positives already assigned by the main loop or the overflow loop above. + const isEmptyDay = db.prepare( + `SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id) + AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id) + AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty` + ); const maxAssigned = Math.max(targetDates.length, dated.length); + let keptDateless = 0; for (let i = datelessIdx; i < dateless.length; i++) { - setDayNumber.run(maxAssigned + (i - datelessIdx) + 1, dateless[i].id); + const empty = (isEmptyDay.get({ id: dateless[i].id }) as { empty: number }).empty; + if (empty) { + del.run(dateless[i].id); + } else { + setDayNumber.run(maxAssigned + keptDateless + 1, dateless[i].id); + keptDateless++; + } } // Final renumber to compact and eliminate any gaps/negatives diff --git a/server/tests/unit/services/atlasService.test.ts b/server/tests/unit/services/atlasService.test.ts index 83282b3d..c709105d 100644 --- a/server/tests/unit/services/atlasService.test.ts +++ b/server/tests/unit/services/atlasService.test.ts @@ -505,4 +505,33 @@ describe('getVisitedRegions', () => { const codes = result.regions['FR'].map((r: any) => r.code); expect(codes).toContain('FR-75'); }); + + it('ATLAS-UNIT-021: GB places resolving to a constituent country are re-resolved to the finer admin-1 code', async () => { + vi.useFakeTimers(); + // A zoom-8 lookup only yields the constituent country (GB-ENG); the zoom-10 lookup + // exposes the borough code (GB-MAN) that Natural Earth's polygons actually carry. + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => Promise.resolve({ + ok: true, + json: async () => ({ + address: url.includes('zoom=10') + ? { country_code: 'gb', 'ISO3166-2-lvl8': 'GB-MAN', city: 'Manchester', state: 'England', 'ISO3166-2-lvl4': 'GB-ENG' } + : { country_code: 'gb', 'ISO3166-2-lvl4': 'GB-ENG', state: 'England' }, + }), + }))); + + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Manchester Trip' }); + insertPlaceWithCoords(testDb, trip.id, 'Old Trafford', 53.4631, -2.2913); + + await getVisitedRegions(user.id); + await vi.runAllTimersAsync(); + const result = await getVisitedRegions(user.id); + + expect(result.regions['GB']).toBeDefined(); + const codes = result.regions['GB'].map((r: any) => r.code); + expect(codes).toContain('GB-MAN'); + expect(codes).not.toContain('GB-ENG'); + + vi.useRealTimers(); + }); }); diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 00fb6920..5bec43d7 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -346,6 +346,39 @@ describe('importGpx', () => { const result = importGpx(String(trip.id), gpx); expect(result).toBeNull(); }); + + it('PLACE-SVC-037 — multiple unnamed tracks in one file get distinct names instead of collapsing to one', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + + + + + + + + + `); + const result = importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(2); + const names = result.places.map((p: any) => p.name); + expect(new Set(names).size).toBe(2); + }); + + it('PLACE-SVC-038 — unnamed tracks fall back to the source filename', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + + + + + `); + const result = importGpx(String(trip.id), gpx, { defaultName: 'morning-hike.gpx' }) as any; + expect(result.places).toHaveLength(1); + expect(result.places[0].name).toBe('morning-hike'); + }); }); // ── importGoogleList ────────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts index 795dbcb2..a4411b4d 100644 --- a/server/tests/unit/services/tripService.test.ts +++ b/server/tests/unit/services/tripService.test.ts @@ -242,6 +242,33 @@ describe('generateDays', () => { const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b); expect(nums).toEqual([1, 2, 3, 4, 5]); }); + + it('TRIP-SVC-017: switching a dateless trip to a shorter dated range drops empty leftover days but keeps ones with content (#1083)', () => { + const { user } = createUser(testDb); + // A 7-day trip, then cleared to dateless placeholders (day_count = 7). + const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' }); + generateDays(trip.id, null, null); + const dateless = getDays(trip.id); + expect(dateless).toHaveLength(7); + expect(dateless.every(d => d.date === null)).toBe(true); + + // Give the LAST dateless day real content so it must be preserved. + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, dateless[6].id, place.id); + + // Now set an explicit 2-day range. The first two dateless days are reused for + // the dates; the four empty leftovers must be removed, the one with content kept. + generateDays(trip.id, '2026-01-10', '2026-01-11'); + + const daysAfter = getDays(trip.id); + const dated = daysAfter.filter(d => d.date !== null); + const stillDateless = daysAfter.filter(d => d.date === null); + expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']); + // day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7) + expect(daysAfter).toHaveLength(3); + expect(stillDateless).toHaveLength(1); + expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id); + }); }); describe('exportICS', () => {