Compare commits

..

3 Commits

Author SHA1 Message Date
Maurice ef191ae7dc i18n(auth): passkey strings across all locales
Add login/settings/admin passkey keys to en and all 19 translated locales.
2026-06-05 18:46:23 +02:00
Maurice 7471976c9a feat(auth): passkey enrolment, login button + admin settings UI
PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.
2026-06-05 18:46:23 +02:00
Maurice 5b8c61d215 feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle
Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.
2026-06-05 18:46:22 +02:00
271 changed files with 2022 additions and 6740 deletions
+2 -7
View File
@@ -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
-33
View File
@@ -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.
-7
View File
@@ -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.
+1 -6
View File
@@ -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());
});
+2 -15
View File
@@ -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
+4 -71
View File
@@ -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}
-51
View File
@@ -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 ──────────────────────────────────────────────────────
+13 -76
View File
@@ -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'
}
+35 -33
View File
@@ -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');
});
});
+2 -7
View File
@@ -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)
-17
View File
@@ -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(
+4 -17
View File
@@ -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()
}
})
+40 -140
View File
@@ -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>
)
})()}
+106 -226
View File
@@ -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>
)
}
-18
View File
@@ -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). */
+8 -25
View File
@@ -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('|')
+145 -51
View File
@@ -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 />);
+4 -20
View File
@@ -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>
+7 -8
View File
@@ -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
}
+3 -38
View File
@@ -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,
+1 -3
View File
@@ -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 })
}
-2
View File
@@ -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,
-58
View File
@@ -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())
}
-4
View File
@@ -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),
-17
View File
@@ -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; }
-8
View File
@@ -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
+4 -4
View File
@@ -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'])
})
})
+5 -86
View File
@@ -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)
}
-73
View File
@@ -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 -27
View File
@@ -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,
+9 -37
View File
@@ -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
-105
View File
@@ -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
View File
@@ -1 +0,0 @@
.atlas-geo-cache/
Binary file not shown.
Binary file not shown.
-225
View File
@@ -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) })
-91
View File
@@ -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) {
-20
View File
@@ -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) }] };
}
+1 -7
View File
@@ -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 });
+2 -8
View File
@@ -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 });
}
+1 -11
View File
@@ -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 });
+1 -11
View File
@@ -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);
+1 -14
View File
@@ -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 });
+1 -7
View File
@@ -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) {
+1 -6
View File
@@ -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 };
+1 -7
View File
@@ -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 });
+1 -4
View File
@@ -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 });
+1 -6
View File
@@ -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 });
}
+1 -5
View File
@@ -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;
+1 -3
View File
@@ -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 -2
View File
@@ -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());
-5
View File
@@ -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);
}
+2 -6
View File
@@ -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;
+1 -1
View File
@@ -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 };
}
+2 -2
View File
@@ -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 }[]) {
+3 -35
View File
@@ -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,
-8
View File
@@ -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');
-21
View File
@@ -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(
-6
View File
@@ -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);
}
}
-16
View File
@@ -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);
}
-25
View File
@@ -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);
-1
View File
@@ -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); }
}
+21 -34
View File
@@ -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()));
+7 -30
View File
@@ -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' };
}
+1 -12
View File
@@ -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();
+1 -5
View File
@@ -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);
-214
View File
@@ -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
// ---------------------------------------------------------------------------
-10
View File
@@ -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);
+10 -40
View File
@@ -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,
},
};
}
+87 -216
View File
@@ -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