mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31: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 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 monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
const prevMonth = () => {
|
const prevMonth = () => {
|
||||||
@@ -68,7 +69,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
|||||||
|
|
||||||
{/* Weekday headers */}
|
{/* Weekday headers */}
|
||||||
<div className="grid grid-cols-7 mb-1">
|
<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 key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import JourneyShareSection from './JourneyShareSection'
|
|||||||
import type { JourneyDetail } from '../../store/journeyStore'
|
import type { JourneyDetail } from '../../store/journeyStore'
|
||||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||||
|
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||||
|
|
||||||
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||||
journey: JourneyDetail
|
journey: JourneyDetail
|
||||||
@@ -49,7 +50,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
|||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('cover', file)
|
formData.append('cover', await normalizeImageFile(file))
|
||||||
try {
|
try {
|
||||||
await journeyApi.uploadCover(journey.id, formData)
|
await journeyApi.uploadCover(journey.id, formData)
|
||||||
toast.success(t('journey.settings.coverUpdated'))
|
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]) },
|
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||||
}))
|
}))
|
||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}, [route])
|
}, [route, mapReady])
|
||||||
|
|
||||||
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ export function MapViewGL({
|
|||||||
} catch { return [] }
|
} catch { return [] }
|
||||||
})
|
})
|
||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}, [places])
|
}, [places, mapReady])
|
||||||
|
|
||||||
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
|
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
|
||||||
// circle arcs for flights/cruises, straight lines for trains/cars,
|
// 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 loadTrip = useTripStore((s) => s.loadTrip)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [files, setFiles] = useState<File[]>([])
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -51,7 +51,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setFile(null)
|
setFiles([])
|
||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -67,14 +67,14 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
|||||||
if (initialFile) {
|
if (initialFile) {
|
||||||
const err = validateFile(initialFile)
|
const err = validateFile(initialFile)
|
||||||
if (err) {
|
if (err) {
|
||||||
setFile(null)
|
setFiles([])
|
||||||
setError(err)
|
setError(err)
|
||||||
} else {
|
} else {
|
||||||
setFile(initialFile)
|
setFiles([initialFile])
|
||||||
setError('')
|
setError('')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setFile(null)
|
setFiles([])
|
||||||
setError('')
|
setError('')
|
||||||
}
|
}
|
||||||
// validateFile uses t() which is stable — intentionally omitted from deps
|
// validateFile uses t() which is stable — intentionally omitted from deps
|
||||||
@@ -86,22 +86,32 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectFile = (f: File) => {
|
const selectFiles = (incoming: File[]) => {
|
||||||
const validationError = validateFile(f)
|
if (incoming.length === 0) return
|
||||||
if (validationError) {
|
const valid: File[] = []
|
||||||
setError(validationError)
|
let firstError: string | null = null
|
||||||
setFile(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
|
return
|
||||||
}
|
}
|
||||||
setFile(f)
|
setFiles(valid)
|
||||||
setError('')
|
setError(firstError ?? '')
|
||||||
setSummary(null)
|
setSummary(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
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 = ''
|
e.target.value = ''
|
||||||
if (f) selectFile(f)
|
if (list.length) selectFiles(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
@@ -116,71 +126,92 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
|||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
const f = e.dataTransfer.files[0]
|
const list = Array.from(e.dataTransfer.files)
|
||||||
if (f) selectFile(f)
|
if (list.length) selectFiles(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!file || loading) return
|
if (files.length === 0 || loading) return
|
||||||
const ext = file.name.toLowerCase().split('.').pop()
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
setSummary(null)
|
setSummary(null)
|
||||||
|
|
||||||
try {
|
let totalCreated = 0
|
||||||
if (ext === 'gpx') {
|
let totalSkipped = 0
|
||||||
const result = await placesApi.importGpx(tripId, file, gpxOpts)
|
const createdIds: number[] = []
|
||||||
await loadTrip(tripId)
|
const errors: string[] = []
|
||||||
if (result.count === 0 && result.skipped > 0) {
|
let mergedSummary: PlacesImportSummary | null = null
|
||||||
toast.warning(t('places.importAllSkipped'))
|
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 {
|
} else {
|
||||||
toast.success(t('places.gpxImported', { count: result.count }))
|
importedKml = true
|
||||||
}
|
const result = await placesApi.importMapFile(tripId, f, kmlOpts)
|
||||||
if (result.places?.length > 0) {
|
totalCreated += result.count ?? 0
|
||||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id))
|
||||||
pushUndo?.(t('undo.importGpx'), async () => {
|
const s = result.summary as PlacesImportSummary | undefined
|
||||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
if (s) {
|
||||||
await loadTrip(tripId)
|
mergedSummary = mergedSummary
|
||||||
})
|
? {
|
||||||
}
|
totalPlacemarks: mergedSummary.totalPlacemarks + s.totalPlacemarks,
|
||||||
handleClose()
|
createdCount: mergedSummary.createdCount + s.createdCount,
|
||||||
} else {
|
skippedCount: mergedSummary.skippedCount + s.skippedCount,
|
||||||
const result = await placesApi.importMapFile(tripId, file, kmlOpts)
|
warnings: [...mergedSummary.warnings, ...(s.warnings ?? [])],
|
||||||
await loadTrip(tripId)
|
errors: [...mergedSummary.errors, ...(s.errors ?? [])],
|
||||||
setSummary(result.summary || null)
|
}
|
||||||
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
|
: s
|
||||||
toast.warning(t('places.importAllSkipped'))
|
totalSkipped += s.skippedCount ?? 0
|
||||||
} 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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
} 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 exts = files.map(f => f.name.toLowerCase().split('.').pop() ?? '')
|
||||||
const isGpx = fileExt === 'gpx'
|
const isGpx = exts.includes('gpx')
|
||||||
const isKml = fileExt === 'kml' || fileExt === 'kmz'
|
const isKml = exts.some(e => e === 'kml' || e === 'kmz')
|
||||||
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
|
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
|
||||||
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
|
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
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -206,6 +237,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".gpx,.kml,.kmz"
|
accept=".gpx,.kml,.kmz"
|
||||||
|
multiple
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleInputChange}
|
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' }} />
|
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||||
{isDragOver ? (
|
{isDragOver ? (
|
||||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('places.importFileDropActive')}</span>
|
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('places.importFileDropActive')}</span>
|
||||||
) : file ? (
|
) : files.length > 0 ? (
|
||||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{file.name}</span>
|
<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>
|
<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();
|
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();
|
const addToast = vi.fn();
|
||||||
window.__addToast = addToast;
|
window.__addToast = addToast;
|
||||||
|
|
||||||
const user = userEvent.setup();
|
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(
|
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} />);
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
@@ -241,7 +244,7 @@ describe('PlaceFormModal', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(addToast).toHaveBeenCalledWith(
|
expect(addToast).toHaveBeenCalledWith(
|
||||||
expect.stringMatching(/search failed/i),
|
expect.stringMatching(/Places API \(New\) has not been used/i),
|
||||||
'error',
|
'error',
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers'
|
import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers'
|
||||||
|
import { getApiErrorMessage } from '../../utils/apiError'
|
||||||
import type { Place, Category, Assignment } from '../../types'
|
import type { Place, Category, Assignment } from '../../types'
|
||||||
|
|
||||||
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
// 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)
|
const result = await mapsApi.search(mapsSearch, language)
|
||||||
setMapsResults(result.places || [])
|
setMapsResults(result.places || [])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(t('places.mapsSearchError'))
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearchingMaps(false)
|
setIsSearchingMaps(false)
|
||||||
}
|
}
|
||||||
@@ -228,7 +229,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch place details:', err)
|
console.error('Failed to fetch place details:', err)
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(t('places.mapsSearchError'))
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearchingMaps(false)
|
setIsSearchingMaps(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
|||||||
)}
|
)}
|
||||||
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
|
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
|
||||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
<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 }}>
|
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)' }}
|
<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' } } }}>
|
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(
|
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
|
||||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
<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)' }}>
|
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' }}
|
<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' } } }}>
|
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 }],
|
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 });
|
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 { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||||
import type { Trip } from '../../types'
|
import type { Trip } from '../../types'
|
||||||
import type { TripCreateRequest } from '@trek/shared'
|
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
|
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) {
|
if (isEditing && trip?.id) {
|
||||||
// Existing trip: upload immediately
|
// Existing trip: upload immediately
|
||||||
uploadCoverNow(file)
|
uploadCoverNow(normalized)
|
||||||
} else {
|
} else {
|
||||||
// New trip: stage for upload after creation
|
// New trip: stage for upload after creation
|
||||||
setPendingCoverFile(file)
|
setPendingCoverFile(normalized)
|
||||||
setCoverPreview(URL.createObjectURL(file))
|
setCoverPreview(URL.createObjectURL(normalized))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('Modal', () => {
|
|||||||
|
|
||||||
it('FE-COMP-MODAL-008: clicking the backdrop calls onClose', () => {
|
it('FE-COMP-MODAL-008: clicking the backdrop calls onClose', () => {
|
||||||
render(<Modal isOpen={true} onClose={onClose}><p>inner</p></Modal>);
|
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
|
// Simulate mousedown then click on the backdrop itself
|
||||||
fireEvent.mouseDown(backdrop, { target: backdrop });
|
fireEvent.mouseDown(backdrop, { target: backdrop });
|
||||||
fireEvent.click(backdrop);
|
fireEvent.click(backdrop);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function Modal({
|
|||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div
|
<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' }}
|
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
|
|||||||
@@ -734,7 +734,7 @@ img[alt="TREK"] {
|
|||||||
.dark .min-h-screen { background-color: var(--bg-primary) !important; }
|
.dark .min-h-screen { background-color: var(--bg-primary) !important; }
|
||||||
|
|
||||||
/* Modal-Hintergrund */
|
/* 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: Fallback für Komponenten die noch nicht auf CSS-Variablen umgestellt sind ── */
|
||||||
.dark {
|
.dark {
|
||||||
@@ -766,8 +766,8 @@ img[alt="TREK"] {
|
|||||||
animation: slide-out-right 0.3s ease-in forwards;
|
animation: slide-out-right 0.3s ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal-Hintergrund */
|
/* Modal-Hintergrund (eigener Namespace, sonst blenden Content-Blocker .modal-backdrop aus) */
|
||||||
.modal-backdrop {
|
.trek-modal-backdrop {
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ export function useAtlas() {
|
|||||||
for (const f of geo.features) {
|
for (const f of geo.features) {
|
||||||
const a2 = f.properties?.ISO_A2
|
const a2 = f.properties?.ISO_A2
|
||||||
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
|
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
|
A2_TO_A3[a2] = a3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,7 +622,7 @@ export function useAtlas() {
|
|||||||
try {
|
try {
|
||||||
const result = await mapsApi.search(bucketSearch, language)
|
const result = await mapsApi.search(bucketSearch, language)
|
||||||
setBucketSearchResults(result.places || [])
|
setBucketSearchResults(result.places || [])
|
||||||
} catch {} finally { setBucketSearching(false) }
|
} catch (err) { console.error('Bucket-list place search failed:', err) } finally { setBucketSearching(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectBucketPoi = (result: any) => {
|
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) {
|
if (!importWaypoints && !importRoutes && !importTracks) {
|
||||||
throw new HttpException({ error: 'No import types selected' }, 400);
|
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) {
|
if (!result) {
|
||||||
throw new HttpException({ error: 'No matching places found in GPX file' }, 400);
|
throw new HttpException({ error: 'No matching places found in GPX file' }, 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ export class PlacesService {
|
|||||||
return svc.deletePlacesMany(tripId, ids);
|
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);
|
return svc.importGpx(tripId, buffer, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -534,13 +534,18 @@ const geocodingInFlight = new Set<number>();
|
|||||||
|
|
||||||
const regionCache = new Map<string, RegionInfo | null>();
|
const regionCache = new Map<string, RegionInfo | null>();
|
||||||
|
|
||||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
// A zoom-8 reverse geocode of a GB place only resolves to the constituent country
|
||||||
const key = roundKey(lat, lng);
|
// (England/Scotland/Wales/Northern Ireland). Natural Earth's admin-1 polygons for GB
|
||||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
// 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();
|
await throttleNominatim();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
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)' },
|
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
@@ -548,27 +553,52 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
|
|||||||
);
|
);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = await res.json() as { address?: Record<string, string> };
|
const data = await res.json() as { address?: Record<string, string> };
|
||||||
const countryCode = data.address?.country_code?.toUpperCase() || null;
|
return data.address ?? {};
|
||||||
// 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;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 }[]> }> {
|
export async function getVisitedRegions(userId: number): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
|
||||||
const trips = getUserTrips(userId);
|
const trips = getUserTrips(userId);
|
||||||
const tripIds = trips.map(t => t.id);
|
const tripIds = trips.map(t => t.id);
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ export interface GpxImportOptions {
|
|||||||
importWaypoints?: boolean;
|
importWaypoints?: boolean;
|
||||||
importRoutes?: boolean;
|
importRoutes?: boolean;
|
||||||
importTracks?: boolean;
|
importTracks?: boolean;
|
||||||
|
/** Source filename used to name unnamed routes/tracks (keeps multiple imports distinct). */
|
||||||
|
defaultName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KmlImportOptions {
|
export interface KmlImportOptions {
|
||||||
@@ -354,7 +356,7 @@ export interface KmlImportOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
|
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 parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||||
const gpx = parsed?.gpx;
|
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 str = (v: unknown) => (v != null ? String(v).trim() : null);
|
||||||
const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; };
|
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 };
|
type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string };
|
||||||
const waypoints: WaypointEntry[] = [];
|
const waypoints: WaypointEntry[] = [];
|
||||||
|
|
||||||
@@ -385,7 +401,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
|
|||||||
if (pts.length === 0) continue;
|
if (pts.length === 0) continue;
|
||||||
const hasAllEle = pts.every(p => p.ele !== null);
|
const hasAllEle = pts.every(p => p.ele !== null);
|
||||||
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
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 start = trackPoints[0];
|
||||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
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);
|
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
|
// Base must be max(targetDates.length, dated.length) to avoid colliding with
|
||||||
// positives already assigned by the main loop or the overflow loop above.
|
// 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);
|
const maxAssigned = Math.max(targetDates.length, dated.length);
|
||||||
|
let keptDateless = 0;
|
||||||
for (let i = datelessIdx; i < dateless.length; i++) {
|
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
|
// 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);
|
const codes = result.regions['FR'].map((r: any) => r.code);
|
||||||
expect(codes).toContain('FR-75');
|
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);
|
const result = importGpx(String(trip.id), gpx);
|
||||||
expect(result).toBeNull();
|
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 ──────────────────────────────────────────────────────────
|
// ── importGoogleList ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -242,6 +242,33 @@ describe('generateDays', () => {
|
|||||||
const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b);
|
const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b);
|
||||||
expect(nums).toEqual([1, 2, 3, 4, 5]);
|
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', () => {
|
describe('exportICS', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user