mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef191ae7dc | |||
| 7471976c9a | |||
| 5b8c61d215 |
+2
-7
@@ -48,8 +48,8 @@ RUN apt-get update && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
|
||||
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
|
||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.0.tgz && \
|
||||
echo "b7058d98990053c7b61847fef0c21e02d59b60e323e2b171ca210b682334e801 /tmp/ki.tgz" | sha256sum -c && \
|
||||
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
|
||||
rm /tmp/ki.tgz; \
|
||||
else \
|
||||
@@ -68,11 +68,6 @@ ENV QT_QPA_PLATFORM=offscreen
|
||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Third-party data & attributions
|
||||
|
||||
TREK bundles and uses third-party data that requires attribution.
|
||||
|
||||
## geoBoundaries — country & sub-national boundaries
|
||||
|
||||
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
||||
`server/assets/atlas/admin1.geojson.gz` and generated by
|
||||
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
||||
|
||||
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
||||
> administrative boundaries. PLoS ONE 15(4): e0231866.
|
||||
> https://doi.org/10.1371/journal.pone.0231866
|
||||
|
||||
geoBoundaries is licensed under **CC BY 4.0**
|
||||
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
|
||||
|
||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||
(gbOpen) release.
|
||||
|
||||
## OpenStreetMap — geocoding
|
||||
|
||||
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
|
||||
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
|
||||
https://www.openstreetmap.org/copyright
|
||||
|
||||
## OurAirports — airport reference data
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
(https://ourairports.com/data/), released into the public domain.
|
||||
@@ -437,13 +437,6 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
<br />
|
||||
|
||||
## Data sources
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
|
||||
## License
|
||||
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest,
|
||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||
@@ -341,7 +341,6 @@ export const daysApi = {
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
@@ -395,7 +394,6 @@ export const packingApi = {
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||
@@ -558,9 +556,6 @@ export const mapsApi = {
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
|
||||
@@ -22,22 +22,8 @@ type Defaults = {
|
||||
time_format?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
const MAPBOX_STYLE_PRESETS = [
|
||||
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
||||
]
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
hint,
|
||||
@@ -91,15 +77,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(data.mapbox_style || '')
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
@@ -119,8 +101,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'mapbox_style') setMapboxStyle('')
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
@@ -287,94 +267,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||
<OptionRow
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={(defaults.map_provider || 'leaflet') === opt.value}
|
||||
onClick={() => save({ map_provider: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{defaults.map_provider === 'mapbox-gl' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
||||
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
||||
placeholder="pk.eyJ…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field="mapbox_style" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => {
|
||||
it('FE-COMP-NOTES-013: delete note calls DELETE API and removes it from grid', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/notes', () =>
|
||||
@@ -193,11 +193,8 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Remove Me');
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
// Deleting now asks for confirmation first — the note stays until confirmed.
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remove Me')).toBeInTheDocument();
|
||||
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
|
||||
const deleteBtn = screen.getByTitle('Delete');
|
||||
await user.click(deleteBtn);
|
||||
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import type { User } from '../../types'
|
||||
import type { CollabNote } from './CollabNotes.types'
|
||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||
@@ -45,7 +44,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState(null)
|
||||
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(null)
|
||||
|
||||
// Empty categories (no notes yet) stored in localStorage
|
||||
const [emptyCategories, setEmptyCategories] = useState(() => {
|
||||
@@ -233,7 +231,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
|
||||
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
|
||||
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
|
||||
pendingDeleteNoteId, setPendingDeleteNoteId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +319,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
||||
|
||||
function CollabNotesGrid(S: NotesState) {
|
||||
const {
|
||||
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId,
|
||||
sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
|
||||
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
|
||||
} = S
|
||||
return (
|
||||
@@ -355,7 +352,7 @@ function CollabNotesGrid(S: NotesState) {
|
||||
currentUser={currentUser}
|
||||
canEdit={canEdit}
|
||||
onUpdate={handleUpdateNote}
|
||||
onDelete={setPendingDeleteNoteId}
|
||||
onDelete={handleDeleteNote}
|
||||
onEdit={setEditingNote}
|
||||
onView={setViewingNote}
|
||||
onPreviewFile={setPreviewFile}
|
||||
@@ -473,7 +470,6 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
viewingNote, showNewModal, editingNote, previewFile, showSettings,
|
||||
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
|
||||
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
|
||||
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
|
||||
} = S
|
||||
|
||||
if (loading) return <CollabNotesLoading {...S} />
|
||||
@@ -531,15 +527,6 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm: delete a collab note — guards against accidental deletion */}
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDeleteNoteId !== null}
|
||||
onClose={() => setPendingDeleteNoteId(null)}
|
||||
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
|
||||
title={t('collab.notes.confirmDeleteTitle')}
|
||||
message={t('collab.notes.confirmDeleteBody')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface NoteCardProps {
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => void
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
onView: (note: CollabNote) => void
|
||||
onPreviewFile: (file: NoteFile) => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
@@ -10,7 +10,6 @@ import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
import type { Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -119,44 +118,6 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
return fallbackIcon
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string) {
|
||||
const cached = poiIconCache.get(category)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
})
|
||||
poiIconCache.set(category, icon)
|
||||
return icon
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onViewportChange) return
|
||||
const emit = () => {
|
||||
const b = map.getBounds()
|
||||
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
}
|
||||
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
||||
map.on('moveend', emit)
|
||||
map.on('zoomend', emit)
|
||||
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
|
||||
}, [map, onViewportChange])
|
||||
return null
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
@@ -170,21 +131,10 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||
// Pan to the selected place without changing zoom. Offset the centre by the
|
||||
// side-panel + bottom-inspector padding so the pin lands in the middle of the
|
||||
// *visible* map area rather than the geometric centre (where the bottom panel
|
||||
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
|
||||
// Pan to the selected place without changing zoom
|
||||
const selected = places.find(p => p.id === selectedPlaceId)
|
||||
if (selected?.lat != null && selected?.lng != null) {
|
||||
const latlng: [number, number] = [selected.lat, selected.lng]
|
||||
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
|
||||
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
|
||||
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
|
||||
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
|
||||
map.panTo(map.unproject(point), { animate: true })
|
||||
} else {
|
||||
map.panTo(latlng, { animate: true })
|
||||
}
|
||||
if (selected?.lat && selected?.lng) {
|
||||
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
@@ -406,21 +356,7 @@ export const MapView = memo(function MapView({
|
||||
showReservationStats = false,
|
||||
visibleConnectionIds = [] as number[],
|
||||
onReservationClick,
|
||||
pois = [] as Poi[],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
}: any) {
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
const visibleReservations = useMemo(() => {
|
||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||
const set = new Set(visibleConnectionIds)
|
||||
@@ -596,7 +532,6 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
@@ -637,8 +572,6 @@ export const MapView = memo(function MapView({
|
||||
showStats={showReservationStats}
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
|
||||
{poiMarkers}
|
||||
</MapContainer>
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -50,9 +49,6 @@ interface Props {
|
||||
visibleConnectionIds?: number[]
|
||||
showReservationStats?: boolean
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
@@ -132,17 +128,6 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
||||
return wrap
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
||||
function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
||||
return el
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -164,9 +149,6 @@ export function MapViewGL({
|
||||
visibleConnectionIds = [],
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
pois = [],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
@@ -185,11 +167,6 @@ export function MapViewGL({
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
@@ -283,14 +260,6 @@ export function MapViewGL({
|
||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
// pill can fetch OSM places for the visible area.
|
||||
const emitViewport = () => {
|
||||
const b = map.getBounds()
|
||||
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
}
|
||||
map.on('moveend', emitViewport)
|
||||
map.once('idle', emitViewport)
|
||||
// In the mapbox-gl map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
@@ -466,22 +435,6 @@ export function MapViewGL({
|
||||
})
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
el.title = poi.name
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
}
|
||||
}, [pois, mapReady])
|
||||
|
||||
// Update route geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
@@ -600,10 +553,6 @@ export function MapViewGL({
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
// Account for the side panels and the bottom inspector / day-detail panel
|
||||
// so the selected pin lands in the centre of the *visible* map area rather
|
||||
// than the geometric centre (where the bottom panel would cover it).
|
||||
padding: paddingOpts,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { POI_CATEGORIES } from './poiCategories'
|
||||
|
||||
interface Props {
|
||||
active: Set<string>
|
||||
onToggle: (key: string) => void
|
||||
loadingKeys?: Set<string>
|
||||
/** true when the map moved since the last search → offer "search this area" */
|
||||
moved?: boolean
|
||||
onSearchArea?: () => void
|
||||
}
|
||||
|
||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||
// fill with the category colour (matching their markers); the label shows in a
|
||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const frosted: React.CSSProperties = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
|
||||
{POI_CATEGORIES.map(cat => {
|
||||
const on = active.has(cat.key)
|
||||
const loading = loadingKeys?.has(cat.key)
|
||||
return (
|
||||
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(cat.key)}
|
||||
aria-pressed={on}
|
||||
aria-label={t(cat.labelKey)}
|
||||
className={on ? '' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: on ? cat.color : 'transparent',
|
||||
color: on ? '#fff' : undefined,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!on) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!on) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{loading ? (
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{
|
||||
width: 14, height: 14, borderRadius: 999, display: 'inline-block',
|
||||
border: '2px solid', borderColor: on ? 'rgba(255,255,255,0.45)' : 'var(--border-primary)',
|
||||
borderTopColor: on ? '#fff' : 'var(--text-muted)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<cat.Icon size={16} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{moved && active.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchArea}
|
||||
className="text-content"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
||||
...frosted,
|
||||
}}
|
||||
>
|
||||
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -43,7 +42,7 @@ function useEndpointPane() {
|
||||
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
const { icon: IconCmp, color } = TYPE_META[type]
|
||||
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
||||
const labelHtml = label ? `<span>${escapeHtml(label)}</span>` : ''
|
||||
const labelHtml = label ? `<span>${label}</span>` : ''
|
||||
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
|
||||
return L.divIcon({
|
||||
className: 'trek-endpoint-marker',
|
||||
@@ -54,7 +53,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
border:1.5px solid #fff;color:#fff;
|
||||
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||
box-sizing:border-box;height:22px;white-space:nowrap;
|
||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''}</div>`,
|
||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
||||
iconSize: [estWidth, 22],
|
||||
iconAnchor: [estWidth / 2, 11],
|
||||
popupAnchor: [0, -11],
|
||||
@@ -158,7 +157,6 @@ interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
to: ReservationEndpoint
|
||||
waypoints: ReservationEndpoint[]
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
primaryArc: [number, number][]
|
||||
@@ -174,8 +172,8 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
|
||||
) + 22
|
||||
const hasBoth = !!mainLabel && !!subLabel
|
||||
const height = hasBoth ? 36 : 22
|
||||
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
|
||||
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
|
||||
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
|
||||
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
|
||||
const html = `<div class="trek-stats-inner" style="
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
width:100%;height:100%;
|
||||
@@ -354,29 +352,15 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
const out: TransportItem[] = []
|
||||
for (const r of reservations) {
|
||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
|
||||
// so the arc + markers below are byte-identical to before for it.
|
||||
const waypoints = (r.endpoints || [])
|
||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
if (waypoints.length < 2) continue
|
||||
const from = waypoints[0]
|
||||
const to = waypoints[waypoints.length - 1]
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) continue
|
||||
const type = r.type as TransportType
|
||||
const isGeo = TYPE_META[type].geodesic
|
||||
// One arc per leg (between consecutive waypoints), concatenated.
|
||||
const arcs: [number, number][][] = []
|
||||
let distanceKm = 0
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
}
|
||||
const arcs = isGeo
|
||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||
const primaryArc = arcs[primaryIdx] ?? []
|
||||
const fallback: [number, number] = primaryArc.length > 0
|
||||
@@ -384,15 +368,12 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
||||
|
||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||
const distance = `${Math.round(distanceKm)} km`
|
||||
// Show the full route (FRA → BER → HND) when every waypoint has a code.
|
||||
const mainLabel = waypoints.every(w => w.code)
|
||||
? waypoints.map(w => w.code).join(' → ')
|
||||
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
||||
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}, [reservations])
|
||||
@@ -434,21 +415,38 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
/>
|
||||
)))}
|
||||
|
||||
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
||||
{visibleItems.flatMap(item => [
|
||||
<Marker
|
||||
key={`wp-${item.res.id}-${wi}`}
|
||||
position={[wp.lat, wp.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
|
||||
key={`from-${item.res.id}`}
|
||||
position={[item.from.lat, item.from.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
|
||||
pane={ENDPOINT_PANE}
|
||||
zIndexOffset={1000}
|
||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{wp.name}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
|
||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)))}
|
||||
</Marker>,
|
||||
<Marker
|
||||
key={`to-${item.res.id}`}
|
||||
position={[item.to.lat, item.to.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
|
||||
pane={ENDPOINT_PANE}
|
||||
zIndexOffset={1000}
|
||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
|
||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>,
|
||||
])}
|
||||
|
||||
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
|
||||
<StatsLabel key={`stats-${item.res.id}`} item={item} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,62 +161,6 @@ describe('optimizeRoute', () => {
|
||||
expect(result[1]).toEqual(c)
|
||||
expect(result[2]).toEqual(b)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-016: start anchor begins the chain at the anchor-nearest stop', () => {
|
||||
const a = { lat: 10, lng: 1 }
|
||||
const b = { lat: 2, lng: 1 }
|
||||
const c = { lat: 5, lng: 1 }
|
||||
// From the accommodation anchor (1,1): nearest is b(2,1), then c(5,1), then a(10,1)
|
||||
const result = optimizeRoute([a, b, c], { start: { lat: 1, lng: 1 } })
|
||||
expect(result).toEqual([b, c, a])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-017: start + end anchors reorder a shuffled day and keep the end-nearest stop last', () => {
|
||||
const a = { lat: 2, lng: 1 }
|
||||
const b = { lat: 5, lng: 1 }
|
||||
const c = { lat: 8, lng: 1 }
|
||||
// Transfer day: start at hotel A (1,1), end at hotel B (9,1). c is nearest B, so it must be last.
|
||||
const result = optimizeRoute([c, a, b], { start: { lat: 1, lng: 1 }, end: { lat: 9, lng: 1 } })
|
||||
expect(result).toEqual([a, b, c])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-018: an anchor makes even a two-stop day sortable', () => {
|
||||
const a = { lat: 10, lng: 1 }
|
||||
const b = { lat: 2, lng: 1 }
|
||||
// Without anchors two stops are returned unchanged; the start anchor orders them by proximity.
|
||||
const result = optimizeRoute([a, b], { start: { lat: 1, lng: 1 } })
|
||||
expect(result).toEqual([b, a])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-019: 2-opt untangles a round-trip into a clean loop around the hotel', () => {
|
||||
const hotel = { lat: 48.8668, lng: 2.3013 } // Rue Marbeuf
|
||||
const stops = [
|
||||
{ id: 1, lat: 48.8565, lng: 2.3324 },
|
||||
{ id: 2, lat: 48.8813, lng: 2.3151 },
|
||||
{ id: 3, lat: 48.8796, lng: 2.308 },
|
||||
{ id: 4, lat: 48.8723, lng: 2.2926 },
|
||||
{ id: 5, lat: 48.866, lng: 2.3102 }, // nearest the hotel
|
||||
]
|
||||
const d = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) =>
|
||||
Math.hypot(a.lat - b.lat, a.lng - b.lng)
|
||||
const loop = (order: typeof stops) =>
|
||||
d(hotel, order[0]) + order.slice(1).reduce((s, p, i) => s + d(order[i], p), 0) + d(order[order.length - 1], hotel)
|
||||
|
||||
const result = optimizeRoute(stops, { start: hotel, end: hotel })
|
||||
// The optimized loop is no longer than the original order…
|
||||
expect(loop(result)).toBeLessThanOrEqual(loop(stops) + 1e-9)
|
||||
// …and the hotel-adjacent stop sits at one end of the loop, right next to the hotel.
|
||||
expect([result[0].id, result[result.length - 1].id]).toContain(5)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-020: an end anchor without a start finishes at the stop nearest it', () => {
|
||||
const a = { lat: 2, lng: 1 }
|
||||
const b = { lat: 5, lng: 1 }
|
||||
const c = { lat: 9, lng: 1 }
|
||||
// a is nearest the end anchor, so the route must finish at a rather than start there.
|
||||
const result = optimizeRoute([a, b, c], { end: { lat: 1, lng: 1 } })
|
||||
expect(result[result.length - 1]).toEqual(a)
|
||||
})
|
||||
})
|
||||
|
||||
// ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
@@ -77,98 +77,35 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||
return `https://www.google.com/maps/dir/${stops}`
|
||||
}
|
||||
|
||||
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine.
|
||||
function sqDist(a: Waypoint, b: Waypoint): number {
|
||||
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2
|
||||
}
|
||||
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
|
||||
export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length <= 2) return places
|
||||
|
||||
// Length of visiting `order` in sequence, optionally pinned to a fixed start and/or end anchor.
|
||||
// With start === end this is a closed loop back to the anchor (a day out from and back to the hotel).
|
||||
function tourLength(order: Waypoint[], start?: Waypoint, end?: Waypoint): number {
|
||||
if (order.length === 0) return 0
|
||||
let total = 0
|
||||
if (start) total += Math.sqrt(sqDist(start, order[0]))
|
||||
for (let i = 0; i < order.length - 1; i++) total += Math.sqrt(sqDist(order[i], order[i + 1]))
|
||||
if (end) total += Math.sqrt(sqDist(order[order.length - 1], end))
|
||||
return total
|
||||
}
|
||||
|
||||
// Greedy nearest-neighbor ordering, seeded at the start anchor when there is one.
|
||||
function nearestNeighborOrder<T extends Waypoint>(valid: T[], start?: Waypoint): T[] {
|
||||
const visited = new Set<number>()
|
||||
const result: T[] = []
|
||||
let current: Waypoint
|
||||
if (start) {
|
||||
current = start
|
||||
} else {
|
||||
current = valid[0]
|
||||
visited.add(0)
|
||||
result.push(valid[0])
|
||||
}
|
||||
let current = valid[0]
|
||||
visited.add(0)
|
||||
result.push(current)
|
||||
|
||||
while (result.length < valid.length) {
|
||||
let nearestIdx = -1
|
||||
let minDist = Infinity
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
if (visited.has(i)) continue
|
||||
const d = sqDist(valid[i], current)
|
||||
const d = Math.sqrt(
|
||||
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
|
||||
)
|
||||
if (d < minDist) { minDist = d; nearestIdx = i }
|
||||
}
|
||||
if (nearestIdx === -1) break
|
||||
visited.add(nearestIdx)
|
||||
current = valid[nearestIdx]
|
||||
result.push(valid[nearestIdx])
|
||||
result.push(current)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 2-opt: repeatedly reverse a sub-segment whenever it shortens the tour. This removes the crossings
|
||||
// a pure nearest-neighbor pass leaves behind. The start/end anchors stay fixed, so a round trip
|
||||
// (start === end) is untangled into a clean loop rather than an open path.
|
||||
function twoOptImprove<T extends Waypoint>(order: T[], start?: Waypoint, end?: Waypoint): T[] {
|
||||
if (order.length < 3) return order
|
||||
let best = order
|
||||
let bestLen = tourLength(best, start, end)
|
||||
let improved = true
|
||||
while (improved) {
|
||||
improved = false
|
||||
for (let i = 0; i < best.length - 1; i++) {
|
||||
for (let j = i + 1; j < best.length; j++) {
|
||||
const candidate = best.slice(0, i).concat(best.slice(i, j + 1).reverse(), best.slice(j + 1))
|
||||
const len = tourLength(candidate, start, end)
|
||||
if (len < bestLen - 1e-12) {
|
||||
best = candidate
|
||||
bestLen = len
|
||||
improved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders waypoints to minimize travel distance: a nearest-neighbor pass for a good starting order,
|
||||
* then 2-opt to untangle crossings. Optional anchors (e.g. the day's accommodation) pin the route's
|
||||
* ends — start === end makes it a loop out from and back to the hotel; a transfer day runs start → end.
|
||||
*/
|
||||
export function optimizeRoute<T extends Waypoint>(places: T[], anchors: RouteAnchors = {}): T[] {
|
||||
const { start, end } = anchors
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length <= 1) return places
|
||||
// Two unanchored stops have no meaningful order to optimize; anchors can still flip them.
|
||||
if (valid.length === 2 && !start && !end) return places
|
||||
|
||||
const order = twoOptImprove(nearestNeighborOrder(valid, start), start, end)
|
||||
|
||||
// A round trip's loop direction is arbitrary, so orient it to begin at the stop nearest the hotel —
|
||||
// that reads naturally as "leave the hotel, head to the closest place, …, come back".
|
||||
if (start && end && start.lat === end.lat && start.lng === end.lng && order.length > 1) {
|
||||
if (sqDist(order[order.length - 1], start) < sqDist(order[0], start)) order.reverse()
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
|
||||
export async function calculateSegments(
|
||||
waypoints: Waypoint[],
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
||||
|
||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||
// lives there; label/icon/colour live here. `color` doubles as the active-pill
|
||||
// fill AND the marker colour, so the pill and the map agree visually.
|
||||
export interface PoiCategory {
|
||||
key: string
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
}
|
||||
|
||||
export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
||||
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
||||
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
||||
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||
]
|
||||
|
||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||
POI_CATEGORIES.map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
export interface Poi {
|
||||
osm_id: string
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
category: string
|
||||
poi_type: string
|
||||
address: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
opening_hours: string | null
|
||||
cuisine: string | null
|
||||
source: 'openstreetmap'
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
export const RESERVATION_SOURCE_ID = 'trek-reservations'
|
||||
@@ -126,7 +125,6 @@ interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
to: ReservationEndpoint
|
||||
waypoints: ReservationEndpoint[]
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
primaryArc: [number, number][]
|
||||
@@ -138,38 +136,23 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
const out: TransportItem[] = []
|
||||
for (const r of reservations) {
|
||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two.
|
||||
const waypoints = (r.endpoints || [])
|
||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
if (waypoints.length < 2) continue
|
||||
const from = waypoints[0]
|
||||
const to = waypoints[waypoints.length - 1]
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) continue
|
||||
const type = r.type as TransportType
|
||||
const isGeo = TYPE_META[type].geodesic
|
||||
// One arc per leg (between consecutive waypoints), concatenated.
|
||||
const arcs: [number, number][][] = []
|
||||
let distanceKm = 0
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
}
|
||||
const arcs = isGeo
|
||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||
const primaryArc = arcs[primaryIdx] ?? []
|
||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||
const distance = `${Math.round(distanceKm)} km`
|
||||
const mainLabel = waypoints.every(w => w.code)
|
||||
? waypoints.map(w => w.code).join(' → ')
|
||||
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
||||
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel })
|
||||
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -178,7 +161,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
||||
const { icon: IconCmp } = TYPE_META[type]
|
||||
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
||||
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
|
||||
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
|
||||
return `<div style="
|
||||
display:inline-flex;align-items:center;justify-content:center;gap:4px;
|
||||
padding:0 8px;border-radius:999px;
|
||||
@@ -196,8 +179,8 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht
|
||||
) + 22
|
||||
const hasBoth = !!mainLabel && !!subLabel
|
||||
const height = hasBoth ? 36 : 22
|
||||
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
|
||||
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
|
||||
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
|
||||
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
|
||||
const html = `<div class="trek-stats-inner" style="
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
width:100%;height:100%;
|
||||
@@ -337,7 +320,7 @@ export class ReservationMapboxOverlay {
|
||||
if (show) {
|
||||
for (const item of visibleItems) {
|
||||
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
||||
for (const ep of item.waypoints) {
|
||||
for (const ep of [item.from, item.to]) {
|
||||
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = endpointMarkerHtml(item.type, label)
|
||||
@@ -358,10 +341,29 @@ export class ReservationMapboxOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
// Stats badge removed — the floating route/duration label on the arc is no
|
||||
// longer drawn; only the connection line and the airport markers remain.
|
||||
// ── stats label (flights only) ──────────────────────────────────
|
||||
this.statsMarkers.forEach(s => s.marker.remove())
|
||||
this.statsMarkers = []
|
||||
if (show && this.opts.showStats) {
|
||||
for (const item of visibleItems) {
|
||||
if (item.type !== 'flight') continue
|
||||
if (!labelVisibleIds.has(item.res.id)) continue
|
||||
if (!item.mainLabel && !item.subLabel) continue
|
||||
const arc = item.primaryArc
|
||||
if (arc.length < 2) continue
|
||||
const mid = arc[Math.floor(arc.length / 2)]!
|
||||
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
|
||||
el.innerHTML = html
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([mid[1], mid[0]])
|
||||
.addTo(map)
|
||||
this.statsMarkers.push({ marker, arc })
|
||||
}
|
||||
}
|
||||
// Prime rotation once so labels don't flash horizontal on first paint.
|
||||
this.updateStatsRotation()
|
||||
}
|
||||
|
||||
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
export interface Bbox { south: number; west: number; north: number; east: number }
|
||||
|
||||
/**
|
||||
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
||||
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
||||
* the results stale (`moved`) so the pill can offer "search this area". This keeps
|
||||
* Overpass load (and visual churn) down.
|
||||
*/
|
||||
export function usePoiExplore() {
|
||||
const [active, setActive] = useState<Set<string>>(() => new Set())
|
||||
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
||||
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
||||
const [moved, setMoved] = useState(false)
|
||||
|
||||
const bboxRef = useRef<Bbox | null>(null)
|
||||
// activeRef always mirrors the latest active set so async callbacks (fetch
|
||||
// completions) can check whether a category is still wanted.
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
|
||||
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(key); else next.delete(key)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
||||
setLoading(key, true)
|
||||
try {
|
||||
const res = await mapsApi.pois(key, bbox)
|
||||
// Drop the result if the user toggled this category off while the (slow)
|
||||
// Overpass request was in flight — otherwise stale results re-appear.
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
||||
} catch {
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
||||
} finally {
|
||||
setLoading(key, false)
|
||||
}
|
||||
}, [setLoading])
|
||||
|
||||
const onViewportChange = useCallback((bbox: Bbox) => {
|
||||
bboxRef.current = bbox
|
||||
if (activeRef.current.size > 0) setMoved(true)
|
||||
}, [])
|
||||
|
||||
// Single-select: clicking a category switches to it (dropping the previous one
|
||||
// and its markers immediately) and fetches it for the current viewport; clicking
|
||||
// the already-active category turns it off.
|
||||
const toggle = useCallback((key: string) => {
|
||||
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
||||
setMoved(false)
|
||||
if (isOnlyActive) {
|
||||
setActive(new Set())
|
||||
setByCat({})
|
||||
return
|
||||
}
|
||||
setActive(new Set([key]))
|
||||
setByCat({})
|
||||
if (bboxRef.current) fetchCat(key, bboxRef.current)
|
||||
}, [fetchCat])
|
||||
|
||||
const searchArea = useCallback(() => {
|
||||
const bbox = bboxRef.current
|
||||
if (!bbox) return
|
||||
setMoved(false)
|
||||
activeRef.current.forEach(key => fetchCat(key, bbox))
|
||||
}, [fetchCat])
|
||||
|
||||
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
||||
|
||||
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
|
||||
}
|
||||
@@ -146,20 +146,4 @@ describe('downloadJourneyBookPDF', () => {
|
||||
expect(html).toContain('Journey Book');
|
||||
expect(html).toContain('The End');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
|
||||
const journey = buildJourney();
|
||||
journey.entries[0].story = 'Hello <script>alert(1)</script> <img src=x onerror="alert(2)"> world';
|
||||
await downloadJourneyBookPDF(journey);
|
||||
const iframe = getIframe()!;
|
||||
const html = iframe.srcdoc;
|
||||
|
||||
// The script tag, image beacon and event handler are stripped from the story.
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html).not.toContain('alert(2)');
|
||||
// Benign prose survives.
|
||||
expect(html).toContain('Hello');
|
||||
expect(html).toContain('world');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeRichTextHtml } from '@trek/shared'
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
function esc(str: string | null | undefined): string {
|
||||
@@ -10,9 +9,7 @@ function esc(str: string | null | undefined): string {
|
||||
|
||||
function md(str: string | null | undefined): string {
|
||||
if (!str) return ''
|
||||
// marked passes embedded raw HTML through by default, so sanitise the result
|
||||
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
|
||||
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
|
||||
return marked.parse(str, { async: false, breaks: true }) as string
|
||||
}
|
||||
|
||||
function abs(url: string | null | undefined): string {
|
||||
@@ -311,9 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
// No script runs inside the document (print is triggered from the parent via
|
||||
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
|
||||
@@ -259,23 +259,6 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('colosseum.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an <img>, not the category icon (#1130)', async () => {
|
||||
const args = {
|
||||
...richArgs,
|
||||
assignments: {
|
||||
'10': [{
|
||||
...assignmentForDay,
|
||||
place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' },
|
||||
}],
|
||||
} as any,
|
||||
}
|
||||
await downloadTripPDF(args)
|
||||
const iframe = getIframe()
|
||||
// The proxy path (no file extension) must still embed as an absolute <img>.
|
||||
expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes')
|
||||
expect(iframe!.srcdoc).toContain('class="place-thumb"')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
|
||||
let photoCalled = false
|
||||
server.use(
|
||||
|
||||
@@ -55,10 +55,6 @@ function absUrl(url) {
|
||||
function safeImg(url) {
|
||||
if (!url) return null
|
||||
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
||||
// The in-app place-photo proxy always streams a JPEG but has no file extension
|
||||
// (it ends in …/bytes), so the extension check below would wrongly reject it —
|
||||
// which is why persisted place photos showed as category icons in the PDF.
|
||||
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
|
||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
||||
}
|
||||
|
||||
@@ -215,13 +211,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const icon = reservationIconSvg(r.type)
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||
let subtitle = ''
|
||||
if (r.type === 'flight') {
|
||||
// Full route over all waypoints (FRA → BER → HND), falling back to the
|
||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
@@ -264,10 +254,9 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const color = cat?.color || '#6366f1'
|
||||
|
||||
// Image: direct > google photo > fallback icon. Both go through safeImg
|
||||
// so the proxy path is resolved to an absolute URL the PDF can load.
|
||||
// Image: direct > google photo > fallback icon
|
||||
const directImg = safeImg(place.image_url)
|
||||
const googleImg = safeImg(photoMap[place.id])
|
||||
const googleImg = photoMap[place.id] || null
|
||||
const img = directImg || googleImg
|
||||
|
||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||
@@ -580,9 +569,7 @@ ${daysHtml}
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
// No script runs inside the document (print is parent-initiated), so withhold
|
||||
// allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Package } from 'lucide-react'
|
||||
import { packingApi } from '../../api/client'
|
||||
import { adminApi, packingApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {})
|
||||
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||
|
||||
describe('itemWeight (bag total weight calc)', () => {
|
||||
@@ -34,10 +34,10 @@ beforeEach(() => {
|
||||
http.get('/api/trips/:id/packing/category-assignees', () =>
|
||||
HttpResponse.json({ assignees: {} })
|
||||
),
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: false, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: false })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/templates', () =>
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [] })
|
||||
),
|
||||
);
|
||||
@@ -381,7 +381,7 @@ describe('PackingListPanel', () => {
|
||||
|
||||
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/templates', () =>
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] })
|
||||
)
|
||||
);
|
||||
@@ -457,8 +457,8 @@ describe('PackingListPanel', () => {
|
||||
|
||||
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||
@@ -556,8 +556,8 @@ describe('PackingListPanel', () => {
|
||||
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||
@@ -585,8 +585,8 @@ describe('PackingListPanel', () => {
|
||||
|
||||
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
|
||||
@@ -601,36 +601,26 @@ describe('PackingListPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-041: save-as-template button present for admins when items exist', async () => {
|
||||
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
||||
it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => {
|
||||
const user = userEvent.setup();
|
||||
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
||||
render(<PackingListPanel tripId={1} items={items} />);
|
||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||
|
||||
// Save-as-template button shows its label "Save as template"
|
||||
const saveBtn = screen.getByText('Save as template').closest('button');
|
||||
expect(saveBtn).toBeTruthy();
|
||||
// Save-as-template button uses FolderPlus icon and "Save as template" text
|
||||
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
|
||||
expect(folderPlusBtn).toBeTruthy();
|
||||
|
||||
// Click to show the name input
|
||||
await user.click(saveBtn!);
|
||||
await user.click(folderPlusBtn!);
|
||||
|
||||
// Template name input appears
|
||||
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-041b: save-as-template button hidden for non-admins', () => {
|
||||
// Default seeded user (beforeEach) is a non-admin trip owner with edit rights.
|
||||
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
||||
render(<PackingListPanel tripId={1} items={items} />);
|
||||
|
||||
// The "Save as template" action must not be available to normal users.
|
||||
expect(screen.queryByText('Save as template')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/templates', () =>
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] })
|
||||
)
|
||||
);
|
||||
@@ -668,8 +658,8 @@ describe('PackingListPanel', () => {
|
||||
|
||||
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [] })
|
||||
@@ -716,7 +706,6 @@ describe('PackingListPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
|
||||
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
||||
const user = userEvent.setup();
|
||||
let savedTemplateName = '';
|
||||
server.use(
|
||||
@@ -725,16 +714,16 @@ describe('PackingListPanel', () => {
|
||||
savedTemplateName = String(body.name);
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.get('/api/trips/:id/packing/templates', () =>
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [] })
|
||||
)
|
||||
);
|
||||
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
|
||||
render(<PackingListPanel tripId={1} items={items} />);
|
||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||
|
||||
// Click the "Save as template" button
|
||||
const saveBtn = screen.getByText('Save as template').closest('button');
|
||||
await user.click(saveBtn!);
|
||||
// Click the FolderPlus "Save as template" button
|
||||
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
|
||||
await user.click(folderPlusBtn!);
|
||||
|
||||
// Type template name
|
||||
const nameInput = await screen.findByPlaceholderText('Template name');
|
||||
@@ -747,8 +736,8 @@ describe('PackingListPanel', () => {
|
||||
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||
@@ -776,8 +765,8 @@ describe('PackingListPanel', () => {
|
||||
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||
@@ -816,8 +805,8 @@ describe('PackingListPanel', () => {
|
||||
let putBody: Record<string, unknown> | null = null;
|
||||
const itemId = 120;
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [] })
|
||||
@@ -872,8 +861,8 @@ describe('PackingListPanel', () => {
|
||||
const itemId = 130;
|
||||
let putBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
||||
@@ -941,8 +930,8 @@ describe('PackingListPanel', () => {
|
||||
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
|
||||
const itemId = 140;
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
http.get('/api/admin/bag-tracking', () =>
|
||||
HttpResponse.json({ enabled: true })
|
||||
),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||
@@ -968,7 +957,7 @@ describe('PackingListPanel', () => {
|
||||
it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/templates', () =>
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
|
||||
)
|
||||
);
|
||||
@@ -1135,7 +1124,7 @@ describe('PackingListPanel', () => {
|
||||
const user = userEvent.setup();
|
||||
let applyCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/templates', () =>
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
|
||||
),
|
||||
http.post('/api/trips/1/packing/apply-template/5', () => {
|
||||
@@ -1188,7 +1177,7 @@ describe('PackingListPanel', () => {
|
||||
const user = userEvent.setup();
|
||||
let createBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||
@@ -1218,7 +1207,7 @@ describe('PackingListPanel', () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||
),
|
||||
@@ -1246,7 +1235,7 @@ describe('PackingListPanel', () => {
|
||||
const user = userEvent.setup();
|
||||
let updateBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
||||
),
|
||||
@@ -1284,7 +1273,7 @@ describe('PackingListPanel', () => {
|
||||
current_user_id: 1,
|
||||
})
|
||||
),
|
||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||
)
|
||||
@@ -1325,7 +1314,7 @@ describe('PackingListPanel', () => {
|
||||
current_user_id: 1,
|
||||
})
|
||||
),
|
||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
|
||||
),
|
||||
@@ -1363,7 +1352,7 @@ describe('PackingListPanel', () => {
|
||||
it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => {
|
||||
let createBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
|
||||
http.post('/api/trips/1/packing/bags', async ({ request }) => {
|
||||
createBody = await request.json() as Record<string, unknown>;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
export function PackingHeader(S: PackingState) {
|
||||
const {
|
||||
inlineHeader, t, items, abgehakt, fortschritt, canEdit, isAdmin,
|
||||
inlineHeader, t, items, abgehakt, fortschritt, canEdit,
|
||||
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
|
||||
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
|
||||
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
|
||||
@@ -26,7 +26,7 @@ export function PackingHeader(S: PackingState) {
|
||||
</div>
|
||||
) : <span />}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{canEdit && isAdmin && items.length > 0 && showSaveTemplate && (
|
||||
{canEdit && items.length > 0 && showSaveTemplate && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
type="text" autoFocus
|
||||
@@ -97,7 +97,7 @@ export function PackingHeader(S: PackingState) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && (
|
||||
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
|
||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
|
||||
@@ -2,11 +2,9 @@ import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { BAG_COLORS } from './packingListPanel.constants'
|
||||
import { parseImportLines } from './packingListPanel.helpers'
|
||||
@@ -48,7 +46,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('packing_edit', trip)
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -148,24 +145,19 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
if (failed) toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
|
||||
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
|
||||
// authenticated user via the addon store (loaded on app start), not the
|
||||
// admin-only endpoint, so non-admin members see weights/bags too.
|
||||
const bagTrackingEnabled = useAddonStore(s => s.bagTracking)
|
||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||
const loadAddons = useAddonStore(s => s.loadAddons)
|
||||
// Bag tracking
|
||||
const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
|
||||
const [bags, setBags] = useState<PackingBag[]>([])
|
||||
const [newBagName, setNewBagName] = useState('')
|
||||
const [showAddBag, setShowAddBag] = useState(false)
|
||||
const [showBagModal, setShowBagModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!addonsLoaded) loadAddons()
|
||||
}, [addonsLoaded, loadAddons])
|
||||
|
||||
useEffect(() => {
|
||||
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
||||
}, [tripId, bagTrackingEnabled])
|
||||
adminApi.getBagTracking().then(d => {
|
||||
setBagTrackingEnabled(d.enabled)
|
||||
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
||||
}).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
const handleCreateBag = async () => {
|
||||
if (!newBagName.trim()) return
|
||||
@@ -242,7 +234,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -275,7 +267,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
toast.success(t('packing.templateSaved'))
|
||||
setShowSaveTemplate(false)
|
||||
setSaveTemplateName('')
|
||||
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
@@ -305,7 +297,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const font = { fontFamily: "var(--font-system)" }
|
||||
|
||||
return {
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||
tripId, items, inlineHeader, t, canEdit, font,
|
||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
||||
|
||||
@@ -982,7 +982,7 @@ describe('DayPlanSidebar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-065: note card delete button calls deleteNote', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
@@ -992,11 +992,6 @@ describe('DayPlanSidebar', () => {
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 1) {
|
||||
await user.click(noteEditBtns[1] as HTMLElement)
|
||||
// Clicking delete opens a confirmation dialog rather than deleting immediately.
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
// Confirming triggers the actual delete.
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLi
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -18,7 +17,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
@@ -51,8 +50,6 @@ interface DayPlanSidebarProps {
|
||||
onDayDetail: (day: Day) => void
|
||||
accommodations?: Accommodation[]
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
onRouteCalculated: (route: RouteResult | null) => void
|
||||
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
|
||||
@@ -98,7 +95,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated,
|
||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
@@ -173,7 +170,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [timeConfirm, setTimeConfirm] = useState<{
|
||||
dayId: number; fromId: number; time: string;
|
||||
// For drag & drop reorder
|
||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
|
||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
|
||||
// For arrow reorder
|
||||
reorderIds?: number[];
|
||||
} | null>(null)
|
||||
@@ -378,30 +375,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||
const merged = mergedItemsMap[selectedDayId] || []
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
const r = it.data
|
||||
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
|
||||
if (from || to) {
|
||||
// Located transport: route to its departure point, break the run (the
|
||||
// flight/train itself isn't driven), and let its arrival start the next.
|
||||
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
cur = []
|
||||
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
|
||||
} else if (cur.length > 0) {
|
||||
// No location: ignore for routing, but attribute the through-leg to the
|
||||
// booking so its distance/duration shows under it (purely cosmetic).
|
||||
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
cur = []
|
||||
}
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
@@ -470,10 +451,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
|
||||
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
|
||||
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
|
||||
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
|
||||
|
||||
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
await _deleteNote(dayId, noteId)
|
||||
@@ -489,9 +466,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const assignmentIds: number[] = []
|
||||
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
||||
// Multi-leg flight legs share a reservation id, so their positions can't live in
|
||||
// the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos.
|
||||
const legPosUpdates: Record<number, Record<number, number>> = {}
|
||||
|
||||
let placeCount = 0
|
||||
let i = 0
|
||||
@@ -512,10 +486,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
group.forEach((g, idx) => {
|
||||
const pos = base + (idx + 1) / (group.length + 1)
|
||||
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
||||
else if (g.type === 'transport') {
|
||||
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
|
||||
else transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||
}
|
||||
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -534,30 +505,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}))
|
||||
setTransportPosVersion(v => v + 1)
|
||||
}
|
||||
// Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions
|
||||
// (the single per-booking slot can't hold one position per leg).
|
||||
const legResIds = Object.keys(legPosUpdates)
|
||||
if (legResIds.length) {
|
||||
for (const ridStr of legResIds) {
|
||||
const rid = Number(ridStr)
|
||||
const r = useTripStore.getState().reservations.find(x => x.id === rid)
|
||||
if (!r) continue
|
||||
let parsed: any = {}
|
||||
try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} }
|
||||
if (!Array.isArray(parsed.legs)) continue
|
||||
const legs = parsed.legs.map((leg: any, i: number) => {
|
||||
const pos = legPosUpdates[rid][i]
|
||||
return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } }
|
||||
})
|
||||
// Send metadata as an OBJECT (like the form does) — passing a JSON string
|
||||
// here double-encodes it on the server, which wipes metadata.legs on read
|
||||
// and collapses the flight back to a single span.
|
||||
const newMeta = { ...parsed, legs }
|
||||
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
|
||||
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
|
||||
}
|
||||
setTransportPosVersion(v => v + 1)
|
||||
}
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
if (transportUpdates.length) {
|
||||
onRouteRefresh?.()
|
||||
@@ -576,11 +523,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => {
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
const m = getMergedItems(dayId)
|
||||
// Multi-leg flights expose one item per leg sharing the same reservation id;
|
||||
// disambiguate the drop target by leg index so you can drop BETWEEN legs.
|
||||
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||
|
||||
// Check if a timed place is being moved → would it break chronological order?
|
||||
if (fromType === 'place') {
|
||||
@@ -588,11 +532,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||
if (fromItem && fromMinutes !== null) {
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(matchTo)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const simulated = [...m]
|
||||
const [moved] = simulated.splice(fromIdx, 1)
|
||||
let insertIdx = simulated.findIndex(matchTo)
|
||||
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (insertIdx === -1) insertIdx = simulated.length
|
||||
if (insertAfter) insertIdx += 1
|
||||
simulated.splice(insertIdx, 0, moved)
|
||||
@@ -609,7 +553,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (!isChronological) {
|
||||
const placeTime = fromItem.data.place.place_time
|
||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr })
|
||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -619,7 +563,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
// Build new order: remove the dragged item, insert at target position
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(matchTo)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
@@ -627,7 +571,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
let adjustedTo = newOrder.findIndex(matchTo)
|
||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||
if (insertAfter) adjustedTo += 1
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
@@ -641,7 +585,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const confirmTimeRemoval = async () => {
|
||||
if (!timeConfirm) return
|
||||
const saved = { ...timeConfirm }
|
||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
||||
setTimeConfirm(null)
|
||||
|
||||
// Remove time from assignment
|
||||
@@ -684,14 +628,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
// Drag & drop reorder
|
||||
if (fromType && toType) {
|
||||
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(matchTo)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
let adjustedTo = newOrder.findIndex(matchTo)
|
||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||
if (insertAfter) adjustedTo += 1
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
@@ -749,27 +692,19 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
|
||||
// Separate fixed (stay at their index) and movable assignments. A place is
|
||||
// fixed if it's locked OR has a set time — timed places are anchored by their
|
||||
// time, so the optimizer must not reshuffle them.
|
||||
// Separate locked (stay at their index) and unlocked assignments
|
||||
const locked = new Map() // index -> assignment
|
||||
const unlocked = []
|
||||
da.forEach((a, i) => {
|
||||
if (lockedIds.has(a.id) || a.place?.place_time) locked.set(i, a)
|
||||
if (lockedIds.has(a.id)) locked.set(i, a)
|
||||
else unlocked.push(a)
|
||||
})
|
||||
|
||||
// Optimize only unlocked assignments (work on assignments, not places)
|
||||
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
|
||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||
? getAccommodationAnchors(day, days, accommodations)
|
||||
: {}
|
||||
const optimizedAssignments = unlockedWithCoords.length >= 2
|
||||
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
||||
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
||||
: unlockedWithCoords
|
||||
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
|
||||
|
||||
@@ -782,8 +717,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
|
||||
await onReorder(selectedDayId, result.map(a => a.id))
|
||||
const usedHotel = !!(anchors.start || anchors.end)
|
||||
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
||||
toast.success(t('dayplan.toast.routeOptimized'))
|
||||
const capturedDayId = selectedDayId
|
||||
pushUndo?.(t('undo.optimize'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||
@@ -868,8 +802,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onDayDetail,
|
||||
accommodations,
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onUpdateDayTitle,
|
||||
onRouteCalculated,
|
||||
onAssignToDay,
|
||||
@@ -919,8 +851,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
cancelNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
pendingDeleteNote,
|
||||
setPendingDeleteNote,
|
||||
moveNote,
|
||||
expandedDays,
|
||||
setExpandedDays,
|
||||
@@ -1014,8 +944,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onDayDetail,
|
||||
accommodations,
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onUpdateDayTitle,
|
||||
onRouteCalculated,
|
||||
onAssignToDay,
|
||||
@@ -1065,8 +993,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
cancelNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
pendingDeleteNote,
|
||||
setPendingDeleteNote,
|
||||
moveNote,
|
||||
expandedDays,
|
||||
setExpandedDays,
|
||||
@@ -1167,9 +1093,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
undoHover={undoHover}
|
||||
setUndoHover={setUndoHover}
|
||||
lastActionLabel={lastActionLabel}
|
||||
canEditDays={canEditDays}
|
||||
onReorderDays={onReorderDays}
|
||||
onAddDay={onAddDay}
|
||||
/>
|
||||
|
||||
{/* Tagesliste */}
|
||||
@@ -1372,8 +1295,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
||||
const transportId = Number(parts[0])
|
||||
const legPart = parts.find(p => /^leg\d+$/.test(p))
|
||||
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
@@ -1381,15 +1302,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (assignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex)
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
return
|
||||
@@ -1435,10 +1356,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : ''
|
||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}`
|
||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
|
||||
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data
|
||||
@@ -1786,13 +1706,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight — show this segment's own route.
|
||||
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(' · ')
|
||||
} else if (res.type === 'flight') {
|
||||
if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
@@ -1801,32 +1715,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// Multi-day span phase (single-leg / non-flight only — a
|
||||
// multi-leg flight is shown as one row per leg, see below).
|
||||
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
|
||||
// Multi-day span phase
|
||||
const spanLabel = getSpanLabel(res, spanPhase)
|
||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!canEditDays) return
|
||||
const target = reservations.find(x => x.id === res.id) ?? res
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||
else onEditReservation?.(target)
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
||||
else onEditReservation?.(res)
|
||||
}}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const inBottom = e.clientY > rect.top + rect.height / 2
|
||||
const ls = res.__leg ? `-leg${res.__leg.index}` : ''
|
||||
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
||||
draggable={canEditDays && spanPhase !== 'middle'}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
|
||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||
// setData is required for the drag to start reliably (Firefox) and
|
||||
// matches how place/note items initiate their drag.
|
||||
e.dataTransfer.setData('reservationId', String(res.id))
|
||||
@@ -1847,15 +1757,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
@@ -1875,7 +1785,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
|
||||
{canEditDays && spanPhase !== 'middle' && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
@@ -1920,7 +1830,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
||||
const active = visibleConnectionIds.includes(res.id)
|
||||
return (
|
||||
<button
|
||||
@@ -1944,7 +1854,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{routeLegs[res.id] && <RouteConnector seg={routeLegs[res.id]} profile={routeProfile} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1999,7 +1908,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
|
||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
]) : undefined}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
@@ -2041,7 +1950,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
@@ -2184,15 +2093,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Confirm: delete a day note — guards against accidental taps on touch devices */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteNote}
|
||||
onClose={() => setPendingDeleteNote(null)}
|
||||
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
|
||||
title={t('dayplan.confirmDeleteNoteTitle')}
|
||||
message={t('dayplan.confirmDeleteNoteBody')}
|
||||
/>
|
||||
|
||||
{/* Transport-Detail-Modal */}
|
||||
<DayPlanSidebarTransportDetailModal
|
||||
transportDetail={transportDetail}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2 } from 'lucide-react'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
@@ -29,18 +27,13 @@ interface DayPlanSidebarToolbarProps {
|
||||
undoHover: boolean
|
||||
setUndoHover: (v: boolean) => void
|
||||
lastActionLabel: string | null
|
||||
canEditDays?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
}
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
@@ -204,39 +197,6 @@ export function DayPlanSidebarToolbar({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Tooltip label={t('dayplan.reorderDays')} placement="bottom">
|
||||
<button
|
||||
onClick={() => setReorderOpen(v => !v)}
|
||||
aria-label={t('dayplan.reorderDays')}
|
||||
aria-pressed={reorderOpen}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: reorderOpen ? 'var(--bg-hover)' : 'none',
|
||||
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!reorderOpen) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!reorderOpen) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<ArrowUpDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{reorderOpen && (
|
||||
<DayReorderPopup
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
days: Day[]
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
onAddDay: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact panel for moving whole days around: drag a row by its grip or use the
|
||||
* up/down arrows, and add a day at the end. Day headers stay untouched — this is
|
||||
* the single surface for ordering. Reorders are applied optimistically by the
|
||||
* store, so the list reflects each move immediately.
|
||||
*/
|
||||
export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
|
||||
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
|
||||
const label = (day: Day, index: number) => {
|
||||
if (day.title) return day.title
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
return t('dayplan.dayN', { n: index + 1 })
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
const ids = ordered.map(d => d.id)
|
||||
const [moved] = ids.splice(from, 1)
|
||||
ids.splice(to, 0, moved)
|
||||
onReorder(ids)
|
||||
}
|
||||
|
||||
const cellBtn = {
|
||||
display: 'grid', placeItems: 'center', width: 26, height: 26,
|
||||
border: '1px solid var(--border-faint)', borderRadius: 7,
|
||||
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
||||
} as const
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* outside-click catcher */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
|
||||
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-faint)', borderRadius: 12,
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
|
||||
<button
|
||||
onClick={onAddDay}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
|
||||
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</div>
|
||||
|
||||
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
|
||||
borderRadius: 8, marginTop: 2,
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -270,18 +270,6 @@ describe('PlaceFormModal', () => {
|
||||
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
|
||||
// Regression: form.category_id is a string but the option values were numbers,
|
||||
// so CustomSelect's strict-equality match failed and the trigger fell back to
|
||||
// "No category". With string option values the chosen category renders.
|
||||
const cat = buildCategory({ name: 'Museums' });
|
||||
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />);
|
||||
// Dropdown is closed, so the only place the category name can appear is the trigger.
|
||||
expect(screen.getByText('Museums')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
|
||||
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
||||
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
|
||||
|
||||
@@ -27,7 +27,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
@@ -86,9 +86,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lng: String(prefillCoords.lng),
|
||||
name: prefillCoords.name || '',
|
||||
address: prefillCoords.address || '',
|
||||
website: prefillCoords.website || '',
|
||||
phone: prefillCoords.phone || '',
|
||||
osm_id: prefillCoords.osm_id,
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
@@ -639,10 +636,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
options={[
|
||||
{ value: '', label: t('places.noCategory') },
|
||||
...(categories || []).map(c => ({
|
||||
// form.category_id is a string; CustomSelect matches options by
|
||||
// strict equality, so the option value must be a string too —
|
||||
// otherwise the chosen category never renders in the trigger.
|
||||
value: String(c.id),
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
})),
|
||||
]}
|
||||
|
||||
@@ -271,21 +271,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
// Full route over all waypoints (from · stops · to), ordered by sequence.
|
||||
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
if (eps.length < 2) return null
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) return null
|
||||
return (
|
||||
<div className="bg-surface-tertiary text-content" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
fontSize: 12.5, flexWrap: 'wrap',
|
||||
fontSize: 12.5,
|
||||
}}>
|
||||
{eps.map((ep, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
{i > 0 && <TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />}
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ep.name}</span>
|
||||
</span>
|
||||
))}
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
|
||||
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -14,7 +14,6 @@ import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
@@ -24,7 +23,7 @@ interface EndpointPick {
|
||||
location?: LocationPoint
|
||||
}
|
||||
|
||||
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
||||
@@ -64,24 +63,6 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
|
||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||
}
|
||||
|
||||
// ── Multi-leg flight waypoints ─────────────────────────────────────────────
|
||||
// A flight is an ordered list of airports. The origin has only a departure, the
|
||||
// destination only an arrival, and each intermediate stop has both — plus the
|
||||
// airline/flight number of the flight LEAVING it. N waypoints = N-1 legs. A
|
||||
// single-leg flight is just two waypoints, so it persists exactly as before.
|
||||
interface WaypointForm {
|
||||
airport: Airport | null
|
||||
arrDayId: string | number
|
||||
arrTime: string
|
||||
depDayId: string | number
|
||||
depTime: string
|
||||
airline: string
|
||||
flight_number: string
|
||||
}
|
||||
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
@@ -141,8 +122,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -180,38 +159,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
const orderedEps = orderedEndpoints(reservation)
|
||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||
let wps: WaypointForm[]
|
||||
if (orderedEps.length >= 2) {
|
||||
wps = orderedEps.map((ep, i) => {
|
||||
const legInto = metaLegs[i - 1] // leg arriving INTO waypoint i
|
||||
const legOut = metaLegs[i] // leg departing FROM waypoint i
|
||||
const isFirst = i === 0
|
||||
const isLast = i === orderedEps.length - 1
|
||||
return {
|
||||
airport: airportFromEndpoint(ep),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
|
||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Legacy flight with no (or partial) endpoints — seed two waypoints.
|
||||
const dep = emptyWaypoint(reservation.day_id ?? '')
|
||||
dep.airport = airportFromEndpoint(from)
|
||||
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
||||
dep.airline = meta.airline ?? ''
|
||||
dep.flight_number = meta.flight_number ?? ''
|
||||
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
||||
arr.airport = airportFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setWaypoints(wps)
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
@@ -220,7 +169,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||
|
||||
@@ -239,45 +187,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
return day?.date ? `${day.date}T${time}` : time
|
||||
}
|
||||
|
||||
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
||||
const firstWp = flightWps[0]
|
||||
const lastWp = flightWps[flightWps.length - 1]
|
||||
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
||||
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
||||
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
||||
|
||||
const metadata: Record<string, any> = {}
|
||||
const metadata: Record<string, string> = {}
|
||||
if (form.type === 'flight') {
|
||||
// Top-level keys mirror the first/last leg so legacy readers keep working.
|
||||
if (firstWp?.airline) metadata.airline = firstWp.airline
|
||||
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
|
||||
if (firstWp?.airport) {
|
||||
metadata.departure_airport = firstWp.airport.iata
|
||||
metadata.departure_timezone = firstWp.airport.tz
|
||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||
if (fromPick.airport) {
|
||||
metadata.departure_airport = fromPick.airport.iata
|
||||
metadata.departure_timezone = fromPick.airport.tz
|
||||
}
|
||||
if (lastWp?.airport) {
|
||||
metadata.arrival_airport = lastWp.airport.iata
|
||||
metadata.arrival_timezone = lastWp.airport.tz
|
||||
}
|
||||
// Per-leg detail only for true multi-leg flights — a single-leg flight
|
||||
// keeps the exact same (flat) metadata it had before this feature.
|
||||
if (flightWps.length > 2) {
|
||||
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
|
||||
const next = flightWps[i + 1]
|
||||
return {
|
||||
from: w.airport!.iata,
|
||||
to: next.airport!.iata,
|
||||
...(w.airline ? { airline: w.airline } : {}),
|
||||
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
arr_time: next.arrTime || null,
|
||||
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
||||
}
|
||||
})
|
||||
if (toPick.airport) {
|
||||
metadata.arrival_airport = toPick.airport.iata
|
||||
metadata.arrival_timezone = toPick.airport.tz
|
||||
}
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
@@ -293,35 +213,21 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
if (form.type === 'flight') {
|
||||
flightWps.forEach((w, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === flightWps.length - 1
|
||||
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
||||
const dId = isLast ? w.arrDayId : w.depDayId
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
|
||||
})
|
||||
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
// Flights derive their span from the first/last waypoint; other transports
|
||||
// keep using the single departure/arrival form fields unchanged.
|
||||
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
||||
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
|
||||
const payload = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
reservation_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
||||
: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
day_id: form.start_day_id ? Number(form.start_day_id) : null,
|
||||
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
|
||||
reservation_time: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
@@ -442,126 +348,100 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
|
||||
{form.type === 'flight' ? (
|
||||
/* ── Flight route: ordered airports (origin · stops · destination) ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
||||
{waypoints.map((wp, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === waypoints.length - 1
|
||||
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||||
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
|
||||
</div>
|
||||
{!isFirst && !isLast && (
|
||||
<button type="button" onClick={() => setWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isFirst && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
||||
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
||||
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
||||
</div>
|
||||
{wp.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLast && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
||||
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
||||
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
||||
</div>
|
||||
{wp.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
||||
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<button type="button" onClick={() => setWaypoints(prev => [...prev.slice(0, i + 1), emptyWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
||||
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* From / To endpoints */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* From / To endpoints (non-flight) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||
<CustomSelect value={form.start_day_id} onChange={value => set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>
|
||||
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.start_day_id}
|
||||
onChange={value => set('start_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>
|
||||
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && fromPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{fromPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrival row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||
<CustomSelect value={form.end_day_id} onChange={value => set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||
{/* Arrival row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.end_day_id}
|
||||
onChange={value => set('end_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && toPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{toPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flight-specific fields */}
|
||||
{form.type === 'flight' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||
placeholder="Lufthansa" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||
placeholder="LH 123" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
|
||||
@@ -262,37 +262,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Explore places on the map (POI category pill) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.mapPoiPill')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('map_poi_pill_enabled', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.map_poi_pill_enabled !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.map_poi_pill_enabled !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapPoiPillHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
|
||||
@@ -322,37 +291,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optimize route from accommodation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.optimizeFromAccommodation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('optimize_from_accommodation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.optimize_from_accommodation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.optimize_from_accommodation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.optimizeFromAccommodationHint')}</p>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,24 +148,6 @@ export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
|
||||
await offlineDb.syncMeta.put(meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a pre-downloaded file blob for offline use. Returns null when the file
|
||||
* was never cached (or on any read error). The stored MIME is reapplied so the
|
||||
* caller's inline-vs-download decision stays correct even if the persisted Blob
|
||||
* lost its type.
|
||||
*/
|
||||
export async function getCachedBlob(url: string): Promise<Blob | null> {
|
||||
try {
|
||||
const entry = await offlineDb.blobCache.get(url);
|
||||
if (!entry) return null;
|
||||
return entry.blob.type
|
||||
? entry.blob
|
||||
: new Blob([entry.blob], { type: entry.mime || 'application/octet-stream' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
|
||||
@@ -53,44 +53,29 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// The departure/arrival coordinate of a transport, if its endpoints carry one.
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position.
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
|
||||
const entries: Entry[] = [
|
||||
// Build a unified list of places + transports sorted by effective position,
|
||||
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
|
||||
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
|
||||
const entries: (Entry & { pos: number })[] = [
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
from: epLoc(r, 'from'),
|
||||
to: epLoc(r, 'to'),
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
// Group located places into driving runs.
|
||||
// - A transport WITH a location anchors the route to its departure point (you
|
||||
// travel there), then breaks the run (you don't drive the flight/train); its
|
||||
// arrival point starts the next run.
|
||||
// - A transport WITHOUT a location is ignored entirely — the places around it
|
||||
// connect directly, as if the booking weren't there.
|
||||
// Group consecutive located places into runs, resetting whenever a transport
|
||||
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
|
||||
const runs: { lat: number; lng: number }[][] = []
|
||||
let currentRun: { lat: number; lng: number }[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||
} else if (entry.from || entry.to) {
|
||||
if (entry.from) currentRun.push(entry.from)
|
||||
} else {
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
currentRun = []
|
||||
if (entry.to) currentRun.push(entry.to)
|
||||
}
|
||||
}
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
@@ -135,9 +120,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
||||
.map(r => {
|
||||
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
||||
// Include endpoints so adding/moving a departure/arrival location re-routes.
|
||||
const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';')
|
||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}`
|
||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
|
||||
})
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
@@ -175,9 +175,6 @@ function useDefaultAtlasHandlers() {
|
||||
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
|
||||
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: {} })),
|
||||
// Country-border GeoJSON (admin-0) — served by the API now. Tests that need real
|
||||
// country features override this handler via server.use(...).
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json({ type: 'FeatureCollection', features: [] })),
|
||||
// Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
|
||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||
);
|
||||
@@ -190,6 +187,18 @@ beforeEach(() => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
||||
|
||||
// Stub the external GeoJSON fetch (GitHub raw URL) to avoid real network calls
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ type: 'FeatureCollection', features: [] }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
useDefaultAtlasHandlers();
|
||||
});
|
||||
|
||||
@@ -460,9 +469,16 @@ describe('AtlasPage', () => {
|
||||
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
|
||||
it('typing in search updates the input value', async () => {
|
||||
// Override fetch to return GeoJSON with FR feature
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(geoJsonWithFR),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
@@ -503,9 +519,16 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-019: confirm popup shows via Enter on search with GeoJSON', () => {
|
||||
it('pressing Enter in search with matching GeoJSON result triggers confirm popup', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(geoJsonWithFR),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
@@ -577,9 +600,16 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-022: confirm popup for bucket type shows month/year selects', () => {
|
||||
it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(geoJsonWithFR),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
@@ -612,9 +642,16 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-031: confirm popup opens and mark-visited action works', () => {
|
||||
it('opens confirm popup via search and clicking Mark as visited closes it', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(geoJsonWithFR),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
@@ -673,9 +710,16 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-032: confirm popup Add to Bucket opens bucket type', () => {
|
||||
it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(geoJsonWithFR),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
@@ -807,9 +851,16 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-029: confirm popup opens via search dropdown click', () => {
|
||||
it('clicking a country in the search dropdown opens the confirm action popup', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(geoJsonWithFR),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
@@ -863,9 +914,16 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-030: confirm popup overlay click closes it', () => {
|
||||
it('clicking the overlay backdrop closes the confirm popup', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(geoJsonWithFR),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
@@ -942,9 +1000,13 @@ describe('AtlasPage', () => {
|
||||
{ type: 'Feature', properties: { ISO_A2: 'DE', ADM0_A3: 'DEU', ISO_A3: 'DEU', NAME: 'Germany', ADMIN: 'Germany' }, geometry: null },
|
||||
],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandDE)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandDE) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
render(<AtlasPage />);
|
||||
|
||||
@@ -961,9 +1023,13 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
|
||||
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
@@ -1034,9 +1100,13 @@ describe('AtlasPage', () => {
|
||||
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
|
||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
@@ -1088,9 +1158,13 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
|
||||
it('submits a bucket list item from the confirm popup', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/bucket-list', () =>
|
||||
@@ -1247,9 +1321,13 @@ describe('AtlasPage', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithXK)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithXK) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
render(<AtlasPage />);
|
||||
|
||||
@@ -1267,9 +1345,13 @@ describe('AtlasPage', () => {
|
||||
{ a3: 'FRA', name: 'France', query: 'france' },
|
||||
{ a3: 'NOR', name: 'Norway', query: 'norway' },
|
||||
])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(makeGeoJsonWithA3Fallback(a3, name))),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(makeGeoJsonWithA3Fallback(a3, name)) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
@@ -1377,9 +1459,13 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-044: direct France dropdown button click', () => {
|
||||
it('directly finds and clicks the France button in the dropdown to cover onClick', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
@@ -1431,9 +1517,13 @@ describe('AtlasPage', () => {
|
||||
|
||||
describe('FE-PAGE-ATLAS-045: dark mode toggle covers map re-init + loadRegionsForViewport', () => {
|
||||
it('switching to dark mode re-initializes map and covers region loading code path', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||
@@ -1546,9 +1636,13 @@ describe('AtlasPage', () => {
|
||||
{ type: 'Feature', properties: { ISO_A2: 'IT', ADM0_A3: 'ITA', ISO_A3: 'ITA', NAME: 'Italy', ADMIN: 'Italy' }, geometry: null },
|
||||
],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandIT)),
|
||||
);
|
||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandIT) } as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||
});
|
||||
|
||||
render(<AtlasPage />);
|
||||
|
||||
|
||||
@@ -42,8 +42,6 @@ import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
||||
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
|
||||
import { useTripPlanner } from './tripPlanner/useTripPlanner'
|
||||
import { usePoiExplore } from '../components/Map/usePoiExplore'
|
||||
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
|
||||
|
||||
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
||||
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
||||
@@ -55,7 +53,6 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
|
||||
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
|
||||
@@ -124,7 +121,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
||||
style={sharedBtnStyle}
|
||||
/>
|
||||
{isAdmin && packingItems.length > 0 && (
|
||||
{packingItems.length > 0 && (
|
||||
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
|
||||
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
||||
style={sharedBtnStyle}
|
||||
@@ -197,17 +194,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
||||
} = useTripPlanner()
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
<div className="bg-surface" style={{
|
||||
@@ -305,16 +299,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
}}
|
||||
pois={poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
/>
|
||||
|
||||
{poiPillEnabled && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||
@@ -355,8 +341,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onSelectDay={handleSelectDay}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onReorder={handleReorder}
|
||||
onReorderDays={handleReorderDays}
|
||||
onAddDay={handleAddDay}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
@@ -608,7 +592,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -132,19 +132,18 @@ export function useAtlas() {
|
||||
}).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
||||
// no third-party fetch from the browser).
|
||||
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons/atlas/countries/geo')
|
||||
.then(res => {
|
||||
const geo = res.data
|
||||
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
|
||||
.then(r => r.json())
|
||||
.then(geo => {
|
||||
// Dynamically build A2→A3 mapping from GeoJSON
|
||||
for (const f of geo.features) {
|
||||
const a2 = f.properties?.ISO_A2
|
||||
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
|
||||
// Only accept clean 2-letter ISO codes and never overwrite an existing
|
||||
// mapping: some datasets carry subdivision-style values like "CN-TW" for
|
||||
// Taiwan, which would clobber the legitimate TWN->TW entry (#1049).
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function useTripPlanner() {
|
||||
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
|
||||
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
@@ -356,24 +356,6 @@ export function useTripPlanner() {
|
||||
} catch { /* best effort */ }
|
||||
}, [language])
|
||||
|
||||
// Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the
|
||||
// data already comes from the POI, so no reverse-geocode is needed.
|
||||
const openAddPlaceFromPoi = useCallback((poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string }) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
setPrefillCoords({
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
name: poi.name,
|
||||
address: poi.address || '',
|
||||
website: poi.website || undefined,
|
||||
phone: poi.phone || undefined,
|
||||
osm_id: poi.osm_id,
|
||||
})
|
||||
setEditingPlace(null)
|
||||
setEditingAssignmentId(null)
|
||||
setShowPlaceForm(true)
|
||||
}, [trip])
|
||||
|
||||
const handleSavePlace = useCallback(async (data) => {
|
||||
const pendingFiles = data._pendingFiles
|
||||
delete data._pendingFiles
|
||||
@@ -541,23 +523,6 @@ export function useTripPlanner() {
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [tripId, toast])
|
||||
|
||||
const handleReorderDays = useCallback((orderedIds: number[]) => {
|
||||
const prevIds = (useTripStore.getState().days || [])
|
||||
.slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id)
|
||||
tripActions.reorderDays(tripId, orderedIds)
|
||||
.then(() => {
|
||||
pushUndo(t('dayplan.reorderUndo'), async () => {
|
||||
await tripActions.reorderDays(tripId, prevIds)
|
||||
})
|
||||
})
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
|
||||
}, [tripId, toast, pushUndo])
|
||||
|
||||
const handleAddDay = useCallback((position?: number) => {
|
||||
tripActions.insertDay(tripId, position)
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')))
|
||||
}, [tripId, toast])
|
||||
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
@@ -676,9 +641,9 @@ export function useTripPlanner() {
|
||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
||||
|
||||
@@ -24,7 +24,6 @@ interface Addon {
|
||||
|
||||
interface AddonState {
|
||||
addons: Addon[]
|
||||
bagTracking: boolean
|
||||
loaded: boolean
|
||||
loadAddons: () => Promise<void>
|
||||
isEnabled: (id: string) => boolean
|
||||
@@ -32,13 +31,12 @@ interface AddonState {
|
||||
|
||||
export const useAddonStore = create<AddonState>((set, get) => ({
|
||||
addons: [],
|
||||
bagTracking: false,
|
||||
loaded: false,
|
||||
|
||||
loadAddons: async () => {
|
||||
try {
|
||||
const data = await addonsApi.enabled()
|
||||
set({ addons: data.addons || [], bagTracking: !!data.bagTracking, loaded: true })
|
||||
set({ addons: data.addons || [], loaded: true })
|
||||
} catch {
|
||||
set({ loaded: true })
|
||||
}
|
||||
|
||||
@@ -32,9 +32,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
temperature_unit: 'fahrenheit',
|
||||
time_format: '12h',
|
||||
show_place_description: false,
|
||||
optimize_from_accommodation: true,
|
||||
map_provider: 'leaflet',
|
||||
map_poi_pill_enabled: true,
|
||||
mapbox_access_token: '',
|
||||
mapbox_style: 'mapbox://styles/mapbox/standard',
|
||||
mapbox_3d_enabled: true,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { daysApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Day } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface DaysSlice {
|
||||
reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined>
|
||||
}
|
||||
|
||||
export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
|
||||
// Move whole days. Day rows stay stable (assignments/notes/bookings ride along
|
||||
// by id); only positions change and, on a dated trip, dates stay pinned to
|
||||
// their slots while the content moves across them. Optimistically reorder the
|
||||
// list, then refresh to pull the server-side re-stamped dates + booking times.
|
||||
reorderDays: async (tripId, orderedIds) => {
|
||||
const prevDays = get().days
|
||||
const byId = new Map(prevDays.map(d => [d.id, d]))
|
||||
const sortedDates = prevDays.map(d => d.date).filter((d): d is string => !!d).sort()
|
||||
const optimistic = orderedIds
|
||||
.map((id, i) => {
|
||||
const d = byId.get(id)
|
||||
if (!d) return null
|
||||
return { ...d, day_number: i + 1, date: sortedDates.length ? (sortedDates[i] ?? null) : d.date }
|
||||
})
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null)
|
||||
|
||||
set({ days: optimistic })
|
||||
|
||||
try {
|
||||
await daysApi.reorder(tripId, orderedIds)
|
||||
await get().refreshDays(tripId)
|
||||
await get().loadReservations(tripId)
|
||||
} catch (err: unknown) {
|
||||
set({ days: prevDays })
|
||||
throw new Error(getApiErrorMessage(err, 'Error reordering days'))
|
||||
}
|
||||
},
|
||||
|
||||
// Insert a new empty day at a 1-based position (omit to append). On a dated
|
||||
// trip this extends the trip by one day and re-pins dates server-side.
|
||||
insertDay: async (tripId, position) => {
|
||||
const prevDays = get().days
|
||||
try {
|
||||
const result = await daysApi.create(tripId, { position })
|
||||
await get().refreshDays(tripId)
|
||||
await get().loadReservations(tripId)
|
||||
return result.day
|
||||
} catch (err: unknown) {
|
||||
set({ days: prevDays })
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding day'))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -283,15 +283,6 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
}
|
||||
case 'day:reordered': {
|
||||
// Apply the new order instantly when we know all ids; the authoritative
|
||||
// dates + re-stamped booking times are pulled by the refresh below.
|
||||
const orderedIds = payload.orderedIds as number[] | undefined
|
||||
if (!orderedIds || orderedIds.length !== state.days.length) return {}
|
||||
const byId = new Map(state.days.map(d => [d.id, d]))
|
||||
if (!orderedIds.every(id => byId.has(id))) return {}
|
||||
return { days: orderedIds.map((id, i) => ({ ...byId.get(id)!, day_number: i + 1 })) }
|
||||
}
|
||||
|
||||
// Day Notes
|
||||
case 'dayNote:created': {
|
||||
@@ -451,16 +442,6 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
}
|
||||
})
|
||||
|
||||
// A reorder/insert re-pins dates and re-stamps booking times server-side, so
|
||||
// pull the authoritative days + reservations for collaborators.
|
||||
if (type === 'day:reordered') {
|
||||
const tripId = get().trip?.id
|
||||
if (tripId) {
|
||||
get().refreshDays(tripId)
|
||||
get().loadReservations(tripId)
|
||||
}
|
||||
}
|
||||
|
||||
// Write the change through to IndexedDB using the post-update state
|
||||
writeToDexie(type, payload as Record<string, unknown>, get())
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { packingRepo } from '../repo/packingRepo'
|
||||
import { todoRepo } from '../repo/todoRepo'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDaysSlice } from './slices/daysSlice'
|
||||
import { createDayNotesSlice } from './slices/dayNotesSlice'
|
||||
import { createPackingSlice } from './slices/packingSlice'
|
||||
import { createTodoSlice } from './slices/todoSlice'
|
||||
@@ -25,7 +24,6 @@ import type {
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import type { PlacesSlice } from './slices/placesSlice'
|
||||
import type { AssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import type { DaysSlice } from './slices/daysSlice'
|
||||
import type { DayNotesSlice } from './slices/dayNotesSlice'
|
||||
import type { PackingSlice } from './slices/packingSlice'
|
||||
import type { TodoSlice } from './slices/todoSlice'
|
||||
@@ -36,7 +34,6 @@ import type { FilesSlice } from './slices/filesSlice'
|
||||
export interface TripStoreState
|
||||
extends PlacesSlice,
|
||||
AssignmentsSlice,
|
||||
DaysSlice,
|
||||
DayNotesSlice,
|
||||
PackingSlice,
|
||||
TodoSlice,
|
||||
@@ -187,7 +184,6 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
...createPlacesSlice(set, get),
|
||||
...createAssignmentsSlice(set, get),
|
||||
...createDaysSlice(set, get),
|
||||
...createDayNotesSlice(set, get),
|
||||
...createPackingSlice(set, get),
|
||||
...createTodoSlice(set, get),
|
||||
|
||||
@@ -580,23 +580,6 @@
|
||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||
.trek-dash .add-trip-card { min-height: 180px; }
|
||||
|
||||
/* Compact list row on mobile — keeps the list view distinct from the grid. The
|
||||
desktop list row uses a 520px cover, which overflowed the phone width: the
|
||||
cover was clipped, the body pushed off-screen, and the fixed 100px cover
|
||||
height left a white strip beneath it. Use a fitting cover that stretches to
|
||||
the row, and show just the title + dates (the counts live in grid view and
|
||||
on the trip itself). */
|
||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
|
||||
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
|
||||
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
|
||||
.trek-dash .trips.list-view .trip-name {
|
||||
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
|
||||
.trek-dash .trips.list-view .trip-meta { display: none; }
|
||||
|
||||
/* Tools — stacked full-width cards (mockup) */
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
||||
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
||||
|
||||
@@ -113,8 +113,6 @@ export interface Settings {
|
||||
show_place_description: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_booking_labels?: boolean
|
||||
map_poi_pill_enabled?: boolean
|
||||
optimize_from_accommodation?: boolean
|
||||
map_provider?: 'leaflet' | 'mapbox-gl'
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -164,12 +162,6 @@ export interface Waypoint {
|
||||
lng: number
|
||||
}
|
||||
|
||||
// Optional fixed start/end points for route optimization (e.g. the day's accommodation).
|
||||
export interface RouteAnchors {
|
||||
start?: Waypoint
|
||||
end?: Waypoint
|
||||
}
|
||||
|
||||
// User with optional OIDC fields
|
||||
export interface UserWithOidc extends User {
|
||||
oidc_issuer?: string | null
|
||||
|
||||
@@ -126,18 +126,18 @@ describe('getMergedItems', () => {
|
||||
expect(types).toEqual(['place', 'transport', 'place'])
|
||||
})
|
||||
|
||||
it('orders a timed transport chronologically regardless of a stale per-day position', () => {
|
||||
it('per-day position overrides time-based insertion', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||
]
|
||||
// The train is at 10:30, so it sorts between the 08:00 and 13:00 places by time —
|
||||
// timed items are arranged chronologically even if an old manual position exists.
|
||||
// Transport at 10:30 would normally go between the two places
|
||||
// but per-day position 1.5 puts it after the second place
|
||||
const dayTransports = [
|
||||
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||
]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||
const types = result.map(i => i.type)
|
||||
expect(types).toEqual(['place', 'transport', 'place'])
|
||||
expect(types).toEqual(['place', 'place', 'transport'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,66 +39,12 @@ export function getDisplayTimeForDay(
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
|
||||
function parseFlightLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight') return null
|
||||
let meta = r.metadata
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
// Defensive: recover metadata that was accidentally double-encoded by an earlier
|
||||
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a multi-leg flight into one synthetic reservation per leg that touches
|
||||
* `dayId`, each with its own day span + departure/arrival time so it slots into
|
||||
* the timeline independently. A single-leg flight (or any other reservation) is
|
||||
* returned untouched, so existing behaviour is unchanged.
|
||||
*/
|
||||
export function expandFlightLegsForDay(
|
||||
r: any,
|
||||
dayId: number,
|
||||
getDayOrder: (id: number) => number,
|
||||
days: Array<{ id: number; date?: string | null }>
|
||||
): any[] {
|
||||
const legs = parseFlightLegs(r)
|
||||
if (!legs) return [r]
|
||||
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
|
||||
const thisOrder = getDayOrder(dayId)
|
||||
const out: any[] = []
|
||||
legs.forEach((leg, i) => {
|
||||
const dep = leg.dep_day_id ?? r.day_id ?? null
|
||||
const arr = leg.arr_day_id ?? dep
|
||||
if (dep == null) return
|
||||
const depOrder = getDayOrder(dep)
|
||||
const arrOrder = getDayOrder(arr ?? dep)
|
||||
if (!(thisOrder >= depOrder && thisOrder <= arrOrder)) return
|
||||
const depDate = dateOf(dep)
|
||||
const arrDate = dateOf(arr ?? dep)
|
||||
out.push({
|
||||
...r,
|
||||
day_id: dep,
|
||||
end_day_id: arr ?? dep,
|
||||
reservation_time: leg.dep_time ? (depDate ? `${depDate}T${leg.dep_time}` : leg.dep_time) : null,
|
||||
reservation_end_time: leg.arr_time ? (arrDate ? `${arrDate}T${leg.arr_time}` : leg.arr_time) : null,
|
||||
// Each leg carries its OWN saved position (not the booking's) so items can be
|
||||
// dropped between legs and persist; absent → falls back to time ordering.
|
||||
day_positions: leg.day_positions || undefined,
|
||||
day_plan_position: undefined,
|
||||
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||
export function getTransportForDay(opts: {
|
||||
reservations: any[]
|
||||
dayId: number
|
||||
dayAssignmentIds: number[]
|
||||
days: Array<{ id: number; day_number?: number; date?: string | null }>
|
||||
days: Array<{ id: number; day_number?: number }>
|
||||
}): any[] {
|
||||
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||
|
||||
@@ -123,34 +69,7 @@ export function getTransportForDay(opts: {
|
||||
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||
}
|
||||
return startDayId === dayId
|
||||
}).flatMap(r => expandFlightLegsForDay(r, dayId, getDayOrder, days))
|
||||
}
|
||||
|
||||
/**
|
||||
* Order items chronologically: anything with a time (a place's place_time, a
|
||||
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
|
||||
* time inherits the time of the timed item before it, so untimed items stay where
|
||||
* they were manually placed. Stable on the incoming order for ties.
|
||||
*/
|
||||
function applyChronoOrder(
|
||||
items: MergedItem[],
|
||||
dayId: number,
|
||||
getDisplayTime: (r: any, dayId: number) => string | null
|
||||
): MergedItem[] {
|
||||
const timeOf = (it: MergedItem): number | null => {
|
||||
if (it.type === 'place') return parseTimeToMinutes(it.data?.place?.place_time)
|
||||
if (it.type === 'note') return parseTimeToMinutes(it.data?.time)
|
||||
return parseTimeToMinutes(getDisplayTime(it.data, dayId))
|
||||
}
|
||||
let last = -Infinity
|
||||
return items
|
||||
.map((it, i) => {
|
||||
const t = timeOf(it)
|
||||
if (t != null) last = t
|
||||
return { it, i, eff: t != null ? t : last }
|
||||
})
|
||||
.sort((a, b) => a.eff - b.eff || a.i - b.i)
|
||||
.map(k => k.it)
|
||||
})
|
||||
}
|
||||
|
||||
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||
@@ -175,9 +94,9 @@ export function getMergedItems(opts: {
|
||||
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return applyChronoOrder(baseItems, dayId, getDisplayTime)
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
if (baseItems.length === 0) {
|
||||
return applyChronoOrder(timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data })), dayId, getDisplayTime)
|
||||
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||
}
|
||||
|
||||
// Insert transports among base items based on per-day position or time
|
||||
@@ -213,5 +132,5 @@ export function getMergedItems(opts: {
|
||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||
}
|
||||
|
||||
return applyChronoOrder(result.sort((a, b) => a.sortKey - b.sortKey), dayId, getDisplayTime)
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Day, Accommodation } from '../types'
|
||||
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors } from './dayOrder'
|
||||
|
||||
const days = [
|
||||
{ id: 10, day_number: 1 },
|
||||
{ id: 20, day_number: 2 },
|
||||
{ id: 30, day_number: 3 },
|
||||
] as unknown as Day[]
|
||||
|
||||
const hotel = (over: Partial<Accommodation>): Accommodation =>
|
||||
({ place_lat: 48.1, place_lng: 11.5, start_day_id: 10, end_day_id: 30, ...over }) as Accommodation
|
||||
|
||||
describe('getDayOrder', () => {
|
||||
it('prefers day_number when present', () => {
|
||||
expect(getDayOrder(days[1], days)).toBe(2)
|
||||
})
|
||||
it('falls back to array index when day_number is missing', () => {
|
||||
const noNumber = [{ id: 5 }, { id: 6 }] as unknown as Day[]
|
||||
expect(getDayOrder(noNumber[1], noNumber)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDayInAccommodationRange', () => {
|
||||
it('is inclusive of both the check-in and check-out day', () => {
|
||||
expect(isDayInAccommodationRange(days[0], 10, 30, days)).toBe(true) // check-in morning
|
||||
expect(isDayInAccommodationRange(days[1], 10, 30, days)).toBe(true) // mid-stay
|
||||
expect(isDayInAccommodationRange(days[2], 10, 30, days)).toBe(true) // check-out day
|
||||
})
|
||||
it('excludes days outside the stay', () => {
|
||||
expect(isDayInAccommodationRange(days[0], 20, 30, days)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAccommodationAnchors', () => {
|
||||
it('returns no anchors when the day has no accommodation', () => {
|
||||
expect(getAccommodationAnchors(days[1], days, [])).toEqual({})
|
||||
})
|
||||
|
||||
it('anchors both ends to the same hotel on a mid-stay day (round trip)', () => {
|
||||
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: 48.1, place_lng: 11.5 })]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
|
||||
start: { lat: 48.1, lng: 11.5 },
|
||||
end: { lat: 48.1, lng: 11.5 },
|
||||
})
|
||||
})
|
||||
|
||||
it('loops a single hotel on its check-out day (home base for the day)', () => {
|
||||
const accs = [hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 2 })]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({ start: { lat: 1, lng: 2 }, end: { lat: 1, lng: 2 } })
|
||||
})
|
||||
|
||||
it('loops a single hotel on its check-in day (home base for the day)', () => {
|
||||
const accs = [hotel({ start_day_id: 20, end_day_id: 30, place_lat: 3, place_lng: 4 })]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({ start: { lat: 3, lng: 4 }, end: { lat: 3, lng: 4 } })
|
||||
})
|
||||
|
||||
it('uses the checked-out hotel as start and the checked-in hotel as end on a transfer day', () => {
|
||||
const accs = [
|
||||
hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 }), // checkout today
|
||||
hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }), // check-in today
|
||||
]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
|
||||
start: { lat: 1, lng: 1 },
|
||||
end: { lat: 9, lng: 9 },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores accommodations that have no coordinates', () => {
|
||||
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,34 +1,8 @@
|
||||
import type { Day, Accommodation, RouteAnchors } from '../types'
|
||||
import type { Day } from '../types'
|
||||
|
||||
export const getDayOrder = (day: Day, days: Day[]): number =>
|
||||
day.day_number ?? days.indexOf(day)
|
||||
|
||||
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
|
||||
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
|
||||
// and into another — instead runs from the morning hotel to the evening one.
|
||||
export const getAccommodationAnchors = (
|
||||
day: Day,
|
||||
days: Day[],
|
||||
accommodations: Accommodation[],
|
||||
): RouteAnchors => {
|
||||
const located = accommodations.filter(a =>
|
||||
a.place_lat != null && a.place_lng != null &&
|
||||
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
|
||||
)
|
||||
if (located.length === 0) return {}
|
||||
|
||||
const toAnchor = (a: Accommodation) => ({ lat: a.place_lat as number, lng: a.place_lng as number })
|
||||
|
||||
const checkOut = located.find(a => a.end_day_id === day.id) // the hotel you leave this morning
|
||||
const checkIn = located.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
|
||||
if (checkOut && checkIn && checkOut !== checkIn) {
|
||||
return { start: toAnchor(checkOut), end: toAnchor(checkIn) }
|
||||
}
|
||||
|
||||
const hotel = toAnchor(located[0])
|
||||
return { start: hotel, end: hotel }
|
||||
}
|
||||
|
||||
export const isDayInAccommodationRange = (
|
||||
day: Day,
|
||||
startDayId: number,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getCachedBlob } from '../db/offlineDb'
|
||||
|
||||
// MIME types safe to open inline (will not execute script in any browser).
|
||||
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
|
||||
// download so a maliciously-named upload cannot run code in the TREK origin.
|
||||
@@ -41,46 +39,17 @@ function isIosStandalone(): boolean {
|
||||
return (navigator as any).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a protected file to a Blob, preferring the live server but falling
|
||||
* back to the offline cache (pre-downloaded by the trip sync manager). This is
|
||||
* what lets attachments open in a PWA / airplane mode. When offline we go
|
||||
* straight to the cache; when online we fetch live and only fall back if the
|
||||
* network actually fails — which also covers flaky links where navigator.onLine
|
||||
* still reports true ("sometimes it works, sometimes it doesn't").
|
||||
*/
|
||||
async function getFileBlob(url: string): Promise<Blob> {
|
||||
assertRelativeUrl(url)
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
const cached = await getCachedBlob(url)
|
||||
if (cached) return cached
|
||||
throw new Error('File not available offline')
|
||||
}
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(url, { credentials: 'include' })
|
||||
} catch (err) {
|
||||
// Genuine network failure — the fetch itself rejected (offline, or a flaky
|
||||
// link even though navigator.onLine is true). Serve the pre-downloaded copy.
|
||||
const cached = await getCachedBlob(url)
|
||||
if (cached) return cached
|
||||
throw err
|
||||
}
|
||||
// The server answered: a non-ok status (401/403/404/…) is a real error and must
|
||||
// surface, not be masked by a stale cached copy.
|
||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
||||
return await resp.blob()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a protected file using cookie auth (credentials: include) and
|
||||
* triggers a browser download. Works inside PWA standalone mode because the
|
||||
* fetch stays in the PWA's WebView rather than handing off to the system
|
||||
* browser (which would lose the session cookie). Falls back to the offline
|
||||
* cache when the network is unavailable.
|
||||
* browser (which would lose the session cookie).
|
||||
*/
|
||||
export async function downloadFile(url: string, filename?: string): Promise<void> {
|
||||
const blob = await getFileBlob(url)
|
||||
assertRelativeUrl(url)
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
triggerAnchorDownload(blobUrl, filename)
|
||||
}
|
||||
@@ -103,7 +72,10 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
||||
* spurious in-page download is triggered.
|
||||
*/
|
||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
const blob = await getFileBlob(url)
|
||||
assertRelativeUrl(url)
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Force download for MIME types that can execute script when rendered inline
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Multi-leg (layover) flight support.
|
||||
//
|
||||
// A flight booking is ONE reservation whose route is an ordered chain of airports
|
||||
// (e.g. FRA -> BER -> HND). The geometry + order are the source of truth in
|
||||
// `reservation.endpoints` (role 'from' for the first airport, 'stop' for each
|
||||
// intermediate one, 'to' for the last, ordered by `sequence`). The per-leg detail
|
||||
// — airline, flight number, and each segment's own day/time — lives in
|
||||
// `metadata.legs`. The top-level metadata (`departure_airport`/`arrival_airport`/
|
||||
// `airline`/`flight_number`) and `day_id`/`end_day_id` mirror the FIRST and LAST
|
||||
// leg so legacy readers keep working.
|
||||
//
|
||||
// A legacy single-leg flight (two endpoints, flat metadata, no `metadata.legs`)
|
||||
// is normalised here into a one-leg chain, so every renderer can use one path.
|
||||
|
||||
import type { Reservation, ReservationEndpoint } from '../types'
|
||||
|
||||
export interface FlightLeg {
|
||||
from: string | null // IATA code (or null)
|
||||
to: string | null
|
||||
airline?: string
|
||||
flight_number?: string
|
||||
dep_day_id?: number | null
|
||||
dep_time?: string | null // 'HH:mm'
|
||||
arr_day_id?: number | null
|
||||
arr_time?: string | null
|
||||
}
|
||||
|
||||
/** reservation.metadata may be a JSON string or an already-parsed object. */
|
||||
export function parseReservationMetadata(r: Pick<Reservation, 'metadata'>): Record<string, any> {
|
||||
const m = r.metadata
|
||||
if (!m) return {}
|
||||
if (typeof m === 'string') {
|
||||
try {
|
||||
let parsed = JSON.parse(m || '{}')
|
||||
// Defensive: an earlier bug could double-encode metadata (a JSON string of a
|
||||
// JSON string) — unwrap it once more so saved flights heal on read.
|
||||
if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed) } catch { /* keep */ } }
|
||||
return (parsed && typeof parsed === 'object') ? parsed : {}
|
||||
} catch { return {} }
|
||||
}
|
||||
return m as Record<string, any>
|
||||
}
|
||||
|
||||
/** Endpoints ordered by `sequence` (geometry + order source of truth). */
|
||||
export function orderedEndpoints(r: Pick<Reservation, 'endpoints'>): ReservationEndpoint[] {
|
||||
return (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered legs of a flight. `metadata.legs` is preferred; otherwise a single leg
|
||||
* is derived from the endpoints (and finally the flat metadata) so that legacy
|
||||
* single-leg flights — and flights created before this feature — still work.
|
||||
*/
|
||||
export function getFlightLegs(r: Reservation): FlightLeg[] {
|
||||
const meta = parseReservationMetadata(r)
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
|
||||
return meta.legs.map((l: any): FlightLeg => ({
|
||||
from: l.from ?? null,
|
||||
to: l.to ?? null,
|
||||
airline: l.airline || undefined,
|
||||
flight_number: l.flight_number || undefined,
|
||||
dep_day_id: l.dep_day_id ?? null,
|
||||
dep_time: l.dep_time ?? null,
|
||||
arr_day_id: l.arr_day_id ?? null,
|
||||
arr_time: l.arr_time ?? null,
|
||||
}))
|
||||
}
|
||||
// Legacy fallback: one leg from the endpoints / flat metadata.
|
||||
const eps = orderedEndpoints(r)
|
||||
const first = eps[0]
|
||||
const last = eps[eps.length - 1]
|
||||
const fromCode = first?.code ?? meta.departure_airport ?? null
|
||||
const toCode = last?.code ?? meta.arrival_airport ?? null
|
||||
if (!fromCode && !toCode) return []
|
||||
return [{
|
||||
from: fromCode,
|
||||
to: toCode,
|
||||
airline: meta.airline || undefined,
|
||||
flight_number: meta.flight_number || undefined,
|
||||
dep_day_id: r.day_id ?? null,
|
||||
dep_time: first?.local_time ?? null,
|
||||
arr_day_id: r.end_day_id ?? r.day_id ?? null,
|
||||
arr_time: last?.local_time ?? null,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Number of flight segments. 1 for a simple from -> to booking. */
|
||||
export function legCount(r: Reservation): number {
|
||||
return getFlightLegs(r).length
|
||||
}
|
||||
|
||||
export function isMultiLegFlight(r: Reservation): boolean {
|
||||
return r.type === 'flight' && legCount(r) > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered route labels (IATA codes, or names when no code) for display, e.g.
|
||||
* ['FRA','BER','HND']. Uses endpoints; falls back to the flat metadata pair.
|
||||
*/
|
||||
export function routeStops(r: Reservation): string[] {
|
||||
const eps = orderedEndpoints(r)
|
||||
if (eps.length >= 2) return eps.map(e => e.code || e.name)
|
||||
const meta = parseReservationMetadata(r)
|
||||
return [meta.departure_airport, meta.arrival_airport].filter(Boolean) as string[]
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { http, HttpResponse } from 'msw';
|
||||
export const addonHandlers = [
|
||||
http.get('/api/addons', () => {
|
||||
return HttpResponse.json({
|
||||
bagTracking: false,
|
||||
addons: [
|
||||
{ id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', enabled: true },
|
||||
{ id: 'atlas', name: 'Atlas', type: 'feature', icon: 'map', enabled: true },
|
||||
|
||||
@@ -18,18 +18,6 @@ describe('addonStore', () => {
|
||||
expect(state.addons.length).toBeGreaterThan(0);
|
||||
expect(state.addons[0]).toHaveProperty('id');
|
||||
expect(state.addons[0]).toHaveProperty('enabled', true);
|
||||
expect(state.bagTracking).toBe(false);
|
||||
});
|
||||
|
||||
it('captures the global bagTracking flag from the response', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||
)
|
||||
);
|
||||
|
||||
await useAddonStore.getState().loadAddons();
|
||||
expect(useAddonStore.getState().bagTracking).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { downloadFile, openFile } from '../../../src/utils/fileDownload'
|
||||
import { getCachedBlob } from '../../../src/db/offlineDb'
|
||||
|
||||
// Mock the offline DB so these tests never touch Dexie/IndexedDB.
|
||||
vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() }))
|
||||
|
||||
function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
@@ -174,52 +170,3 @@ describe('openFile', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('offline fallback (#1046)', () => {
|
||||
function setOnline(value: boolean) {
|
||||
Object.defineProperty(navigator, 'onLine', { value, configurable: true })
|
||||
}
|
||||
beforeEach(() => vi.mocked(getCachedBlob).mockReset())
|
||||
afterEach(() => setOnline(true))
|
||||
|
||||
it('serves the cached blob without a network call when offline', async () => {
|
||||
setOnline(false)
|
||||
const blob = new Blob(['x'], { type: 'application/pdf' })
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob)
|
||||
const fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadFile('/uploads/files/cached.pdf')
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
|
||||
})
|
||||
|
||||
it('falls back to the cache when a live fetch rejects (network error) while online', async () => {
|
||||
setOnline(true)
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
||||
const blob = new Blob(['x'], { type: 'application/pdf' })
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadFile('/uploads/files/cached.pdf')
|
||||
|
||||
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
|
||||
})
|
||||
|
||||
it('throws when offline and the file was never cached', async () => {
|
||||
setOnline(false)
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(null)
|
||||
await expect(downloadFile('/uploads/files/missing.pdf')).rejects.toThrow(/offline/i)
|
||||
})
|
||||
|
||||
it('does not consult the cache on an HTTP error — a 401 still surfaces', async () => {
|
||||
setOnline(true)
|
||||
vi.stubGlobal('fetch', makeFetchMock(401))
|
||||
await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
|
||||
expect(getCachedBlob).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
||||
.atlas-geo-cache/
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,225 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Build server/assets/atlas/{admin0,admin1}.geojson.gz from geoBoundaries (gbOpen).
|
||||
//
|
||||
// Why: Atlas previously fetched country + sub-national boundaries from Natural Earth's
|
||||
// GitHub `master` at runtime. Natural Earth is stale (e.g. it still shows Norway's
|
||||
// pre-2020 counties) and depicts some contested territory in ways the project does not
|
||||
// want (see nvkelso/natural-earth-vector#391). geoBoundaries (CC BY 4.0) is current,
|
||||
// redistributable, and carries ISO 3166-2 codes on its per-country ADM1 files.
|
||||
//
|
||||
// This downloads the *simplified* per-country gbOpen ADM0 (countries) and ADM1
|
||||
// (regions) layers from a pinned geoBoundaries revision, normalizes each feature to
|
||||
// the property names the Atlas client/server already read, and writes two gzipped
|
||||
// FeatureCollections that the server serves at runtime (no network at boot).
|
||||
//
|
||||
// geoBoundaries: CC BY 4.0 — https://www.geoboundaries.org/ (attribution required).
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import zlib from 'node:zlib'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const OUT_DIR = path.join(__dirname, '..', 'assets', 'atlas')
|
||||
|
||||
// Pinned geoBoundaries revision (override with GB_REF=<sha|branch|tag>). The LFS media
|
||||
// endpoint resolves a commit SHA, branch, or tag in the <ref> path segment.
|
||||
const GB_REF = process.env.GB_REF || '5c25134028196d43ce97b5071934fd0cfc92f09f'
|
||||
const MEDIA = (a3, level) =>
|
||||
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
|
||||
`/releaseData/gbOpen/${a3}/${level}/geoBoundaries-${a3}-${level}_simplified.geojson`
|
||||
|
||||
// Country borders come from CGAZ (the Comprehensive Global Administrative Zones composite)
|
||||
// rather than per-country gbOpen ADM0: CGAZ is gap-filled, so it includes territories
|
||||
// that gbOpen omits or folds away — notably Svalbard (inside Norway's geometry) and
|
||||
// Greenland. The country layer only needs A3/A2/name, so CGAZ's lack of `shapeISO` is
|
||||
// irrelevant. (gbOpen ADM0 maxes Norway at 71°N and has no Svalbard at all.)
|
||||
const CGAZ_ADM0 =
|
||||
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
|
||||
`/releaseData/CGAZ/geoBoundariesCGAZ_ADM0.geojson`
|
||||
|
||||
const CONCURRENCY = 8
|
||||
const RETRIES = 3
|
||||
|
||||
// Complete ISO-3166-1 alpha-3 → alpha-2 map (source: lukes/ISO-3166-Countries-with-
|
||||
// Regional-Codes). Drives ADM1 enumeration (one gbOpen request per code; missing ones
|
||||
// 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries keys by alpha-3
|
||||
// `shapeGroup`). A complete map — not the client's curated ~180 — is what restores the
|
||||
// dropped territories (Greenland, Falklands, French Guiana, …).
|
||||
const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
|
||||
|
||||
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
|
||||
|
||||
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
|
||||
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF)
|
||||
|
||||
async function fetchGeo(url) {
|
||||
const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0])
|
||||
if (fs.existsSync(cacheFile)) {
|
||||
const cached = fs.readFileSync(cacheFile, 'utf8')
|
||||
return cached === '' ? null : JSON.parse(cached)
|
||||
}
|
||||
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'TREK atlas builder' } })
|
||||
if (res.status === 404) { fs.writeFileSync(cacheFile, ''); return null } // no file — skip
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const text = await res.text()
|
||||
if (text.startsWith('version https://git-lfs')) throw new Error('got LFS pointer, not content')
|
||||
const parsed = JSON.parse(text)
|
||||
fs.writeFileSync(cacheFile, text)
|
||||
return parsed
|
||||
} catch (err) {
|
||||
if (attempt === RETRIES) {
|
||||
console.warn(` ! ${url.split('/').slice(-1)[0]}: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500 * attempt))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Run async tasks with a fixed concurrency cap.
|
||||
async function pool(items, worker) {
|
||||
const results = []
|
||||
let i = 0
|
||||
const runners = Array.from({ length: CONCURRENCY }, async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++
|
||||
results[idx] = await worker(items[idx], idx)
|
||||
}
|
||||
})
|
||||
await Promise.all(runners)
|
||||
return results
|
||||
}
|
||||
|
||||
// Geometry size control. geoBoundaries' "_simplified" files still carry ~12-decimal
|
||||
// coordinates, which dominate the JSON size. Quantizing to a fixed grid (rounding
|
||||
// preserves topology — identical input coords map to identical output) and dropping
|
||||
// the now-redundant consecutive duplicate points shrinks the bundles ~5-8x with no
|
||||
// visible effect at the atlas' zoom range (3-10). ADM0 fills are viewed zoomed out, so
|
||||
// they tolerate a coarser grid than ADM1 region borders.
|
||||
const ADM0_DECIMALS = 2 // ~1.1 km
|
||||
const ADM1_DECIMALS = 3 // ~110 m
|
||||
|
||||
function quantizeRing(ring, decimals) {
|
||||
const m = 10 ** decimals
|
||||
const out = []
|
||||
let prevX, prevY
|
||||
for (const pt of ring) {
|
||||
const x = Math.round(pt[0] * m) / m
|
||||
const y = Math.round(pt[1] * m) / m
|
||||
if (x === prevX && y === prevY) continue
|
||||
out.push([x, y])
|
||||
prevX = x; prevY = y
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Quantize a (Multi)Polygon, dropping rings that collapse below a valid ring (<4 pts).
|
||||
function quantizeGeometry(geom, decimals) {
|
||||
if (!geom) return null
|
||||
if (geom.type === 'Polygon') {
|
||||
const rings = geom.coordinates.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4)
|
||||
return rings.length ? { type: 'Polygon', coordinates: rings } : null
|
||||
}
|
||||
if (geom.type === 'MultiPolygon') {
|
||||
const polys = geom.coordinates
|
||||
.map(poly => poly.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4))
|
||||
.filter(poly => poly.length)
|
||||
return polys.length ? { type: 'MultiPolygon', coordinates: polys } : null
|
||||
}
|
||||
return geom
|
||||
}
|
||||
|
||||
// Normalize one CGAZ ADM0 feature (keyed by alpha-3 `shapeGroup`) to the property names
|
||||
// the client country layer reads (ISO_A2/ADM0_A3/NAME/ADMIN). Returns null for the CRS
|
||||
// pseudo-entry or anything without a group/geometry.
|
||||
function normalizeAdm0Feature(f) {
|
||||
const a3 = f.properties?.shapeGroup
|
||||
if (!a3) return null
|
||||
const name = f.properties?.shapeName || a3
|
||||
const geometry = quantizeGeometry(f.geometry, ADM0_DECIMALS)
|
||||
if (!geometry) return null
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { ISO_A2: A3_TO_A2[a3] || null, ADM0_A3: a3, NAME: name, ADMIN: name },
|
||||
geometry,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAdm1(geo, a3, countryName) {
|
||||
if (!geo?.features) return []
|
||||
return geo.features.map(f => {
|
||||
const name = f.properties?.shapeName || ''
|
||||
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
|
||||
if (!geometry) return null
|
||||
const a2 = A3_TO_A2[a3] || null
|
||||
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
|
||||
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
|
||||
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
|
||||
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
|
||||
// every region is still markable.
|
||||
let code = f.properties?.shapeISO || ''
|
||||
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
|
||||
return {
|
||||
type: 'Feature',
|
||||
// Property names the Atlas region layer + server getRegionGeo already read.
|
||||
properties: {
|
||||
iso_a2: a2,
|
||||
iso_3166_2: code,
|
||||
name,
|
||||
name_en: name,
|
||||
admin: countryName,
|
||||
},
|
||||
geometry,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`)
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
||||
|
||||
// ADM0 (countries) — one comprehensive CGAZ file (large; cached). Also yields the
|
||||
// English country name (shapeGroup → shapeName) used for the ADM1 `admin` field.
|
||||
console.log('[atlas-geo] downloading CGAZ ADM0 (countries)…')
|
||||
const cgaz = await fetchGeo(CGAZ_ADM0)
|
||||
const adm0Features = []
|
||||
const a3ToName = {}
|
||||
for (const f of cgaz?.features || []) {
|
||||
const nf = normalizeAdm0Feature(f)
|
||||
if (nf) { a3ToName[nf.properties.ADM0_A3] = nf.properties.NAME; adm0Features.push(nf) }
|
||||
}
|
||||
|
||||
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
|
||||
console.log('[atlas-geo] downloading ADM1 (regions)…')
|
||||
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, 'ADM1')))
|
||||
const adm1Features = []
|
||||
let withCodes = 0
|
||||
COUNTRIES.forEach((a3, idx) => {
|
||||
const feats = normalizeAdm1(adm1Raw[idx], a3, a3ToName[a3] || a3)
|
||||
for (const f of feats) if (f.properties.iso_3166_2) withCodes++
|
||||
adm1Features.push(...feats)
|
||||
})
|
||||
|
||||
const write = (name, features) => {
|
||||
const fc = { type: 'FeatureCollection', features }
|
||||
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 })
|
||||
const file = path.join(OUT_DIR, `${name}.geojson.gz`)
|
||||
fs.writeFileSync(file, gz)
|
||||
console.log(`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} — ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`)
|
||||
}
|
||||
|
||||
write('admin0', adm0Features)
|
||||
write('admin1', adm1Features)
|
||||
|
||||
const missing1 = COUNTRIES.filter((a3, i) => !normalizeAdm1(adm1Raw[i], a3, '').length)
|
||||
console.log(`[atlas-geo] ADM0 country features: ${adm0Features.length}`)
|
||||
console.log(`[atlas-geo] ADM1 countries without regions (skipped/404): ${missing1.length}`)
|
||||
console.log(`[atlas-geo] ADM1 features with ISO 3166-2 code: ${withCodes}/${adm1Features.length}`)
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
@@ -1,6 +1,3 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import Database from 'better-sqlite3';
|
||||
import { encrypt_api_key } from '../services/apiKeyCrypto';
|
||||
|
||||
@@ -2372,94 +2369,6 @@ function runMigrations(db: Database.Database): void {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
||||
`),
|
||||
// Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national
|
||||
// regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; some no
|
||||
// longer match any polygon in the new bundle and would stop highlighting. Reconcile
|
||||
// every row against the ACTUAL shipped admin-1 bundle so this covers *all* countries,
|
||||
// not just one hand-listed reform:
|
||||
// 1. code still present in the new bundle → leave it (already correct);
|
||||
// 2. else a region in the same country shares → adopt that region's code+name
|
||||
// the stored region_name (case-insensitive) (handles code re-spellings, e.g.
|
||||
// ES-AN → ES_AND, names unchanged);
|
||||
// 3. else a curated merge crosswalk maps it → adopt the merged region (handles
|
||||
// (region absorbed into a *renamed* one) reforms where the name changed,
|
||||
// which step 2 cannot catch);
|
||||
// 4. else → leave as-is (cannot be resolved; the client's name fallback may still
|
||||
// highlight it, and nothing is destroyed).
|
||||
// Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold only
|
||||
// ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` is free
|
||||
// text we must not auto-rewrite, and `place_regions` is a re-derivable Nominatim cache.
|
||||
() => {
|
||||
type Row = { id: number; region_code: string; region_name: string; country_code: string };
|
||||
const rows = db.prepare(
|
||||
'SELECT id, region_code, region_name, country_code FROM visited_regions'
|
||||
).all() as Row[];
|
||||
if (rows.length === 0) return; // nothing marked → skip the bundle read entirely
|
||||
|
||||
// Index the shipped admin-1 bundle: valid codes, name→code per country, code→name.
|
||||
// __dirname resolves ../../assets under both dist (dist/db) and tests (src/db).
|
||||
let features: { properties?: { iso_a2?: string; iso_3166_2?: string; name?: string } }[] = [];
|
||||
try {
|
||||
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
|
||||
features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || [];
|
||||
} catch {
|
||||
features = []; // bundle missing → degrade to the curated crosswalk below
|
||||
}
|
||||
const validCodes = new Set<string>();
|
||||
const nameToCode = new Map<string, string>(); // `${A2}|${nameLower}` → code
|
||||
const codeToName = new Map<string, string>();
|
||||
for (const f of features) {
|
||||
const a2 = (f.properties?.iso_a2 || '').toUpperCase();
|
||||
const code = f.properties?.iso_3166_2 || '';
|
||||
const name = f.properties?.name || '';
|
||||
if (!code) continue;
|
||||
validCodes.add(code);
|
||||
if (!codeToName.has(code)) codeToName.set(code, name);
|
||||
if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code);
|
||||
}
|
||||
|
||||
// Curated crosswalk for regions absorbed into a *renamed* successor (step 2 can't
|
||||
// match these because the name changed). Norway's 2018/2020 reforms; extend as the
|
||||
// pinned geoBoundaries dataset gains further reforms.
|
||||
const MERGE_CROSSWALK: Record<string, string> = {
|
||||
'NO-04': 'NO-34', 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet
|
||||
'NO-12': 'NO-46', 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland
|
||||
'NO-09': 'NO-42', 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder
|
||||
'NO-01': 'NO-30', 'NO-02': 'NO-30', 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken
|
||||
'NO-07': 'NO-38', 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark
|
||||
'NO-19': 'NO-54', 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark
|
||||
'NO-16': 'NO-50', 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag
|
||||
};
|
||||
|
||||
const resolve = (row: Row): string | null => {
|
||||
if (validCodes.has(row.region_code)) return null; // already valid
|
||||
const a2 = (row.country_code || '').toUpperCase();
|
||||
const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`);
|
||||
if (byName) return byName;
|
||||
const merged = MERGE_CROSSWALK[row.region_code];
|
||||
// Only trust the crosswalk target if it actually exists in the bundle (or the
|
||||
// bundle was unreadable, in which case we apply the curated map blindly).
|
||||
if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged;
|
||||
return null;
|
||||
};
|
||||
|
||||
const update = db.prepare(
|
||||
'UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?'
|
||||
);
|
||||
const del = db.prepare('DELETE FROM visited_regions WHERE id = ?');
|
||||
for (const row of rows) {
|
||||
const newCode = resolve(row);
|
||||
if (!newCode || newCode === row.region_code) continue;
|
||||
const newName = codeToName.get(newCode) || row.region_name;
|
||||
update.run(newCode, newName, row.id);
|
||||
// UNIQUE(user_id, region_code): if the user already had the target code the
|
||||
// UPDATE was IGNORED and this row still carries the old code → drop the duplicate.
|
||||
const after = db.prepare('SELECT region_code FROM visited_regions WHERE id = ?').get(row.id) as
|
||||
| { region_code: string }
|
||||
| undefined;
|
||||
if (after && after.region_code === row.region_code) del.run(row.id);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { broadcast } from '../../websocket';
|
||||
import { db } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
|
||||
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
|
||||
try {
|
||||
@@ -48,24 +46,6 @@ export function noAccess() {
|
||||
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
|
||||
}
|
||||
|
||||
export function permissionDenied() {
|
||||
return { content: [{ type: 'text' as const, text: 'You do not have permission to perform this action on this trip.' }], isError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* RBAC gate for MCP tools, mirroring the checkPermission() calls the REST/Nest
|
||||
* routes run. Call this after canAccessTrip() with the same action key the
|
||||
* matching REST route uses. Returns true when the user may perform `action`
|
||||
* on `tripId`.
|
||||
*/
|
||||
export function hasTripPermission(action: string, tripId: number | string, userId: number): boolean {
|
||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id?: number } | undefined;
|
||||
if (!trip) return false;
|
||||
const userRow = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
||||
const tripOwnerId = typeof trip.user_id === 'number' ? trip.user_id : null;
|
||||
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
|
||||
}
|
||||
|
||||
export function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
@@ -38,7 +38,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
async ({ tripId, dayId, placeId, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const assignment = createAssignment(dayId, placeId, notes || null);
|
||||
@@ -61,7 +60,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
async ({ tripId, dayId, assignmentId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
deleteAssignment(assignmentId);
|
||||
@@ -85,7 +83,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
async ({ tripId, assignmentId, place_time, end_time }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const assignment = updateTime(
|
||||
@@ -114,7 +111,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
if (!getDay(newDayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
|
||||
@@ -155,7 +151,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
async ({ tripId, assignmentId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const participants = setAssignmentParticipants(assignmentId, userIds);
|
||||
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
|
||||
@@ -179,7 +174,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
async ({ tripId, dayId, assignmentIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
reorderAssignments(dayId, assignmentIds);
|
||||
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
@@ -38,7 +38,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, name, category, total_price, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
@@ -58,7 +57,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deleteBudgetItem(itemId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:deleted', { itemId });
|
||||
@@ -87,7 +85,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:updated', { item });
|
||||
@@ -114,7 +111,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, name, category, total_price, note, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const hasMembers = userIds && userIds.length > 0;
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
@@ -148,7 +144,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, itemId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updateBudgetMembers(itemId, tripId, userIds);
|
||||
safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
@@ -170,8 +165,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, itemId, memberId, paid }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const member = toggleMemberPaid(itemId, tripId, memberId, paid);
|
||||
const member = toggleMemberPaid(itemId, memberId, paid);
|
||||
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
|
||||
return ok({ member });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
@@ -43,7 +43,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, title, content, category, color, pinned }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
|
||||
safeBroadcast(tripId, 'collab:note:created', { note });
|
||||
return ok({ note });
|
||||
@@ -68,7 +67,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, noteId, title, content, category, color, pinned }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:note:updated', { note });
|
||||
@@ -89,7 +87,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deleteCollabNote(tripId, noteId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
|
||||
@@ -131,7 +128,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, question, options, multiple, deadline }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const poll = createPoll(tripId, userId, { question, options, multiple, deadline });
|
||||
safeBroadcast(tripId, 'collab:poll:created', { poll });
|
||||
return ok({ poll });
|
||||
@@ -151,7 +147,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
},
|
||||
async ({ tripId, pollId, optionIndex }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const result = votePoll(tripId, pollId, userId, optionIndex);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
|
||||
@@ -172,7 +167,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, pollId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const poll = closePoll(tripId, pollId);
|
||||
if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:poll:closed', { poll });
|
||||
@@ -193,7 +187,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, pollId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deletePoll(tripId, pollId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
|
||||
@@ -232,7 +225,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, text, replyTo }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const result = createMessage(tripId, userId, text, replyTo ?? null);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:message:created', { message: result.message });
|
||||
@@ -253,7 +245,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, messageId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const result = deleteMessage(tripId, messageId, userId);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
|
||||
@@ -275,7 +266,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
async ({ tripId, messageId, emoji }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
|
||||
const result = addOrRemoveReaction(messageId, tripId, userId, emoji);
|
||||
if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
@@ -38,7 +38,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, dayId, title }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const current = getDay(dayId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
|
||||
@@ -61,7 +60,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, date, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const day = createDay(tripId, date, notes);
|
||||
safeBroadcast(tripId, 'day:created', { day });
|
||||
return ok({ day });
|
||||
@@ -81,7 +79,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, dayId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
deleteDay(dayId);
|
||||
safeBroadcast(tripId, 'day:deleted', { id: dayId });
|
||||
@@ -108,7 +105,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
||||
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
@@ -148,7 +144,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||
try {
|
||||
@@ -187,7 +182,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getAccommodation(accommodationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
@@ -209,7 +203,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, accommodationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const { linkedReservationId } = deleteAccommodation(accommodationId);
|
||||
safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
|
||||
@@ -235,7 +228,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, dayId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const note = createDayNote(dayId, tripId, text, time, icon);
|
||||
safeBroadcast(tripId, 'dayNote:created', { dayId, note });
|
||||
@@ -260,7 +252,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, dayId, noteId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getDayNote(noteId, dayId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
|
||||
@@ -283,7 +274,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
async ({ tripId, dayId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const note = getDayNote(noteId, dayId, tripId);
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
deleteDayNote(noteId);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
@@ -42,7 +42,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const item = createPackingItem(tripId, { name, category: category || 'General' });
|
||||
safeBroadcast(tripId, 'packing:created', { item });
|
||||
return ok({ item });
|
||||
@@ -63,7 +62,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, itemId, checked }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:updated', { item });
|
||||
@@ -84,7 +82,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deletePackingItem(tripId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:deleted', { itemId });
|
||||
@@ -109,7 +106,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, itemId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
|
||||
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
@@ -133,7 +129,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, orderedIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
reorderPackingItems(tripId, orderedIds);
|
||||
safeBroadcast(tripId, 'packing:reordered', { orderedIds });
|
||||
return ok({ success: true });
|
||||
@@ -170,7 +165,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, name, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const bag = createBag(tripId, { name, color });
|
||||
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
||||
return ok({ bag });
|
||||
@@ -192,7 +186,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, bagId, name, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const fields: Record<string, unknown> = {};
|
||||
const bodyKeys: string[] = [];
|
||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||
@@ -216,7 +209,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, bagId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
deleteBag(tripId, bagId);
|
||||
safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId });
|
||||
return ok({ success: true });
|
||||
@@ -237,7 +229,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, bagId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
setBagMembers(tripId, bagId, userIds);
|
||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
|
||||
return ok({ success: true });
|
||||
@@ -274,7 +265,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, categoryName, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
|
||||
return ok({ success: true });
|
||||
@@ -294,7 +284,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, templateId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const applied = applyTemplate(tripId, templateId);
|
||||
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:template-applied', { templateId });
|
||||
@@ -315,7 +304,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, templateName }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
saveAsTemplate(tripId, userId, templateName);
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -338,7 +326,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
async ({ tripId, items }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
bulkImport(tripId, items);
|
||||
safeBroadcast(tripId, 'packing:updated', {});
|
||||
return ok({ success: true, count: items.length });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { searchPlaces } from '../../services/mapsService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
@@ -45,7 +45,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
@@ -79,7 +78,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
@@ -127,7 +125,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:updated', { place });
|
||||
@@ -148,7 +145,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ tripId, placeId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId });
|
||||
@@ -226,7 +222,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ tripId, url, source }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
const result = source === 'google-list'
|
||||
? await importGoogleList(String(tripId), url)
|
||||
@@ -256,7 +251,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ tripId, placeIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
const deleted = deletePlacesMany(String(tripId), placeIds);
|
||||
for (const id of deleted) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { placeExists, getAssignmentForTrip } from '../../services/assignmentServ
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
@@ -47,7 +47,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
// Validate that all referenced IDs belong to this trip
|
||||
if (day_id && !getDay(day_id, tripId))
|
||||
@@ -114,7 +113,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
|
||||
@@ -146,7 +144,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (accommodationDeleted) {
|
||||
@@ -174,7 +171,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
async ({ tripId, positions, dayId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
updateReservationPositions(tripId, positions, dayId);
|
||||
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
|
||||
return ok({ success: true });
|
||||
@@ -199,7 +195,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
const current = getReservation(reservationId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
@@ -58,7 +58,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
safeBroadcast(tripId, 'todo:created', { item });
|
||||
return ok({ item });
|
||||
@@ -84,7 +83,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
// Build bodyKeys to signal which nullable fields were explicitly provided
|
||||
const bodyKeys: string[] = [];
|
||||
if (due_date !== undefined) bodyKeys.push('due_date');
|
||||
@@ -112,7 +110,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, itemId, checked }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updateTodoItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'todo:updated', { item });
|
||||
@@ -133,7 +130,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deleteTodoItem(tripId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'todo:deleted', { itemId });
|
||||
@@ -154,7 +150,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, orderedIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
reorderTodoItems(tripId, orderedIds);
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -190,7 +185,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, categoryName, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
|
||||
return ok({ assignees });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
@@ -56,7 +56,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
@@ -121,7 +120,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
async ({ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
@@ -167,7 +165,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
const { deleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
safeBroadcast, MAX_MCP_TRIP_DAYS,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
|
||||
|
||||
@@ -84,7 +84,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
|
||||
if (start_date) {
|
||||
const d = new Date(start_date + 'T00:00:00Z');
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
|
||||
@@ -322,8 +321,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
// Read parity with the REST route GET /api/trips/:tripId/share-link, which
|
||||
// only requires trip membership (share_manage gates create/delete, not read).
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const link = getShareLink(String(tripId));
|
||||
return ok({ link });
|
||||
@@ -347,7 +344,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
|
||||
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
|
||||
share_map: share_map ?? true,
|
||||
share_bookings: share_bookings ?? true,
|
||||
@@ -371,7 +367,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
async ({ tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
|
||||
deleteShareLink(String(tripId));
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -27,11 +27,7 @@ export function extractToken(req: Request): string | null {
|
||||
*/
|
||||
export function verifyJwtAndLoadUser(token: string): User | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
|
||||
// Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
|
||||
// secret but are not full session tokens — only their dedicated endpoint
|
||||
// may accept them, so reject any token carrying a purpose claim here.
|
||||
if (decoded.purpose) return null;
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
|
||||
const row = db.prepare(
|
||||
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
|
||||
).get(decoded.id) as (User & { password_version?: number }) | undefined;
|
||||
|
||||
@@ -97,6 +97,7 @@ export function applyGlobalMiddleware(
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||
],
|
||||
@@ -106,9 +107,6 @@ export function applyGlobalMiddleware(
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
frameAncestors: ["'self'"],
|
||||
// Restrict <form> submission targets (form-action has no default-src
|
||||
// fallback, so it must be set explicitly).
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import type { Addon } from '../../types';
|
||||
import { getBagTracking, getCollabFeatures } from '../../services/adminService';
|
||||
import { getCollabFeatures } from '../../services/adminService';
|
||||
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||
|
||||
/**
|
||||
@@ -53,7 +53,6 @@ export class AddonsService {
|
||||
|
||||
return {
|
||||
collabFeatures: getCollabFeatures(),
|
||||
bagTracking: getBagTracking().enabled,
|
||||
addons: [
|
||||
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map((p) => ({
|
||||
|
||||
@@ -62,12 +62,6 @@ export class AtlasController {
|
||||
return geo;
|
||||
}
|
||||
|
||||
@Get('countries/geo')
|
||||
@Header('Cache-Control', 'public, max-age=86400')
|
||||
countryGeo(): RegionGeo {
|
||||
return this.atlas.countryGeo();
|
||||
}
|
||||
|
||||
@Get('country/:code')
|
||||
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
|
||||
return this.atlas.countryPlaces(user.id, code.toUpperCase());
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
unmarkRegionVisited,
|
||||
getVisitedRegions,
|
||||
getRegionGeo,
|
||||
getCountryGeo,
|
||||
listBucketList,
|
||||
createBucketItem,
|
||||
updateBucketItem,
|
||||
@@ -38,10 +37,6 @@ export class AtlasService {
|
||||
return getRegionGeo(countries);
|
||||
}
|
||||
|
||||
countryGeo() {
|
||||
return getCountryGeo();
|
||||
}
|
||||
|
||||
countryPlaces(userId: number, code: string) {
|
||||
return getCountryPlaces(userId, code);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -77,15 +76,12 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Put('me/password')
|
||||
changePassword(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
changePassword(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.auth.changePassword(user.id, user.email, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
// Refresh this device's cookie with the new password_version so the user
|
||||
// stays logged in here while all other sessions are invalidated.
|
||||
if (result.token) this.auth.setAuthCookie(res, result.token, req);
|
||||
writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -90,90 +90,6 @@ function mapFlight(r: KiReservation, source: ParsedBookingItem['source']): Parse
|
||||
};
|
||||
}
|
||||
|
||||
/** True when flight `b` is a short layover connection that continues flight `a`. */
|
||||
function sameConnection(a: KiReservation, b: KiReservation): boolean {
|
||||
const fa = a.reservationFor as KiFlight | undefined;
|
||||
const fb = b.reservationFor as KiFlight | undefined;
|
||||
if (!fa || !fb) return false;
|
||||
const arrIata = fa.arrivalAirport?.iataCode?.toUpperCase();
|
||||
const depIata = fb.departureAirport?.iataCode?.toUpperCase();
|
||||
if (!arrIata || !depIata || arrIata !== depIata) return false; // must connect at the same airport
|
||||
const arrIso = toIsoString(fa.arrivalTime);
|
||||
const depIso = toIsoString(fb.departureTime);
|
||||
if (arrIso && depIso) {
|
||||
const gapMs = new Date(depIso).getTime() - new Date(arrIso).getTime();
|
||||
// A real layover is forward in time and short — anything longer (e.g. a
|
||||
// round-trip return days later) stays a separate booking.
|
||||
if (gapMs < 0 || gapMs > 24 * 3600 * 1000) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Collapse several connecting flight legs (same PNR) into one multi-leg booking. */
|
||||
function mapFlightGroup(legs: KiReservation[], source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
const flights = legs.map(l => l.reservationFor as KiFlight | undefined);
|
||||
if (flights.some(f => !f)) return mapFlight(legs[0], source); // malformed → fall back to single
|
||||
const fs = flights as KiFlight[];
|
||||
|
||||
const iataOf = (ap: KiFlight['departureAirport']) => ap?.iataCode?.toUpperCase() ?? null;
|
||||
const makeEndpoint = (
|
||||
ap: KiFlight['departureAirport'], role: 'from' | 'stop' | 'to', time: string | null, date: string | null,
|
||||
): ParsedEndpoint | null => {
|
||||
const iata = iataOf(ap);
|
||||
const found = iata ? findByIata(iata) : null;
|
||||
const label = found ? (found.city ? `${found.city} (${found.iata})` : found.name) : (ap?.name ?? iata ?? 'Unknown');
|
||||
if (found) return { role, sequence: 0, name: label, code: found.iata, lat: found.lat, lng: found.lng, timezone: found.tz, local_time: time, local_date: date };
|
||||
const c = coords(ap?.geo);
|
||||
if (c) return { role, sequence: 0, name: label, code: iata, lat: c.lat, lng: c.lng, timezone: null, local_time: time, local_date: date };
|
||||
return null;
|
||||
};
|
||||
|
||||
const endpoints: ParsedEndpoint[] = [];
|
||||
const metaLegs: Record<string, unknown>[] = [];
|
||||
const first = fs[0];
|
||||
const firstDep = splitIso(first.departureTime);
|
||||
const originEp = makeEndpoint(first.departureAirport, 'from', firstDep.time, firstDep.date);
|
||||
if (originEp) endpoints.push(originEp);
|
||||
|
||||
fs.forEach((f, i) => {
|
||||
const isLast = i === fs.length - 1;
|
||||
const arr = splitIso(f.arrivalTime);
|
||||
const arrEp = makeEndpoint(f.arrivalAirport, isLast ? 'to' : 'stop', arr.time, arr.date);
|
||||
if (arrEp) endpoints.push(arrEp);
|
||||
const airline = f.airline?.name ?? f.airline?.iataCode ?? '';
|
||||
metaLegs.push({
|
||||
from: iataOf(f.departureAirport),
|
||||
to: iataOf(f.arrivalAirport),
|
||||
...(airline ? { airline } : {}),
|
||||
...(f.flightNumber ? { flight_number: f.flightNumber } : {}),
|
||||
dep_time: splitIso(f.departureTime).time,
|
||||
arr_time: arr.time,
|
||||
});
|
||||
});
|
||||
endpoints.forEach((e, i) => { e.sequence = i; });
|
||||
|
||||
const last = fs[fs.length - 1];
|
||||
const airline = first.airline?.name ?? first.airline?.iataCode ?? '';
|
||||
const route = [iataOf(first.departureAirport), ...fs.map(f => iataOf(f.arrivalAirport))].filter(Boolean).join(' → ');
|
||||
return {
|
||||
type: 'flight',
|
||||
title: airline ? `${airline} ${route}` : `Flight ${route}`,
|
||||
reservation_time: toIsoString(first.departureTime),
|
||||
reservation_end_time: toIsoString(last.arrivalTime),
|
||||
confirmation_number: legs[0].reservationNumber ?? null,
|
||||
metadata: {
|
||||
...(airline ? { airline } : {}),
|
||||
...(first.flightNumber ? { flight_number: first.flightNumber } : {}),
|
||||
...(iataOf(first.departureAirport) ? { departure_airport: iataOf(first.departureAirport) } : {}),
|
||||
...(iataOf(last.arrivalAirport) ? { arrival_airport: iataOf(last.arrivalAirport) } : {}),
|
||||
legs: metaLegs,
|
||||
},
|
||||
endpoints,
|
||||
needs_review: endpoints.length < fs.length + 1,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
const t = r.reservationFor as KiTrainTrip | undefined;
|
||||
if (!t) return null;
|
||||
@@ -317,25 +233,8 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
|
||||
const source = { fileName, index: i };
|
||||
let item: ParsedBookingItem | null = null;
|
||||
|
||||
// Group consecutive connecting flight legs that share a PNR into one booking.
|
||||
if (r['@type'] === 'FlightReservation') {
|
||||
const pnr = r.reservationNumber ?? null;
|
||||
const group = [r];
|
||||
while (
|
||||
i + 1 < kiItems.length &&
|
||||
kiItems[i + 1]['@type'] === 'FlightReservation' &&
|
||||
pnr != null &&
|
||||
(kiItems[i + 1].reservationNumber ?? null) === pnr &&
|
||||
sameConnection(group[group.length - 1], kiItems[i + 1])
|
||||
) {
|
||||
group.push(kiItems[++i]);
|
||||
}
|
||||
item = group.length > 1 ? mapFlightGroup(group, source) : mapFlight(r, source);
|
||||
if (item) items.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (r['@type']) {
|
||||
case 'FlightReservation': item = mapFlight(r, source); break;
|
||||
case 'TrainReservation': item = mapTrain(r, source); break;
|
||||
case 'BusReservation': item = mapBus(r, source); break;
|
||||
case 'BoatReservation': item = mapBoat(r, source); break;
|
||||
|
||||
@@ -229,7 +229,7 @@ export class BudgetController {
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const member = this.budget.toggleMemberPaid(id, tripId, userId, paid);
|
||||
const member = this.budget.toggleMemberPaid(id, userId, paid);
|
||||
this.budget.broadcast(tripId, 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, socketId);
|
||||
return { member };
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ export class BudgetService {
|
||||
return svc.updateMembers(id, tripId, userIds);
|
||||
}
|
||||
|
||||
toggleMemberPaid(id: string, tripId: string, userId: string, paid: boolean) {
|
||||
return svc.toggleMemberPaid(id, tripId, userId, paid);
|
||||
toggleMemberPaid(id: string, userId: string, paid: boolean) {
|
||||
return svc.toggleMemberPaid(id, userId, paid);
|
||||
}
|
||||
|
||||
setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { DaysService } from './days.service';
|
||||
import { DayReorderError } from '../../services/dayService';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
@@ -53,47 +52,16 @@ export class DaysController {
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { date?: string; notes?: string; position?: number },
|
||||
@Body() body: { date?: string; notes?: string },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// A `position` means "insert a new empty day here" (which on a dated trip
|
||||
// extends the trip and re-pins dates); without it, the legacy append.
|
||||
const day = body.position !== undefined
|
||||
? this.days.insert(tripId, body.position)
|
||||
: this.days.create(tripId, body.date, body.notes);
|
||||
// An insert can shuffle dates/positions of other days, so collaborators
|
||||
// refetch the whole list; a plain append only needs the new day.
|
||||
const event = body.position !== undefined ? 'day:reordered' : 'day:created';
|
||||
this.days.broadcast(tripId, event, { day }, socketId);
|
||||
const day = this.days.create(tripId, body.date, body.notes);
|
||||
this.days.broadcast(tripId, 'day:created', { day }, socketId);
|
||||
return { day };
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { orderedIds?: number[] },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(body.orderedIds)) {
|
||||
throw new HttpException({ error: 'orderedIds must be an array' }, 400);
|
||||
}
|
||||
try {
|
||||
this.days.reorder(tripId, body.orderedIds);
|
||||
} catch (err) {
|
||||
if (err instanceof DayReorderError) {
|
||||
throw new HttpException({ error: err.message }, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this.days.broadcast(tripId, 'day:reordered', { orderedIds: body.orderedIds }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
|
||||
@@ -39,14 +39,6 @@ export class DaysService {
|
||||
return dayService.createDay(tripId, date, notes);
|
||||
}
|
||||
|
||||
insert(tripId: string, position?: number) {
|
||||
return dayService.insertDay(tripId, position);
|
||||
}
|
||||
|
||||
reorder(tripId: string, orderedIds: number[]) {
|
||||
return dayService.reorderDays(tripId, orderedIds);
|
||||
}
|
||||
|
||||
update(id: string, current: Parameters<typeof dayService.updateDay>[1], fields: { notes?: string; title?: string | null }) {
|
||||
return dayService.updateDay(id, current, fields);
|
||||
}
|
||||
|
||||
@@ -52,11 +52,9 @@ export class JourneyPublicController {
|
||||
const wantThumb = kind === 'thumbnail' ? 'thumbnail' : 'original';
|
||||
|
||||
if (provider === 'local') {
|
||||
// Local journey assets are flat filenames; use basename() and confine the
|
||||
// resolved path to the journey upload directory.
|
||||
const journeyDir = path.resolve(__dirname, '../../../uploads/journey');
|
||||
const resolved = path.resolve(path.join(journeyDir, path.basename(assetId)));
|
||||
if (!resolved.startsWith(journeyDir + path.sep) || !fs.existsSync(resolved)) {
|
||||
const resolved = path.resolve(path.join(__dirname, '../../../uploads/journey', assetId));
|
||||
const uploadsDir = path.resolve(__dirname, '../../../uploads');
|
||||
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
@@ -67,27 +67,6 @@ export class MapsController {
|
||||
}
|
||||
}
|
||||
|
||||
// OSM-only POI explore: places of a category within the current map viewport.
|
||||
@Get('pois')
|
||||
async pois(
|
||||
@Query('category') category?: string,
|
||||
@Query('south') south?: string,
|
||||
@Query('west') west?: string,
|
||||
@Query('north') north?: string,
|
||||
@Query('east') east?: string,
|
||||
) {
|
||||
if (!category) throw new HttpException({ error: 'A category is required' }, 400);
|
||||
const bbox = { south: Number(south), west: Number(west), north: Number(north), east: Number(east) };
|
||||
if (Object.values(bbox).some(v => !Number.isFinite(v))) {
|
||||
throw new HttpException({ error: 'A valid bbox (south, west, north, east) is required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.pois(category, bbox);
|
||||
} catch (err: unknown) {
|
||||
throw toHttpException(err, 'POI search error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('autocomplete')
|
||||
@HttpCode(200)
|
||||
async autocomplete(
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
searchOverpassPois,
|
||||
} from '../../services/mapsService';
|
||||
import { serveFilePath } from '../../services/placePhotoCache';
|
||||
|
||||
@@ -87,9 +86,4 @@ export class MapsService {
|
||||
resolveUrl(url: string): Promise<MapsResolveUrlResult> {
|
||||
return resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
|
||||
}
|
||||
|
||||
// OSM-only POI search by category within a viewport bbox (never calls Google).
|
||||
pois(category: string, bbox: { south: number; west: number; north: number; east: number }) {
|
||||
return searchOverpassPois(category, bbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { cookieOptions } from '../../services/cookie';
|
||||
|
||||
const OIDC_STATE_COOKIE = 'trek_oidc_state';
|
||||
|
||||
/**
|
||||
* /api/auth/oidc — OIDC SSO login flow (Authorization Code + PKCE).
|
||||
@@ -43,11 +40,6 @@ export class OidcController {
|
||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken);
|
||||
// Bind the state to THIS browser. The callback requires a matching cookie,
|
||||
// so an attacker-initiated login (whose callback URL carries a valid state
|
||||
// from the shared server map) cannot be replayed in a victim's browser to
|
||||
// log them into the attacker's account (OIDC login CSRF / session fixation).
|
||||
res.cookie(OIDC_STATE_COOKIE, state, { ...cookieOptions(false, req), maxAge: 10 * 60 * 1000 });
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
@@ -69,15 +61,10 @@ export class OidcController {
|
||||
@Query('code') code: string | undefined,
|
||||
@Query('state') state: string | undefined,
|
||||
@Query('error') oidcError: string | undefined,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const f = (p: string) => res.redirect(this.oidc.frontendUrl(p));
|
||||
|
||||
// The state cookie is single-use — clear it regardless of the outcome.
|
||||
const boundState = (req.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
|
||||
res.clearCookie(OIDC_STATE_COOKIE, cookieOptions(true, req));
|
||||
|
||||
if (!this.oidc.oidcLoginEnabled()) return f('/login?oidc_error=sso_disabled');
|
||||
if (oidcError) {
|
||||
console.error('[OIDC] Provider error:', oidcError);
|
||||
@@ -85,9 +72,6 @@ export class OidcController {
|
||||
}
|
||||
if (!code || !state) return f('/login?oidc_error=missing_params');
|
||||
|
||||
// Require the callback to come from the browser that started the flow.
|
||||
if (!boundState || boundState !== state) return f('/login?oidc_error=invalid_state');
|
||||
|
||||
const pending = this.oidc.consumeState(state);
|
||||
if (!pending) return f('/login?oidc_error=invalid_state');
|
||||
|
||||
|
||||
@@ -195,12 +195,6 @@ export class PackingController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { templates: this.packing.listTemplates() };
|
||||
}
|
||||
|
||||
@Post('apply-template/:templateId')
|
||||
@HttpCode(200)
|
||||
applyTemplate(
|
||||
@@ -244,9 +238,6 @@ export class PackingController {
|
||||
@Body('name') name?: string,
|
||||
) {
|
||||
this.requireTrip(tripId, user);
|
||||
if (user.role !== 'admin') {
|
||||
throw new HttpException({ error: 'Admin access required' }, 403);
|
||||
}
|
||||
if (!name?.trim()) {
|
||||
throw new HttpException({ error: 'Template name is required' }, 400);
|
||||
}
|
||||
|
||||
@@ -71,10 +71,6 @@ export class PackingService {
|
||||
return svc.setBagMembers(tripId, bagId, userIds);
|
||||
}
|
||||
|
||||
listTemplates() {
|
||||
return svc.listTemplates();
|
||||
}
|
||||
|
||||
applyTemplate(tripId: string, templateId: string) {
|
||||
return svc.applyTemplate(tripId, templateId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, HttpException, Param, Post, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import type { User } from '../../types';
|
||||
import { ShareService } from './share.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -73,30 +72,6 @@ export class TripShareController {
|
||||
export class SharedController {
|
||||
constructor(private readonly share: ShareService) {}
|
||||
|
||||
/**
|
||||
* Public, token-scoped place-photo proxy. The shared payload rewrites place
|
||||
* image URLs to this route so thumbnails load without a session cookie (the
|
||||
* /api/maps bytes endpoint is JwtAuthGuard'd). The service validates the token
|
||||
* and that the place belongs to its trip; a miss streams nothing and answers
|
||||
* 404. Declared before the bare ':token' read route. Streaming mirrors
|
||||
* MapsController.placePhotoBytes (cached photos are always JPEG).
|
||||
*/
|
||||
@Get(':token/place-photo/:placeId/bytes')
|
||||
placePhotoBytes(@Param('token') token: string, @Param('placeId') placeId: string, @Res() res: Response): void {
|
||||
const fp = this.share.getSharedPlacePhotoPath(token, placeId);
|
||||
if (!fp) {
|
||||
res.status(404).json({ error: 'Photo not cached' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
res.type('image/jpeg');
|
||||
const stream = createReadStream(fp);
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' });
|
||||
});
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Get(':token')
|
||||
read(@Param('token') token: string) {
|
||||
const data = this.share.getSharedTripData(token);
|
||||
|
||||
@@ -26,5 +26,4 @@ export class ShareService {
|
||||
get(tripId: string) { return svc.getShareLink(tripId); }
|
||||
remove(tripId: string) { return svc.deleteShareLink(tripId); }
|
||||
getSharedTripData(token: string) { return svc.getSharedTripData(token); }
|
||||
getSharedPlacePhotoPath(token: string, placeId: string) { return svc.getSharedPlacePhotoPath(token, placeId); }
|
||||
}
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { db } from '../db/database';
|
||||
import { Trip, Place } from '../types';
|
||||
|
||||
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
||||
//
|
||||
// Sourced from geoBoundaries (CC BY 4.0), normalized + quantized offline by
|
||||
// scripts/build-atlas-geo.mjs into gzipped FeatureCollections under server/assets.
|
||||
// They are read + decompressed once and cached in memory — no network at runtime.
|
||||
// (Replaces the previous runtime fetch of Natural Earth, which was stale for recent
|
||||
// sub-national reforms and depicts some contested borders in unwanted ways.)
|
||||
//
|
||||
// __dirname is server/dist/services at runtime and server/src/services under vitest;
|
||||
// both resolve ../../assets to server/assets.
|
||||
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
|
||||
|
||||
const geoBundleCache = new Map<string, any>();
|
||||
let admin1GeoCache: any = null;
|
||||
let admin1GeoLoading: Promise<any> | null = null;
|
||||
|
||||
function loadGeoBundle(name: 'admin0' | 'admin1'): any {
|
||||
const cached = geoBundleCache.get(name);
|
||||
if (cached) return cached;
|
||||
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn(`[Atlas] ${name}.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||
const empty = { type: 'FeatureCollection', features: [] };
|
||||
geoBundleCache.set(name, empty);
|
||||
return empty;
|
||||
}
|
||||
const geo = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'));
|
||||
geoBundleCache.set(name, geo);
|
||||
console.log(`[Atlas] Loaded ${name} GeoJSON: ${geo.features?.length || 0} features`);
|
||||
return geo;
|
||||
}
|
||||
|
||||
/** Full admin-0 country-border FeatureCollection (for the client map's country layer). */
|
||||
export function getCountryGeo(): any {
|
||||
return loadGeoBundle('admin0');
|
||||
async function loadAdmin1Geo(): Promise<any> {
|
||||
if (admin1GeoCache) return admin1GeoCache;
|
||||
if (admin1GeoLoading) return admin1GeoLoading;
|
||||
admin1GeoLoading = fetch(
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
).then(r => r.json()).then((geo: any) => {
|
||||
admin1GeoCache = geo;
|
||||
admin1GeoLoading = null;
|
||||
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
|
||||
return geo;
|
||||
}).catch(err => {
|
||||
admin1GeoLoading = null;
|
||||
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
|
||||
return null;
|
||||
});
|
||||
return admin1GeoLoading;
|
||||
}
|
||||
|
||||
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
||||
const geo = loadGeoBundle('admin1');
|
||||
const geo = await loadAdmin1Geo();
|
||||
if (!geo) return { type: 'FeatureCollection', features: [] };
|
||||
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
||||
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
||||
|
||||
@@ -490,9 +490,8 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
|
||||
const pv = (user as User & { password_version?: number }).password_version ?? 0;
|
||||
const mfa_token = jwt.sign(
|
||||
{ id: Number(user.id), purpose: 'mfa_login', pv },
|
||||
{ id: Number(user.id), purpose: 'mfa_login' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '5m', algorithm: 'HS256' }
|
||||
);
|
||||
@@ -535,7 +534,7 @@ export function changePassword(
|
||||
userId: number,
|
||||
userEmail: string,
|
||||
body: { current_password?: string; new_password?: string }
|
||||
): { error?: string; status?: number; success?: boolean; token?: string } {
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
if (isOidcOnlyMode()) {
|
||||
return { error: 'Password authentication is disabled.', status: 403 };
|
||||
}
|
||||
@@ -550,32 +549,14 @@ export function changePassword(
|
||||
const pwCheck = validatePassword(new_password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
|
||||
const user = db.prepare('SELECT password_hash, password_version FROM users WHERE id = ?').get(userId) as { password_hash: string; password_version?: number } | undefined;
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
|
||||
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
|
||||
return { error: 'Current password is incorrect', status: 401 };
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
|
||||
const newPv = (user.password_version ?? 0) + 1;
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, newPv, userId);
|
||||
// A password change rotates the user's sessions: bumping password_version
|
||||
// invalidates existing JWT cookie sessions, and the separate MCP static
|
||||
// token and OAuth bearer-token stores are pruned to match (same set the
|
||||
// password-reset path already revokes).
|
||||
db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(userId);
|
||||
try {
|
||||
db.prepare("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL").run(userId);
|
||||
} catch { /* oauth_tokens table may not exist in very old installs */ }
|
||||
})();
|
||||
|
||||
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
|
||||
|
||||
// Re-issue a session bound to the new password_version so the current device
|
||||
// stays logged in while other existing sessions are rotated out by the pv gate.
|
||||
const token = generateToken({ id: userId, password_version: newPv });
|
||||
return { success: true, token };
|
||||
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
|
||||
@@ -1194,13 +1175,9 @@ export function requestPasswordReset(rawEmail: string, createdIp: string | null)
|
||||
if (!user) {
|
||||
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
|
||||
}
|
||||
// SSO-linked account — refuse a reset. OIDC users are created with a random
|
||||
// bcrypt hash (so password_hash is never empty), which is why we must key off
|
||||
// oidc_sub rather than a missing hash. Letting the reset proceed would set a
|
||||
// local password and revoke session/credential state, which breaks the SSO
|
||||
// login; admins (or the user, with their current password) can still set one.
|
||||
// OIDC-only account (no local password) — we can't reset what isn't there.
|
||||
// The client still gets the generic "if that email exists…" response.
|
||||
if (user.oidc_sub) {
|
||||
if (!user.password_hash && user.oidc_sub) {
|
||||
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ const dataDir = path.join(__dirname, '../../data');
|
||||
const backupsDir = path.join(dataDir, 'backups');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
|
||||
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
|
||||
// limit only caps the compressed bytes). Generous enough for any real backup.
|
||||
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -190,14 +187,6 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||
let reinitFailed: unknown = null;
|
||||
try {
|
||||
// Check the declared uncompressed size from the central directory and bail
|
||||
// if it exceeds the cap, before extracting anything.
|
||||
const directory = await unzipper.Open.file(zipPath);
|
||||
const claimedSize = directory.files.reduce((sum, f) => sum + (f.uncompressedSize || 0), 0);
|
||||
if (claimedSize > MAX_BACKUP_DECOMPRESSED_SIZE) {
|
||||
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
|
||||
}
|
||||
|
||||
await fs.createReadStream(zipPath)
|
||||
.pipe(unzipper.Extract({ path: extractDir }))
|
||||
.promise();
|
||||
|
||||
@@ -280,11 +280,7 @@ export function updateMembers(id: string | number, tripId: string | number, user
|
||||
return { members, item: updated };
|
||||
}
|
||||
|
||||
export function toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
|
||||
// Resolve the item within the caller's trip before updating.
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
export function toggleMemberPaid(id: string | number, userId: string | number, paid: boolean) {
|
||||
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
|
||||
.run(paid ? 1 : 0, id, userId);
|
||||
|
||||
|
||||
@@ -157,220 +157,6 @@ export function deleteDay(id: string | number) {
|
||||
db.prepare('DELETE FROM days WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day reorder / insert (#589)
|
||||
//
|
||||
// Reordering keeps every day ROW stable (so assignments, notes, accommodations,
|
||||
// photos and multi-day reservation positions ride along by id) and only changes
|
||||
// each row's day_number — its position. On a dated trip the calendar dates stay
|
||||
// pinned to their slots (position i keeps the i-th date) and the day's content
|
||||
// moves across them. Because a booking's day is derived from the date part of
|
||||
// reservation_time, every booking on a day whose date changed gets that date
|
||||
// re-stamped onto the day's new date (time-of-day preserved), so day_id stays
|
||||
// consistent and the booking moves with its day.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function addDays(date: string, n: number): string {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const t = Date.UTC(y, m - 1, d) + n * MS_PER_DAY;
|
||||
const dt = new Date(t);
|
||||
const yyyy = dt.getUTCFullYear();
|
||||
const mm = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(dt.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function dayDelta(from: string, to: string): number {
|
||||
const [fy, fm, fd] = from.split('-').map(Number);
|
||||
const [ty, tm, td] = to.split('-').map(Number);
|
||||
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/** Replace the date part of an ISO-ish timestamp, keeping any time suffix. */
|
||||
function withDatePart(timestamp: string, date: string): string {
|
||||
return date + (timestamp.length > 10 ? timestamp.slice(10) : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* After day dates have been re-pinned, re-stamp the date of every booking on a
|
||||
* moved day so reservation_time/reservation_end_time follow their day's new
|
||||
* date (time-of-day preserved). Transport endpoints (flight legs) shift by the
|
||||
* same per-booking day delta so multi-leg timing stays internally consistent.
|
||||
*/
|
||||
function restampReservationDates(
|
||||
tripId: string | number,
|
||||
oldDateById: Map<number, string | null>,
|
||||
newDateById: Map<number, string | null>,
|
||||
): void {
|
||||
const reservations = db.prepare(
|
||||
'SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?'
|
||||
).all(tripId) as {
|
||||
id: number; day_id: number | null; end_day_id: number | null;
|
||||
reservation_time: string | null; reservation_end_time: string | null;
|
||||
}[];
|
||||
|
||||
const setTime = db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
|
||||
const setEndTime = db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?');
|
||||
const endpoints = db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?');
|
||||
const setEndpointDate = db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?');
|
||||
|
||||
for (const r of reservations) {
|
||||
if (r.day_id != null && r.reservation_time) {
|
||||
const oldDate = oldDateById.get(r.day_id);
|
||||
const newDate = newDateById.get(r.day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setTime.run(withDatePart(r.reservation_time, newDate), r.id);
|
||||
// Shift each transport leg's local_date by the same number of days.
|
||||
const delta = dayDelta(oldDate, newDate);
|
||||
if (delta !== 0) {
|
||||
for (const ep of endpoints.all(r.id) as { id: number; local_date: string | null }[]) {
|
||||
if (ep.local_date) setEndpointDate.run(addDays(ep.local_date, delta), ep.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.end_day_id != null && r.reservation_end_time) {
|
||||
const oldDate = oldDateById.get(r.end_day_id);
|
||||
const newDate = newDateById.get(r.end_day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setEndTime.run(withDatePart(r.reservation_end_time, newDate), r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A stay must not end before it begins after a reorder/insert. */
|
||||
function assertNoInvertedAccommodation(tripId: string | number): void {
|
||||
const spans = db.prepare(`
|
||||
SELECT a.id, s.day_number AS start_no, e.day_number AS end_no
|
||||
FROM day_accommodations a
|
||||
JOIN days s ON a.start_day_id = s.id
|
||||
JOIN days e ON a.end_day_id = e.id
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId) as { id: number; start_no: number; end_no: number }[];
|
||||
for (const span of spans) {
|
||||
if (span.start_no > span.end_no) {
|
||||
throw new DayReorderError('This move would make an accommodation end before it starts.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */
|
||||
export class DayReorderError extends Error {}
|
||||
|
||||
/**
|
||||
* Reorder whole days. `orderedIds` is the desired full sequence of this trip's
|
||||
* day ids (a permutation of the current ids).
|
||||
*/
|
||||
export function reorderDays(tripId: string | number, orderedIds: number[]) {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
|
||||
).all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
|
||||
const existingIds = new Set(rows.map(r => r.id));
|
||||
if (orderedIds.length !== rows.length || !orderedIds.every(id => existingIds.has(id))) {
|
||||
throw new DayReorderError('orderedIds must be a permutation of the trip day ids.');
|
||||
}
|
||||
|
||||
const oldDateById = new Map(rows.map(r => [r.id, r.date]));
|
||||
// Dates stay pinned to slots: position i keeps the i-th date (ascending).
|
||||
const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort();
|
||||
const isDated = sortedDates.length > 0;
|
||||
|
||||
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
// Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions.
|
||||
orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id));
|
||||
const newDateById = new Map<number, string | null>();
|
||||
orderedIds.forEach((id, i) => {
|
||||
const date = isDated ? (sortedDates[i] ?? null) : null;
|
||||
setDayNumberAndDate.run(i + 1, date, id);
|
||||
newDateById.set(id, date);
|
||||
});
|
||||
|
||||
if (isDated) restampReservationDates(tripId, oldDateById, newDateById);
|
||||
assertNoInvertedAccommodation(tripId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
|
||||
return listDays(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new empty day at a 1-based position (default: append at the end).
|
||||
* On a dated trip the trip gains one calendar day: dates re-pin so the slots
|
||||
* stay contiguous, the trip's end_date extends by one day, and bookings on
|
||||
* shifted days have their dates re-stamped (same rules as reorderDays).
|
||||
*/
|
||||
export function insertDay(tripId: string | number, position?: number) {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
|
||||
).all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
const n = rows.length;
|
||||
const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1);
|
||||
const datedRows = rows.filter(r => r.date) as { id: number; day_number: number; date: string }[];
|
||||
const isDated = datedRows.length > 0;
|
||||
|
||||
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
|
||||
if (!isDated) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const toShift = rows.filter(r => r.day_number >= pos);
|
||||
toShift.forEach(r => setDayNumber.run(-r.day_number, r.id));
|
||||
const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, pos);
|
||||
toShift.forEach(r => setDayNumber.run(r.day_number + 1, r.id));
|
||||
db.exec('COMMIT');
|
||||
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day;
|
||||
return { ...day, assignments: [], notes_items: [] };
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Dated trip: rebuild N+1 contiguous dates from the earliest date.
|
||||
const start = datedRows.map(r => r.date).sort()[0];
|
||||
const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i));
|
||||
const oldDateById = new Map(rows.map(r => [r.id, r.date]));
|
||||
const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id));
|
||||
const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, pos, dates[pos - 1]);
|
||||
const newId = Number(result.lastInsertRowid);
|
||||
|
||||
const orderedIds = rows.map(r => r.id);
|
||||
orderedIds.splice(pos - 1, 0, newId);
|
||||
const newDateById = new Map<number, string | null>();
|
||||
orderedIds.forEach((id, i) => {
|
||||
setDayNumberAndDate.run(i + 1, dates[i], id);
|
||||
newDateById.set(id, dates[i]);
|
||||
});
|
||||
|
||||
restampReservationDates(tripId, oldDateById, newDateById);
|
||||
assertNoInvertedAccommodation(tripId);
|
||||
db.prepare('UPDATE trips SET end_date = ? WHERE id = ?').run(dates[dates.length - 1], tripId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(newId) as Day;
|
||||
return { ...day, assignments: [], notes_items: [] };
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accommodation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -568,18 +568,8 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
// Allow-list the columns a client may set: keys come from the request body
|
||||
// and are interpolated as SQL column names, so restrict them to the known
|
||||
// entry fields. Keep this in sync with the data type above.
|
||||
const allowed = new Set([
|
||||
'type', 'title', 'story', 'entry_date', 'entry_time',
|
||||
'location_name', 'location_lat', 'location_lng',
|
||||
'mood', 'weather', 'tags', 'pros_cons', 'visibility', 'sort_order',
|
||||
]);
|
||||
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (val === undefined) continue;
|
||||
if (!allowed.has(key)) continue;
|
||||
if (key === 'tags') {
|
||||
fields.push('tags = ?');
|
||||
values.push(Array.isArray(val) ? JSON.stringify(val) : val);
|
||||
|
||||
@@ -84,8 +84,10 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
|
||||
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||
WHERE tkp.asset_id = ? AND gp.journey_id = ?
|
||||
`).get(assetId, row.journey_id) as any;
|
||||
// Only resolve assets that actually belong to this shared journey.
|
||||
if (!photo) return null;
|
||||
if (!photo) {
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { ownerId: journey.user_id } : null;
|
||||
}
|
||||
return { ownerId: photo.owner_id };
|
||||
}
|
||||
|
||||
@@ -135,45 +137,13 @@ export function getPublicJourney(token: string) {
|
||||
photos: photosByEntry[e.id] || [],
|
||||
}));
|
||||
|
||||
// Stats are derived from the full data so the overview pills stay accurate
|
||||
// even when a section is hidden.
|
||||
// Stats
|
||||
const stats = {
|
||||
entries: entries.length,
|
||||
photos: gallery.length,
|
||||
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
};
|
||||
|
||||
const shareTimeline = !!row.share_timeline;
|
||||
const shareGallery = !!row.share_gallery;
|
||||
const shareMap = !!row.share_map;
|
||||
|
||||
// Honour the share flags server-side so the API only returns the sections the
|
||||
// owner enabled (the client gates these too, but it must not rely on that).
|
||||
let publicEntries: Record<string, unknown>[] = [];
|
||||
if (shareTimeline) {
|
||||
// Include the full entry, but drop GPS unless the map is shared and inline
|
||||
// photos unless the gallery is shared.
|
||||
publicEntries = enrichedEntries.map(e => {
|
||||
const projected: Record<string, unknown> = { ...e };
|
||||
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
|
||||
if (!shareGallery) projected.photos = [];
|
||||
return projected;
|
||||
});
|
||||
} else if (shareMap) {
|
||||
// Map-only share: just enough to plot markers, no story/photos/mood.
|
||||
publicEntries = enrichedEntries.map(e => ({
|
||||
id: e.id,
|
||||
journey_id: e.journey_id,
|
||||
type: e.type,
|
||||
entry_date: e.entry_date,
|
||||
title: e.title,
|
||||
location_name: e.location_name,
|
||||
location_lat: e.location_lat,
|
||||
location_lng: e.location_lng,
|
||||
sort_order: e.sort_order,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
journey: {
|
||||
title: journey.title,
|
||||
@@ -181,13 +151,13 @@ export function getPublicJourney(token: string) {
|
||||
cover_image: journey.cover_image,
|
||||
status: journey.status,
|
||||
},
|
||||
entries: publicEntries,
|
||||
gallery: shareGallery ? gallery : [],
|
||||
entries: enrichedEntries,
|
||||
gallery,
|
||||
stats,
|
||||
permissions: {
|
||||
share_timeline: shareTimeline,
|
||||
share_gallery: shareGallery,
|
||||
share_map: shareMap,
|
||||
share_timeline: !!row.share_timeline,
|
||||
share_gallery: !!row.share_gallery,
|
||||
share_map: !!row.share_map,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,24 +70,6 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
|
||||
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
|
||||
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
|
||||
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
|
||||
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
|
||||
// opening hours come back in the wrong language. Codes not listed here pass
|
||||
// through unchanged (they are already valid), as do locale forms the client
|
||||
// sometimes sends (e.g. 'pt-BR').
|
||||
const API_LANG_OVERRIDES: Record<string, string> = {
|
||||
br: 'pt-BR',
|
||||
gr: 'el',
|
||||
'el-GR': 'el',
|
||||
};
|
||||
function toApiLang(lang: string | undefined, fallback = 'en'): string {
|
||||
const code = (lang || '').trim();
|
||||
if (!code) return fallback;
|
||||
return API_LANG_OVERRIDES[code] ?? code;
|
||||
}
|
||||
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
@@ -133,7 +115,7 @@ export async function searchNominatim(query: string, lang?: string) {
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
limit: '10',
|
||||
'accept-language': toApiLang(lang),
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
@@ -166,7 +148,7 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
|
||||
const params = new URLSearchParams({
|
||||
osm_ids: `${typePrefix}${osmId}`,
|
||||
format: 'json',
|
||||
'accept-language': toApiLang(lang),
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
||||
@@ -204,114 +186,6 @@ export async function fetchOverpassDetails(osmType: string, osmId: string): Prom
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Overpass POI search (by category within a viewport bbox) ─────────────────
|
||||
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
|
||||
// calls Google, even when a Google key is configured.
|
||||
|
||||
export interface OverpassPoi {
|
||||
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
category: string; // the requested pill category key, e.g. 'restaurant'
|
||||
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
|
||||
address: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
opening_hours: string | null;
|
||||
cuisine: string | null;
|
||||
source: 'openstreetmap';
|
||||
}
|
||||
|
||||
// Each pill category → the OSM tag selectors it searches. Keys here are the
|
||||
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
|
||||
// client-side).
|
||||
const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
|
||||
cafe: ['amenity=cafe'],
|
||||
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
||||
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
||||
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
|
||||
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
||||
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
||||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||||
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
||||
supermarket: ['shop=supermarket', 'shop=convenience'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
interface OverpassPoiElement {
|
||||
type: string;
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
limit = 60,
|
||||
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
|
||||
const filters = CATEGORY_OSM_FILTERS[category];
|
||||
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
|
||||
let elements: OverpassPoiElement[] = [];
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[] };
|
||||
elements = data.elements || [];
|
||||
} catch (err: any) {
|
||||
if (err?.status) throw err;
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
}
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
const name = tags.name || tags['name:en'] || tags.brand || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched = filters.find(f => { const [k, v] = f.split('='); return tags[k] === v; }) || filters[0];
|
||||
const addr = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: tags.website || tags['contact:website'] || null,
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > limit;
|
||||
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
|
||||
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
||||
@@ -465,7 +339,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
||||
},
|
||||
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
|
||||
body: JSON.stringify({ textQuery: query, languageCode: lang || 'en' }),
|
||||
});
|
||||
|
||||
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
@@ -507,7 +381,7 @@ export async function autocompletePlaces(
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
input,
|
||||
languageCode: toApiLang(lang),
|
||||
languageCode: lang || 'en',
|
||||
};
|
||||
if (locationBias) {
|
||||
body.locationBias = {
|
||||
@@ -598,7 +472,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
}
|
||||
|
||||
// Google details
|
||||
const langKey = toApiLang(lang, 'de');
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) {
|
||||
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
@@ -658,7 +532,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
}
|
||||
|
||||
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
||||
const langKey = toApiLang(lang, 'de');
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
|
||||
@@ -754,93 +628,90 @@ export async function getPlacePhoto(
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
|
||||
// (right-click) places and as a fallback when a Google place yields no photo,
|
||||
// so a place added via search still gets a marker image when Google returns
|
||||
// nothing. Returns null (without marking an error) so the caller decides.
|
||||
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
if (isNaN(lat) || isNaN(lng)) return null;
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (!wiki) return null;
|
||||
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
|
||||
// host) is re-validated against the SSRF guard, not just the first URL.
|
||||
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
||||
if (!imgRes.ok) return null;
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
} catch {
|
||||
return null;
|
||||
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
// Wikimedia photos: fetch bytes and cache to disk. Follow redirects
|
||||
// manually so each hop (the image URL can 3xx to a CDN host) is
|
||||
// re-validated against the SSRF guard, not just the first URL.
|
||||
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
||||
if (imgRes.ok) {
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Google Places photo for a Google place_id. Returns null (without marking an
|
||||
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
|
||||
// a failed media download — so the caller can fall back to Wikimedia.
|
||||
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
|
||||
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
|
||||
|
||||
// Fetch details to get the photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const body = await detailsRes.text();
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
||||
catch { return null; }
|
||||
if (!details.photos?.length) return null;
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
if (!mediaRes.ok) return null;
|
||||
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) return null;
|
||||
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
};
|
||||
|
||||
// Prefer the Google photo (higher quality); if Google yields nothing, fall
|
||||
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
|
||||
// places use. Coordinate-only ids skip Google entirely.
|
||||
if (!isCoordLookup) {
|
||||
const googlePhoto = await fetchGooglePhoto();
|
||||
if (googlePhoto) return googlePhoto;
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallback = await fetchWikimediaFallback();
|
||||
if (fallback) return fallback;
|
||||
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
|
||||
if (/^https?:\/\//i.test(placeId)) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
// Google Photos — fetch details to get photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const body = await detailsRes.text();
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
||||
catch { placePhotoCache.markError(placeId); return null; }
|
||||
|
||||
if (!details.photos?.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
|
||||
if (!mediaRes.ok) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
} finally {
|
||||
releasePhotoFetchSlot();
|
||||
}
|
||||
@@ -858,7 +729,7 @@ export async function getPlacePhoto(
|
||||
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
|
||||
const params = new URLSearchParams({
|
||||
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
||||
'accept-language': toApiLang(lang),
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user