mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b476f011 | |||
| 959d6c3714 | |||
| c37ee2c6c3 | |||
| 0175a06c9e | |||
| 39113e12de | |||
| d02ecf239e | |||
| 8691814330 | |||
| 48098ef5ec | |||
| c565f22bf2 | |||
| 5bf8dd8cef |
@@ -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 */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
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<HTMLInputElement>) => {
|
||||
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
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('places.importFileDropActive')}</span>
|
||||
) : file ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{file.name}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('places.importFileDropHere')}</span>
|
||||
)}
|
||||
|
||||
@@ -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(<PlaceFormModal {...defaultProps} />);
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
)}
|
||||
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
|
||||
<div onClick={e => { 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 }}>
|
||||
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }}
|
||||
ref={el => { 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(
|
||||
<div onClick={e => { 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)' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { 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' } } }}>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('Modal', () => {
|
||||
|
||||
it('FE-COMP-MODAL-008: clicking the backdrop calls onClose', () => {
|
||||
render(<Modal isOpen={true} onClose={onClose}><p>inner</p></Modal>);
|
||||
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);
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Modal({
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -534,13 +534,18 @@ const geocodingInFlight = new Set<number>();
|
||||
|
||||
const regionCache = new Map<string, RegionInfo | null>();
|
||||
|
||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||
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<Record<string, string> | 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<RegionInf
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: Record<string, string> };
|
||||
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<string, string>, 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<RegionInfo | null> {
|
||||
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<string, { code: string; name: string; placeCount: number }[]> }> {
|
||||
const trips = getUserTrips(userId);
|
||||
const tripIds = trips.map(t => t.id);
|
||||
|
||||
@@ -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 <name>. 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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="48.8566" lon="2.3522"></trkpt>
|
||||
<trkpt lat="48.8570" lon="2.3530"></trkpt>
|
||||
</trkseg></trk>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="40.0000" lon="-3.0000"></trkpt>
|
||||
<trkpt lat="40.1000" lon="-3.1000"></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`);
|
||||
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(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="48.8566" lon="2.3522"></trkpt>
|
||||
<trkpt lat="48.8570" lon="2.3530"></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`);
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user