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
419 changed files with 2533 additions and 12988 deletions
-1
View File
@@ -34,5 +34,4 @@ jobs:
command: cves command: cves
image: trek:scan image: trek:scan
only-severities: critical,high only-severities: critical,high
only-fixed: true
exit-code: true exit-code: true
+4 -22
View File
@@ -1,10 +1,3 @@
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
# ── Stage 1: shared ────────────────────────────────────────────────────────── # ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder FROM node:24-alpine AS shared-builder
WORKDIR /app WORKDIR /app
@@ -51,12 +44,12 @@ COPY server/package.json ./server/
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck) # amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary) # arm64 — apt package (KDE publishes no arm64 static binary)
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \ apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
npm ci --workspace=server --omit=dev && \ npm ci --workspace=server --omit=dev && \
ARCH=$(dpkg --print-architecture) && \ ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \ 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 && \ 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 "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \ echo "b7058d98990053c7b61847fef0c21e02d59b60e323e2b171ca210b682334e801 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \ tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \ rm /tmp/ki.tgz; \
else \ else \
@@ -67,9 +60,6 @@ RUN apt-get update && \
apt-get autoremove -y && \ apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache ENV XDG_CACHE_HOME=/tmp/kf6-cache
# Prevent Qt from probing for a display in headless containers. # Prevent Qt from probing for a display in headless containers.
ENV QT_QPA_PLATFORM=offscreen ENV QT_QPA_PLATFORM=offscreen
@@ -78,11 +68,6 @@ ENV QT_QPA_PLATFORM=offscreen
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist 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. # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/ COPY server/tsconfig.json ./server/
COPY --from=shared-builder /app/shared/dist ./shared/dist COPY --from=shared-builder /app/shared/dist ./shared/dist
@@ -105,8 +90,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1 CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly. # cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"] CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
-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.
-10
View File
@@ -311,9 +311,6 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it. Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3> <h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`): If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
@@ -440,13 +437,6 @@ Caddy handles TLS and WebSockets automatically.
<br /> <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 ## 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. 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.
+5 -26
View File
@@ -18,7 +18,7 @@ import {
type TripAddMemberRequest, type AssignmentReorderRequest, type TripAddMemberRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, type DayCreateRequest, type DayUpdateRequest,
type PlaceCreateRequest, type PlaceUpdateRequest, type PlaceCreateRequest, type PlaceUpdateRequest,
type ReservationCreateRequest, type ReservationUpdateRequest, type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest, 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), 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), 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), 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 = { export const placesApi = {
@@ -366,10 +365,10 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
}, },
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) => importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) => importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) => bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
} }
@@ -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), 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), 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), 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), 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), 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), 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),
@@ -487,20 +485,6 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data), enabled: () => apiClient.get('/addons').then(r => r.data),
} }
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
}
export const journeyApi = { export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data), list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data), create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
@@ -572,11 +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')), 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')), 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')), 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.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
} }
export const airportsApi = { export const airportsApi = {
-6
View File
@@ -20,12 +20,6 @@ export function getSocketId(): string | null {
return mySocketId return mySocketId
} }
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
* trip's store after the network comes back via the `online` event. */
export function getActiveTrips(): string[] {
return Array.from(activeTrips)
}
export function setRefetchCallback(fn: RefetchCallback | null): void { export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn refetchCallback = fn
} }
+2 -2
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
const ICON_MAP = { const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
} }
function ImmichIcon({ size = 14 }: { size?: number }) { function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -22,22 +22,8 @@ type Defaults = {
time_format?: string time_format?: string
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_tile_url?: string 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({ function OptionRow({
label, label,
hint, hint,
@@ -91,15 +77,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({}) const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('') const [mapTileUrl, setMapTileUrl] = useState('')
const [mapboxToken, setMapboxToken] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => { useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => { adminApi.getDefaultUserSettings().then((data: Defaults) => {
setDefaults(data) setDefaults(data)
setMapTileUrl(data.map_tile_url || '') setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(data.mapbox_style || '')
setLoaded(true) setLoaded(true)
}).catch(() => setLoaded(true)) }).catch(() => setLoaded(true))
}, []) }, [])
@@ -119,8 +101,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null }) const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated) setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('') if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style') setMapboxStyle('')
toast.success(t('admin.defaultSettings.reset')) toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error')) toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -287,94 +267,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
})} })}
</div> </div>
</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> </Section>
) )
} }
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
expect(document.body).toBeInTheDocument(); 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(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/trips/1/collab/notes', () => http.get('/api/trips/1/collab/notes', () =>
@@ -193,11 +193,8 @@ describe('CollabNotes', () => {
); );
render(<CollabNotes {...defaultProps} />); render(<CollabNotes {...defaultProps} />);
await screen.findByText('Remove Me'); await screen.findByText('Remove Me');
await user.click(screen.getByTitle('Delete')); const deleteBtn = screen.getByTitle('Delete');
// Deleting now asks for confirmation first — the note stays until confirmed. await user.click(deleteBtn);
expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument()); 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 { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import type { User } from '../../types' import type { User } from '../../types'
import type { CollabNote } from './CollabNotes.types' import type { CollabNote } from './CollabNotes.types'
import { FONT, NOTE_COLORS } from './CollabNotes.constants' import { FONT, NOTE_COLORS } from './CollabNotes.constants'
@@ -45,7 +44,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const [previewFile, setPreviewFile] = useState(null) const [previewFile, setPreviewFile] = useState(null)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [activeCategory, setActiveCategory] = useState(null) const [activeCategory, setActiveCategory] = useState(null)
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(null)
// Empty categories (no notes yet) stored in localStorage // Empty categories (no notes yet) stored in localStorage
const [emptyCategories, setEmptyCategories] = useState(() => { const [emptyCategories, setEmptyCategories] = useState(() => {
@@ -233,7 +231,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
activeCategory, setActiveCategory, categoryColors, getCategoryColor, activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit, handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes, handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
pendingDeleteNoteId, setPendingDeleteNoteId,
} }
} }
@@ -322,7 +319,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
function CollabNotesGrid(S: NotesState) { function CollabNotesGrid(S: NotesState) {
const { const {
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId, sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t, setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
} = S } = S
return ( return (
@@ -355,7 +352,7 @@ function CollabNotesGrid(S: NotesState) {
currentUser={currentUser} currentUser={currentUser}
canEdit={canEdit} canEdit={canEdit}
onUpdate={handleUpdateNote} onUpdate={handleUpdateNote}
onDelete={setPendingDeleteNoteId} onDelete={handleDeleteNote}
onEdit={setEditingNote} onEdit={setEditingNote}
onView={setViewingNote} onView={setViewingNote}
onPreviewFile={setPreviewFile} onPreviewFile={setPreviewFile}
@@ -473,7 +470,6 @@ export default function CollabNotes(props: CollabNotesProps) {
viewingNote, showNewModal, editingNote, previewFile, showSettings, viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings, setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote, handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
} = S } = S
if (loading) return <CollabNotesLoading {...S} /> if (loading) return <CollabNotesLoading {...S} />
@@ -531,15 +527,6 @@ export default function CollabNotes(props: CollabNotesProps) {
t={t} 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> </div>
) )
} }
@@ -16,7 +16,7 @@ interface NoteCardProps {
currentUser: User currentUser: User
canEdit: boolean canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void> onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => void onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void onPreviewFile: (file: NoteFile) => void
@@ -1,42 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { render } from '../../../tests/helpers/render'
import OfflineBanner from './OfflineBanner'
vi.mock('../../sync/mutationQueue', () => ({
mutationQueue: {
pendingCount: vi.fn(),
failedCount: vi.fn(),
},
}))
import { mutationQueue } from '../../sync/mutationQueue'
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
afterEach(() => {
vi.clearAllMocks()
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
describe('OfflineBanner (B3 surface)', () => {
it('shows the failed pill when failedCount > 0 while online', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(2)
render(<OfflineBanner />)
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending or failed', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
const { container } = render(<OfflineBanner />)
// Give the async poll a tick to resolve.
await waitFor(() => expect(failedCount).toHaveBeenCalled())
expect(container.querySelector('[role="status"]')).toBeNull()
})
})
+13 -27
View File
@@ -2,7 +2,6 @@
* OfflineBanner — connectivity + sync state indicator. * OfflineBanner — connectivity + sync state indicator.
* *
* States: * States:
* N failed → red pill "N changes failed to sync" (takes priority)
* offline + N queued → amber pill "Offline · N queued" * offline + N queued → amber pill "Offline · N queued"
* offline + 0 queued → amber pill "Offline" * offline + 0 queued → amber pill "Offline"
* online + N pending → blue pill "Syncing N…" * online + N pending → blue pill "Syncing N…"
@@ -13,7 +12,7 @@
* headers. On mobile it hovers just above the bottom tab bar. * headers. On mobile it hovers just above the bottom tab bar.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react' import { WifiOff, RefreshCw } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue' import { mutationQueue } from '../../sync/mutationQueue'
const POLL_MS = 3_000 const POLL_MS = 3_000
@@ -21,7 +20,6 @@ const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null { export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0) const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
useEffect(() => { useEffect(() => {
const onOnline = () => setIsOnline(true) const onOnline = () => setIsOnline(true)
@@ -37,36 +35,26 @@ export default function OfflineBanner(): React.ReactElement | null {
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function poll() { async function poll() {
const [n, failed] = await Promise.all([ const n = await mutationQueue.pendingCount()
mutationQueue.pendingCount(), if (!cancelled) setPendingCount(n)
mutationQueue.failedCount(),
])
if (!cancelled) {
setPendingCount(n)
setFailedCount(failed)
}
} }
poll() poll()
const id = setInterval(poll, POLL_MS) const id = setInterval(poll, POLL_MS)
return () => { cancelled = true; clearInterval(id) } return () => { cancelled = true; clearInterval(id) }
}, []) }, [])
const hidden = isOnline && pendingCount === 0 && failedCount === 0 const hidden = isOnline && pendingCount === 0
if (hidden) return null if (hidden) return null
const offline = !isOnline const offline = !isOnline
// Failed mutations are the most important signal — they mean data was dropped. const bg = offline ? '#92400e' : '#1e40af'
const failed = failedCount > 0
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
const text = '#fff' const text = '#fff'
const label = failed const label = offline
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync` ? pendingCount > 0
: offline ? `Offline · ${pendingCount} queued`
? pendingCount > 0 : 'Offline'
? `Offline · ${pendingCount} queued` : `Syncing ${pendingCount}`
: 'Offline'
: `Syncing ${pendingCount}`
return ( return (
<div <div
@@ -94,11 +82,9 @@ export default function OfflineBanner(): React.ReactElement | null {
pointerEvents: 'none', pointerEvents: 'none',
}} }}
> >
{failed {offline
? <AlertTriangle size={12} /> ? <WifiOff size={12} />
: offline : <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
} }
{label} {label}
</div> </div>
@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react'
import { Navigation } from 'lucide-react'
import type mapboxgl from 'mapbox-gl'
/**
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
* pitched, so this shows the current bearing (the arrow points to north) and snaps
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
const update = () => setBearing(map.getBearing())
update()
map.on('rotate', update)
return () => { map.off('rotate', update) }
}, [map])
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
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))',
}}>
<button
type="button"
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
aria-label="Reset north"
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
</button>
</div>
)
}
+4 -71
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react' import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server' 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 MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.css'
@@ -10,7 +10,6 @@ import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons' import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import ReservationOverlay from './ReservationOverlay' import ReservationOverlay from './ReservationOverlay'
import type { Reservation } from '../../types' import type { Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
function categoryIconSvg(iconName: string | null | undefined, size: number): string { function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -119,44 +118,6 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
return fallbackIcon 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 { interface SelectionControllerProps {
places: Place[] places: Place[]
selectedPlaceId: number | null selectedPlaceId: number | null
@@ -170,21 +131,10 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
useEffect(() => { useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) { if (selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom. Offset the centre by the // Pan to the selected place without changing zoom
// 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.
const selected = places.find(p => p.id === selectedPlaceId) const selected = places.find(p => p.id === selectedPlaceId)
if (selected?.lat != null && selected?.lng != null) { if (selected?.lat && selected?.lng) {
const latlng: [number, number] = [selected.lat, selected.lng] map.panTo([selected.lat, selected.lng], { animate: true })
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 })
}
} }
} }
prev.current = selectedPlaceId prev.current = selectedPlaceId
@@ -406,21 +356,7 @@ export const MapView = memo(function MapView({
showReservationStats = false, showReservationStats = false,
visibleConnectionIds = [] as number[], visibleConnectionIds = [] as number[],
onReservationClick, onReservationClick,
pois = [] as Poi[],
onPoiClick,
onViewportChange,
}: any) { }: 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(() => { const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [] if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds) const set = new Set(visibleConnectionIds)
@@ -596,7 +532,6 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} /> <MapContextMenuHandler onContextMenu={onMapContextMenu} />
<ViewportController onViewportChange={onViewportChange} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} /> <LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup <MarkerClusterGroup
@@ -637,8 +572,6 @@ export const MapView = memo(function MapView({
showStats={showReservationStats} showStats={showReservationStats}
onEndpointClick={onReservationClick} onEndpointClick={onReservationClick}
/> />
{poiMarkers}
</MapContainer> </MapContainer>
{isMobile && <LocationButton {isMobile && <LocationButton
mode={trackingMode} mode={trackingMode}
@@ -5,11 +5,6 @@ import { MapViewGL } from './MapViewGL'
// Auto-selects the map renderer based on user settings. Keeps the existing // Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly. // behind a toggle. Atlas is not affected — it imports Leaflet directly.
//
// Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
// vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) { export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider) const provider = useSettingsStore(s => s.settings.map_provider)
@@ -40,12 +40,6 @@ vi.mock('mapbox-gl', () => ({
})), })),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })), LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(), NavigationControl: vi.fn(),
Popup: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
})),
}, },
})) }))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})) vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
-85
View File
@@ -12,8 +12,6 @@ import { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types' import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
function categoryIconSvg(iconName: string | null | undefined, size: number): string { function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -51,10 +49,6 @@ interface Props {
visibleConnectionIds?: number[] visibleConnectionIds?: number[]
showReservationStats?: boolean showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void onReservationClick?: (reservationId: number) => void
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
onMapReady?: (map: mapboxgl.Map | null) => void
} }
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -134,17 +128,6 @@ function createMarkerElement(place: Place & { category_color?: string; category_
return wrap 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({ export function MapViewGL({
places = [], places = [],
dayPlaces = [], dayPlaces = [],
@@ -166,10 +149,6 @@ export function MapViewGL({
visibleConnectionIds = [], visibleConnectionIds = [],
showReservationStats = false, showReservationStats = false,
onReservationClick, onReservationClick,
pois = [],
onPoiClick,
onViewportChange,
onMapReady,
}: Props) { }: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard') const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -188,16 +167,6 @@ export function MapViewGL({
// options without forcing a full overlay rebuild on every prop change. // options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick) const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
// Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip.
const popupRef = useRef<mapboxgl.Popup | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation() const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu }) const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick onClickRefs.current.marker = onMarkerClick
@@ -220,16 +189,6 @@ export function MapViewGL({
projection: mapboxQuality ? 'globe' : 'mercator', projection: mapboxQuality ? 'globe' : 'mercator',
}) })
mapRef.current = map mapRef.current = map
popupRef.current = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
offset: 18,
maxWidth: '240px',
className: 'trek-map-popup',
})
// Hand the map out so the trip planner can render its own compass pill next to
// the POI pill (a custom round control instead of Mapbox's default top-right one).
onMapReadyRef.current?.(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).__trek_map = map ;(window as any).__trek_map = map
@@ -301,14 +260,6 @@ export function MapViewGL({
if (t.closest('.mapboxgl-marker')) return // markers handle their own click if (t.closest('.mapboxgl-marker')) return // markers handle their own click
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) 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 // 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 // built-in rotate/pitch gesture, so we bind the "add place" action
// to the middle mouse button (button === 1) instead. // to the middle mouse button (button === 1) instead.
@@ -375,8 +326,6 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick) canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove()) markersRef.current.forEach(m => m.remove())
markersRef.current.clear() markersRef.current.clear()
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
onMapReadyRef.current?.(null)
if (reservationOverlayRef.current) { if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy() reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null reservationOverlayRef.current = null
@@ -450,10 +399,6 @@ export function MapViewGL({
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
if (!map) return if (!map) return
// Markers are about to be rebuilt; drop any open hover popup first. A marker
// recreated under the pointer (e.g. when its photo streams in) never fires
// mouseleave, which would otherwise leave the popup orphaned on the map.
popupRef.current?.remove()
const ids = new Set(places.map(p => p.id)) const ids = new Set(places.map(p => p.id))
markersRef.current.forEach((marker, id) => { markersRef.current.forEach((marker, id) => {
@@ -474,12 +419,6 @@ export function MapViewGL({
ev.stopPropagation() ev.stopPropagation()
onClickRefs.current.marker?.(place.id) onClickRefs.current.marker?.(place.id)
}) })
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([place.lng, place.lat])
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
.addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
// Recreate marker each time rather than patching internal state — // Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps. // mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id) const existing = markersRef.current.get(place.id)
@@ -496,26 +435,6 @@ export function MapViewGL({
}) })
}, [places, selectedPlaceId, dayOrderMap, photoUrls]) }, [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
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
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 // Update route geojson
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
@@ -634,10 +553,6 @@ export function MapViewGL({
zoom: Math.max(map.getZoom(), 14), zoom: Math.max(map.getZoom(), 14),
pitch: mapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
duration: 400, 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 */ } } catch { /* noop */ }
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps }, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -1,101 +0,0 @@
import { RotateCw, AlertTriangle } 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>
/** categories whose last fetch failed → show a retry affordance */
errorKeys?: 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, errorKeys, moved, onSearchArea }: Props) {
const { t } = useTranslation()
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
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={{
position: 'relative',
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} />
)}
{on && !loading && errorKeys?.has(cat.key) && (
<span style={{
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
}} />
)}
</button>
</Tooltip>
)
})}
</div>
{(moved || anyError) && 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',
color: anyError ? '#ef4444' : undefined,
...frosted,
}}
>
{anyError
? <AlertTriangle size={13} strokeWidth={2.4} />
: <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 { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet' import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types' import type { Reservation, ReservationEndpoint } from '../../types'
@@ -43,7 +42,7 @@ function useEndpointPane() {
function endpointIcon(type: TransportType, label: string | null): L.DivIcon { function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type] const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 })) 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 const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
return L.divIcon({ return L.divIcon({
className: 'trek-endpoint-marker', className: 'trek-endpoint-marker',
@@ -54,7 +53,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
border:1.5px solid #fff;color:#fff; 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; 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; 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], iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11], iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11], popupAnchor: [0, -11],
@@ -158,7 +157,6 @@ interface TransportItem {
res: Reservation res: Reservation
from: ReservationEndpoint from: ReservationEndpoint
to: ReservationEndpoint to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType type: TransportType
arcs: [number, number][][] arcs: [number, number][][]
primaryArc: [number, number][] primaryArc: [number, number][]
@@ -174,8 +172,8 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
) + 22 ) + 22
const hasBoth = !!mainLabel && !!subLabel const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22 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 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' : ''}">${escapeHtml(subLabel)}</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=" const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center; display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%; width:100%;height:100%;
@@ -354,29 +352,15 @@ export default function ReservationOverlay({ reservations, showConnections, show
const out: TransportItem[] = [] const out: TransportItem[] = []
for (const r of reservations) { for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two, const eps = r.endpoints || []
// so the arc + markers below are byte-identical to before for it. const from = eps.find(e => e.role === 'from')
const waypoints = (r.endpoints || []) const to = eps.find(e => e.role === 'to')
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop') if (!from || !to) continue
.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 type = r.type as TransportType const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic const isGeo = TYPE_META[type].geodesic
// One arc per leg (between consecutive waypoints), concatenated. const arcs = isGeo
const arcs: [number, number][][] = [] ? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
let distanceKm = 0 : [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
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 primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0) const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? [] const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0 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] : [(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 duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km` const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
// Show the full route (FRA → BER → HND) when every waypoint has a code. const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const mainLabel = waypoints.every(w => w.code)
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code}${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[] const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null 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 return out
}, [reservations]) }, [reservations])
@@ -434,21 +415,38 @@ export default function ReservationOverlay({ reservations, showConnections, show
/> />
)))} )))}
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => ( {visibleItems.flatMap(item => [
<Marker <Marker
key={`wp-${item.res.id}-${wi}`} key={`from-${item.res.id}`}
position={[wp.lat, wp.lng]} position={[item.from.lat, item.from.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)} icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
pane={ENDPOINT_PANE} pane={ENDPOINT_PANE}
zIndexOffset={1000} zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }} eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
> >
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip"> <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>} {item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
</Tooltip> </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[1]).toEqual(c)
expect(result[2]).toEqual(b) 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 ────────────────────────────────────────────────────── // ── 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' 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}` return `https://www.google.com/maps/dir/${stops}`
} }
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine. /** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
function sqDist(a: Waypoint, b: Waypoint): number { export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2 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 visited = new Set<number>()
const result: T[] = [] const result: T[] = []
let current: Waypoint let current = valid[0]
if (start) { visited.add(0)
current = start result.push(current)
} else {
current = valid[0]
visited.add(0)
result.push(valid[0])
}
while (result.length < valid.length) { while (result.length < valid.length) {
let nearestIdx = -1 let nearestIdx = -1
let minDist = Infinity let minDist = Infinity
for (let i = 0; i < valid.length; i++) { for (let i = 0; i < valid.length; i++) {
if (visited.has(i)) continue 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 (d < minDist) { minDist = d; nearestIdx = i }
} }
if (nearestIdx === -1) break if (nearestIdx === -1) break
visited.add(nearestIdx) visited.add(nearestIdx)
current = valid[nearestIdx] current = valid[nearestIdx]
result.push(valid[nearestIdx]) result.push(current)
} }
return result 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). */ /** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
export async function calculateSegments( export async function calculateSegments(
waypoints: Waypoint[], waypoints: Waypoint[],
-68
View File
@@ -1,68 +0,0 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import type { Place } from '../../types'
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
// no equivalent, so these produce the same card as an HTML string for a
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
type PlaceWithCategory = Place & {
category_color?: string | null
category_icon?: string | null
category_name?: string | null
}
function esc(s: string | null | undefined): string {
if (!s) return ''
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Render a lucide category icon to an inline SVG string in the given colour.
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
try {
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
} catch {
return ''
}
}
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
// into an <img src> — everything else is a fetch seed, not a displayable URL.
function isDisplayablePhoto(url: string | null | undefined): url is string {
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
}
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
const img = isDisplayablePhoto(photoUrl)
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
: ''
const category =
place.category_name && place.category_icon
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
: ''
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
}
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
export function buildPoiPopupHtml(poi: Poi): string {
const cat = POI_CATEGORY_BY_KEY[poi.category]
const color = cat?.color || '#6b7280'
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
return `${CARD_OPEN}${head}${address}</div>`
}
@@ -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 { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types' import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations' export const RESERVATION_SOURCE_ID = 'trek-reservations'
@@ -126,7 +125,6 @@ interface TransportItem {
res: Reservation res: Reservation
from: ReservationEndpoint from: ReservationEndpoint
to: ReservationEndpoint to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType type: TransportType
arcs: [number, number][][] arcs: [number, number][][]
primaryArc: [number, number][] primaryArc: [number, number][]
@@ -138,38 +136,23 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = [] const out: TransportItem[] = []
for (const r of reservations) { for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two. const eps = r.endpoints || []
const waypoints = (r.endpoints || []) const from = eps.find(e => e.role === 'from')
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop') const to = eps.find(e => e.role === 'to')
.slice() if (!from || !to) continue
.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 type = r.type as TransportType const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic const isGeo = TYPE_META[type].geodesic
// One arc per leg (between consecutive waypoints), concatenated. const arcs = isGeo
const arcs: [number, number][][] = [] ? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
let distanceKm = 0 : [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
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 primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0) const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? [] const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null) const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km` const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = waypoints.every(w => w.code) const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code}${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[] const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null 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 return out
} }
@@ -178,7 +161,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
function endpointMarkerHtml(type: TransportType, label: string | null): string { function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type] const { icon: IconCmp } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 })) 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=" return `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px; display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px; padding:0 8px;border-radius:999px;
@@ -196,8 +179,8 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht
) + 22 ) + 22
const hasBoth = !!mainLabel && !!subLabel const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22 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 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' : ''}">${escapeHtml(subLabel)}</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=" const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center; display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%; width:100%;height:100%;
@@ -337,7 +320,7 @@ export class ReservationMapboxOverlay {
if (show) { if (show) {
for (const item of visibleItems) { for (const item of visibleItems) {
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id) 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 label = showLabel ? (ep.code || cleanName(ep.name)) : null
const el = document.createElement('div') const el = document.createElement('div')
el.innerHTML = endpointMarkerHtml(item.type, label) 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 // ── stats label (flights only) ──────────────────────────────────
// longer drawn; only the connection line and the airport markers remain.
this.statsMarkers.forEach(s => s.marker.remove()) this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = [] 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. // Match the Leaflet overlay's "rotate the label along the arc" look.
-115
View File
@@ -1,115 +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 }
// A request we cancelled on purpose (newer search superseded it) — not a failure.
function isAbortError(err: unknown): boolean {
const e = err as { name?: string; code?: string } | null
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
}
/**
* 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)
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
// the pill can offer a retry instead of looking like "no places here".
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
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
// One in-flight AbortController per category, so re-toggling / re-searching
// cancels the previous (possibly slow) Overpass request instead of racing it.
const abortRef = useRef<Record<string, AbortController>>({})
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 setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
if (on === prev.has(key)) return 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) => {
abortRef.current[key]?.abort()
const ctrl = new AbortController()
abortRef.current[key] = ctrl
setLoading(key, true)
setError(key, false)
try {
const res = await mapsApi.pois(key, bbox, ctrl.signal)
// 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 (err) {
// A superseded request was aborted on purpose — leave its state untouched
// so the newer request owns the spinner and results.
if (isAbortError(err)) return
// A real failure (every Overpass mirror down/timed out): surface it instead
// of a silent empty so the user can retry rather than assume "no places".
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
if (activeRef.current.has(key)) setError(key, true)
} finally {
// Only the latest controller for this key clears the spinner; a superseded
// one must not, or it would hide the newer request's in-flight state.
if (abortRef.current[key] === ctrl) {
setLoading(key, false)
delete abortRef.current[key]
}
}
}, [setLoading, setError])
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)
setErrorKeys(new Set())
// Switching to another category (or turning off) — cancel any in-flight
// fetches so their results can't land after the selection changed.
Object.values(abortRef.current).forEach(c => c.abort())
abortRef.current = {}
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, errorKeys, moved, toggle, searchArea, onViewportChange }
}
@@ -146,20 +146,4 @@ describe('downloadJourneyBookPDF', () => {
expect(html).toContain('Journey Book'); expect(html).toContain('Journey Book');
expect(html).toContain('The End'); 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 // Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { marked } from 'marked' import { marked } from 'marked'
import { sanitizeRichTextHtml } from '@trek/shared'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
function esc(str: string | null | undefined): string { 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 { function md(str: string | null | undefined): string {
if (!str) return '' if (!str) return ''
// marked passes embedded raw HTML through by default, so sanitise the result return marked.parse(str, { async: false, breaks: true }) as string
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
} }
function abs(url: string | null | undefined): string { function abs(url: string | null | undefined): string {
@@ -311,9 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is triggered from the parent via iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html iframe.srcdoc = html
card.appendChild(header) card.appendChild(header)
-17
View File
@@ -259,23 +259,6 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('colosseum.jpg') 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 () => { it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
let photoCalled = false let photoCalled = false
server.use( server.use(
+4 -18
View File
@@ -55,10 +55,6 @@ function absUrl(url) {
function safeImg(url) { function safeImg(url) {
if (!url) return null if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url 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 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 icon = reservationIconSvg(r.type)
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6' const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
let subtitle = '' let subtitle = ''
if (r.type === 'flight') { 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(' · ')
// 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(' · ')
}
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 === '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 === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].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 cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1' const color = cat?.color || '#6366f1'
// Image: direct > google photo > fallback icon. Both go through safeImg // Image: direct > google photo > fallback icon
// so the proxy path is resolved to an absolute URL the PDF can load.
const directImg = safeImg(place.image_url) const directImg = safeImg(place.image_url)
const googleImg = safeImg(photoMap[place.id]) const googleImg = photoMap[place.id] || null
const img = directImg || googleImg const img = directImg || googleImg
const iconSvg = categoryIconSvg(cat?.icon, color, 24) const iconSvg = categoryIconSvg(cat?.icon, color, 24)
@@ -293,7 +282,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''} ${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
</div> </div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''} ${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''} ${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''} ${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''} ${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
@@ -581,9 +569,7 @@ ${daysHtml}
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is parent-initiated), so withhold iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html iframe.srcdoc = html
card.appendChild(header) card.appendChild(header)
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react' import { Package } from 'lucide-react'
import { packingApi } from '../../api/client' import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => { useEffect(() => {
packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {}) adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId]) }, [tripId])
useEffect(() => { useEffect(() => {
@@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; 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'; import PackingListPanel, { itemWeight } from './PackingListPanel';
describe('itemWeight (bag total weight calc)', () => { describe('itemWeight (bag total weight calc)', () => {
@@ -34,10 +34,10 @@ beforeEach(() => {
http.get('/api/trips/:id/packing/category-assignees', () => http.get('/api/trips/:id/packing/category-assignees', () =>
HttpResponse.json({ assignees: {} }) HttpResponse.json({ assignees: {} })
), ),
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: false, addons: [] }) HttpResponse.json({ enabled: false })
), ),
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] }) HttpResponse.json({ templates: [] })
), ),
); );
@@ -381,7 +381,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => { it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
server.use( 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 }] }) 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 () => { it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] }) 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 () => { it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) 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 () => { it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] }) 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 () => { it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
const user = userEvent.setup(); const user = userEvent.setup();
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })]; 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" // Save-as-template button uses FolderPlus icon and "Save as template" text
const saveBtn = screen.getByText('Save as template').closest('button'); const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
expect(saveBtn).toBeTruthy(); expect(folderPlusBtn).toBeTruthy();
// Click to show the name input // Click to show the name input
await user.click(saveBtn!); await user.click(folderPlusBtn!);
// Template name input appears // Template name input appears
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument(); 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 () => { it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( 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 }] }) 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 () => { it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] }) HttpResponse.json({ bags: [] })
@@ -716,7 +706,6 @@ describe('PackingListPanel', () => {
}); });
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => { it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
const user = userEvent.setup(); const user = userEvent.setup();
let savedTemplateName = ''; let savedTemplateName = '';
server.use( server.use(
@@ -725,16 +714,16 @@ describe('PackingListPanel', () => {
savedTemplateName = String(body.name); savedTemplateName = String(body.name);
return HttpResponse.json({ success: true }); return HttpResponse.json({ success: true });
}), }),
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] }) HttpResponse.json({ templates: [] })
) )
); );
const items = [buildPackingItem({ name: 'Item', category: 'Test' })]; 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 // Click the FolderPlus "Save as template" button
const saveBtn = screen.getByText('Save as template').closest('button'); const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
await user.click(saveBtn!); await user.click(folderPlusBtn!);
// Type template name // Type template name
const nameInput = await screen.findByPlaceholderText('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 () => { it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] }) 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 () => { it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) 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; let putBody: Record<string, unknown> | null = null;
const itemId = 120; const itemId = 120;
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] }) HttpResponse.json({ bags: [] })
@@ -872,8 +861,8 @@ describe('PackingListPanel', () => {
const itemId = 130; const itemId = 130;
let putBody: Record<string, unknown> | null = null; let putBody: Record<string, unknown> | null = null;
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] }) 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 () => { it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
const itemId = 140; const itemId = 140;
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] }) 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 () => { it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( 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 }] }) HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
) )
); );
@@ -1135,7 +1124,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup(); const user = userEvent.setup();
let applyCalled = false; let applyCalled = false;
server.use( 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 }] }) HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
), ),
http.post('/api/trips/1/packing/apply-template/5', () => { http.post('/api/trips/1/packing/apply-template/5', () => {
@@ -1188,7 +1177,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup(); const user = userEvent.setup();
let createBody: Record<string, unknown> | null = null; let createBody: Record<string, unknown> | null = null;
server.use( 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) // Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) 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(); const user = userEvent.setup();
let deleteCalled = false; let deleteCalled = false;
server.use( 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', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) 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(); const user = userEvent.setup();
let updateBody: Record<string, unknown> | null = null; let updateBody: Record<string, unknown> | null = null;
server.use( 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', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] }) 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, 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', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] }) 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, 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', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] }) 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 () => { 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; let createBody: Record<string, unknown> | null = null;
server.use( 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.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
http.post('/api/trips/1/packing/bags', async ({ request }) => { http.post('/api/trips/1/packing/bags', async ({ request }) => {
createBody = await request.json() as Record<string, unknown>; createBody = await request.json() as Record<string, unknown>;
@@ -5,7 +5,7 @@ import type { PackingState } from './usePackingListPanel'
export function PackingHeader(S: PackingState) { export function PackingHeader(S: PackingState) {
const { const {
inlineHeader, t, items, abgehakt, fortschritt, canEdit, isAdmin, inlineHeader, t, items, abgehakt, fortschritt, canEdit,
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate, showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef, setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
@@ -26,7 +26,7 @@ export function PackingHeader(S: PackingState) {
</div> </div>
) : <span />} ) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}> <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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input <input
type="text" autoFocus type="text" autoFocus
@@ -97,7 +97,7 @@ export function PackingHeader(S: PackingState) {
)} )}
</div> </div>
)} )}
{inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && ( {inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(true)} style={{ <button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99, 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', border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
@@ -45,7 +45,7 @@ export const KAT_COLORS = [
'#14b8a6', // teal '#14b8a6', // teal
] ]
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b'] export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
// A category's first item is seeded with this sentinel because the server // A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI. // rejects empty names. Treat it as a placeholder in the UI.
@@ -2,11 +2,9 @@ import { useState, useMemo, useRef, useEffect } from 'react'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client' import { packingApi, tripsApi, adminApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem, PackingBag } from '../../types' import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS } from './packingListPanel.constants' import { BAG_COLORS } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers' import { parseImportLines } from './packingListPanel.helpers'
@@ -48,7 +46,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const can = useCanDo() const can = useCanDo()
const trip = useTripStore((s) => s.trip) const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip) const canEdit = can('packing_edit', trip)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const toast = useToast() const toast = useToast()
const { t } = useTranslation() const { t } = useTranslation()
@@ -148,24 +145,19 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
if (failed) toast.error(t('packing.toast.deleteError')) if (failed) toast.error(t('packing.toast.deleteError'))
} }
// Bag tracking — the global toggle is a packing sub-flag surfaced to every // Bag tracking
// authenticated user via the addon store (loaded on app start), not the const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
// 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)
const [bags, setBags] = useState<PackingBag[]>([]) const [bags, setBags] = useState<PackingBag[]>([])
const [newBagName, setNewBagName] = useState('') const [newBagName, setNewBagName] = useState('')
const [showAddBag, setShowAddBag] = useState(false) const [showAddBag, setShowAddBag] = useState(false)
const [showBagModal, setShowBagModal] = useState(false) const [showBagModal, setShowBagModal] = useState(false)
useEffect(() => { useEffect(() => {
if (!addonsLoaded) loadAddons() adminApi.getBagTracking().then(d => {
}, [addonsLoaded, loadAddons]) setBagTrackingEnabled(d.enabled)
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
useEffect(() => { }).catch(() => {})
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {}) }, [tripId])
}, [tripId, bagTrackingEnabled])
const handleCreateBag = async () => { const handleCreateBag = async () => {
if (!newBagName.trim()) return if (!newBagName.trim()) return
@@ -242,7 +234,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const templateDropdownRef = useRef<HTMLDivElement>(null) const templateDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
}, [tripId]) }, [tripId])
useEffect(() => { useEffect(() => {
@@ -275,7 +267,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
toast.success(t('packing.templateSaved')) toast.success(t('packing.templateSaved'))
setShowSaveTemplate(false) setShowSaveTemplate(false)
setSaveTemplateName('') setSaveTemplateName('')
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
} }
@@ -305,7 +297,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const font = { fontFamily: "var(--font-system)" } const font = { fontFamily: "var(--font-system)" }
return { return {
tripId, items, inlineHeader, t, canEdit, isAdmin, font, tripId, items, inlineHeader, t, canEdit, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName, filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked, handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
@@ -1,261 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useEffect, useMemo } from 'react'
import { Plane, X, Check } from 'lucide-react'
import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi, reservationsApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
interface AirTrailImportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
if (!d) return ''
try {
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
})
} catch {
return d
}
}
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const trip = useTripStore(s => s.trip)
const reservations = useTripStore(s => s.reservations)
const loadReservations = useTripStore(s => s.loadReservations)
const mouseDownTarget = useRef<EventTarget | null>(null)
const [loading, setLoading] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState('')
const [flights, setFlights] = useState<AirtrailFlight[]>([])
const [selected, setSelected] = useState<Set<string>>(() => new Set())
// AirTrail flight ids already linked to a reservation in this trip.
const importedIds = useMemo(() => {
const set = new Set<string>()
for (const r of reservations) {
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
}
return set
}, [reservations])
const inRange = (f: AirtrailFlight): boolean =>
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
useEffect(() => {
if (!isOpen) return
setError('')
setSelected(new Set())
setLoading(true)
airtrailApi
.flights()
.then((d: { flights: AirtrailFlight[] }) => {
const list = d.flights ?? []
setFlights(list)
// Pre-select the flights that fall inside the trip and aren't imported yet.
const pre = new Set<string>()
for (const f of list) if (inRange(f) && !importedIds.has(f.id)) pre.add(f.id)
setSelected(pre)
})
.catch((err: any) => setError(err?.response?.data?.error ?? t('reservations.airtrail.loadError')))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
const { during, others } = useMemo(() => {
const during: AirtrailFlight[] = []
const others: AirtrailFlight[] = []
for (const f of flights) (inRange(f) ? during : others).push(f)
const byDateDesc = (a: AirtrailFlight, b: AirtrailFlight) => (b.date ?? '').localeCompare(a.date ?? '')
return { during: during.sort(byDateDesc), others: others.sort(byDateDesc) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flights, trip?.start_date, trip?.end_date])
const toggle = (id: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleClose = () => { onClose() }
const handleImport = async () => {
const ids = [...selected].filter(id => !importedIds.has(id))
if (ids.length === 0 || importing) return
setImporting(true)
setError('')
try {
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
await loadReservations(tripId)
const imported = result.imported ?? []
if (imported.length > 0) {
pushUndo?.(t('reservations.airtrail.undo'), async () => {
const linked = useTripStore.getState().reservations.filter(
r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)),
)
await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {})))
await loadReservations(tripId)
})
toast.success(t('reservations.airtrail.imported', { count: imported.length }))
}
const skippedInTrip = (result.skipped ?? []).filter(s => s.reason === 'already-in-trip').length
if (skippedInTrip > 0) toast.warning(t('reservations.airtrail.skippedDuplicate', { count: skippedInTrip }))
if (imported.length === 0 && skippedInTrip === 0) toast.warning(t('reservations.airtrail.nothingImported'))
handleClose()
} catch (err: any) {
setError(err?.response?.data?.error ?? t('reservations.airtrail.importError'))
} finally {
setImporting(false)
}
}
const selectableCount = [...selected].filter(id => !importedIds.has(id)).length
if (!isOpen) return null
const renderFlight = (f: AirtrailFlight) => {
const already = importedIds.has(f.id)
const isSelected = selected.has(f.id)
const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'}${f.toCode ?? '?'}`
return (
<button
key={f.id}
onClick={() => !already && toggle(f.id)}
disabled={already}
className={already ? 'bg-surface-tertiary' : isSelected ? 'bg-surface-secondary' : 'bg-transparent'}
style={{
width: '100%', textAlign: 'left', borderRadius: 10, padding: '10px 12px', marginBottom: 8,
border: `1px solid ${isSelected && !already ? 'var(--accent)' : 'var(--border-primary)'}`,
opacity: already ? 0.55 : 1, cursor: already ? 'default' : 'pointer',
display: 'flex', gap: 10, alignItems: 'center', fontFamily: 'inherit',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<span style={{
flexShrink: 0, width: 18, height: 18, borderRadius: 5,
border: `1.5px solid ${isSelected || already ? 'var(--accent)' : 'var(--border-primary)'}`,
background: isSelected || already ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{(isSelected || already) && <Check size={12} color="var(--accent-text)" strokeWidth={3} />}
</span>
<Plane size={15} color="#3b82f6" style={{ flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>
{f.fromCode ?? f.fromName ?? '?'} {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
</span>
</span>
{already && (
<span style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
{t('reservations.airtrail.alreadyImported')}
</span>
)}
</button>
)
}
return ReactDOM.createPortal(
<div
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
mouseDownTarget.current = null
}}
>
<div
onClick={e => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<Plane size={16} color="#3b82f6" />
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.airtrail.title')}
</div>
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<X size={16} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{loading && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('common.loading')}
</div>
)}
{!loading && flights.length === 0 && !error && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('reservations.airtrail.empty')}
</div>
)}
{!loading && during.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
{t('reservations.airtrail.duringTrip')}
</div>
{during.map(renderFlight)}
</>
)}
{!loading && others.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
{t('reservations.airtrail.otherFlights')}
</div>
{others.map(renderFlight)}
</>
)}
{error && (
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
{error}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
<button
onClick={handleClose}
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={handleImport}
disabled={selectableCount === 0 || importing}
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: selectableCount > 0 && !importing ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{importing ? t('common.loading') : t('reservations.airtrail.importCta', { count: selectableCount })}
</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -2,7 +2,6 @@ import {
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route, ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
} from 'lucide-react' } from 'lucide-react'
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText } export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
@@ -28,18 +27,6 @@ export const NOTE_ICONS = [
{ id: 'AlertTriangle', Icon: AlertTriangle }, { id: 'AlertTriangle', Icon: AlertTriangle },
{ id: 'ShoppingBag', Icon: ShoppingBag }, { id: 'ShoppingBag', Icon: ShoppingBag },
{ id: 'Bookmark', Icon: Bookmark }, { id: 'Bookmark', Icon: Bookmark },
{ id: 'Utensils', Icon: Utensils },
{ id: 'Wine', Icon: Wine },
{ id: 'ParkingSquare', Icon: ParkingSquare },
{ id: 'Fuel', Icon: Fuel },
{ id: 'Footprints', Icon: Footprints },
{ id: 'Mountain', Icon: Mountain },
{ id: 'Waves', Icon: Waves },
{ id: 'Sun', Icon: Sun },
{ id: 'Umbrella', Icon: Umbrella },
{ id: 'Music', Icon: Music },
{ id: 'Landmark', Icon: Landmark },
{ id: 'Gift', Icon: Gift },
] ]
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon])) const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText } export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
@@ -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 user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' }) 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') const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
if (noteEditBtns.length > 1) { if (noteEditBtns.length > 1) {
await user.click(noteEditBtns[1] as HTMLElement) 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() expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
} }
}) })
@@ -1708,49 +1703,4 @@ describe('DayPlanSidebar', () => {
expect(onEditTransport).toHaveBeenCalledWith(res) expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled() expect(onEditReservation).not.toHaveBeenCalled()
}) })
// ── showRouteToolsWhenExpanded (mobile route tools) ───────────────────────
it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => {
const places = [
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
]
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assigns = {
'10': [
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true,
})} />)
// Days are expanded by default, so route tools must be visible even with no selected day
expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => {
const user = userEvent.setup()
const onReorder = vi.fn().mockResolvedValue(undefined)
const places = [
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
]
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assigns = {
'10': [
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true,
})} />)
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
await user.click(optimizeBtn)
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
})
}) })
+48 -153
View File
@@ -7,7 +7,6 @@ import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLi
import { assignmentsApi, reservationsApi } from '../../api/client' import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator' import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -18,7 +17,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder' import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
@@ -51,8 +50,6 @@ interface DayPlanSidebarProps {
onDayDetail: (day: Day) => void onDayDetail: (day: Day) => void
accommodations?: Accommodation[] accommodations?: Accommodation[]
onReorder: (dayId: number, orderedIds: number[]) => void onReorder: (dayId: number, orderedIds: number[]) => void
onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void
onUpdateDayTitle: (dayId: number, title: string) => void onUpdateDayTitle: (dayId: number, title: string) => void
onRouteCalculated: (route: RouteResult | null) => void onRouteCalculated: (route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number, position?: number) => void onAssignToDay: (placeId: number, dayId: number, position?: number) => void
@@ -84,8 +81,6 @@ interface DayPlanSidebarProps {
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
initialScrollTop?: number initialScrollTop?: number
onScrollTopChange?: (top: number) => void onScrollTopChange?: (top: number) => void
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
showRouteToolsWhenExpanded?: boolean
} }
/** /**
@@ -100,7 +95,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
trip, days, places, categories, assignments, trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId, selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated, onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
visibleConnectionIds = [], visibleConnectionIds = [],
@@ -127,7 +122,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddBookingToAssignment, onAddBookingToAssignment,
initialScrollTop, initialScrollTop,
onScrollTopChange, onScrollTopChange,
showRouteToolsWhenExpanded = false,
} = props } = props
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
@@ -176,7 +170,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [timeConfirm, setTimeConfirm] = useState<{ const [timeConfirm, setTimeConfirm] = useState<{
dayId: number; fromId: number; time: string; dayId: number; fromId: number; time: string;
// For drag & drop reorder // 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 // For arrow reorder
reorderIds?: number[]; reorderIds?: number[];
} | null>(null) } | null>(null)
@@ -381,30 +375,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (legsAbortRef.current) legsAbortRef.current.abort() if (legsAbortRef.current) legsAbortRef.current.abort()
if (!selectedDayId || !routeShown) { setRouteLegs({}); return } if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
const merged = mergedItemsMap[selectedDayId] || [] 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 }[][] = [] const runs: { id: number; lat: number; lng: number }[][] = []
let cur: { id: number; lat: number; lng: number }[] = [] let cur: { id: number; lat: number; lng: number }[] = []
for (const it of merged) { for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { 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 }) cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
} else if (it.type === 'transport') { } else if (it.type === 'transport') {
const r = it.data if (cur.length >= 2) runs.push(cur)
const from = epLoc(r, 'from'), to = epLoc(r, 'to') cur = []
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) if (cur.length >= 2) runs.push(cur)
@@ -473,10 +451,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
_openEditNote(dayId, note) _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) => { const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation()
await _deleteNote(dayId, noteId) await _deleteNote(dayId, noteId)
@@ -492,9 +466,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const assignmentIds: number[] = [] const assignmentIds: number[] = []
const noteUpdates: { id: number; sort_order: number }[] = [] const noteUpdates: { id: number; sort_order: number }[] = []
const transportUpdates: { id: number; day_plan_position: 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 placeCount = 0
let i = 0 let i = 0
@@ -515,10 +486,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
group.forEach((g, idx) => { group.forEach((g, idx) => {
const pos = base + (idx + 1) / (group.length + 1) const pos = base + (idx + 1) / (group.length + 1)
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos }) if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
else if (g.type === 'transport') { else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
else transportUpdates.push({ id: g.data.id, day_plan_position: pos })
}
}) })
} }
} }
@@ -537,30 +505,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
})) }))
setTransportPosVersion(v => v + 1) 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 (assignmentIds.length) await onReorder(dayId, assignmentIds)
if (transportUpdates.length) { if (transportUpdates.length) {
onRouteRefresh?.() onRouteRefresh?.()
@@ -579,11 +523,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } 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) 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? // Check if a timed place is being moved → would it break chronological order?
if (fromType === 'place') { if (fromType === 'place') {
@@ -591,11 +532,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time) const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
if (fromItem && fromMinutes !== null) { if (fromItem && fromMinutes !== null) {
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) 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) { if (fromIdx !== -1 && toIdx !== -1) {
const simulated = [...m] const simulated = [...m]
const [moved] = simulated.splice(fromIdx, 1) 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 (insertIdx === -1) insertIdx = simulated.length
if (insertAfter) insertIdx += 1 if (insertAfter) insertIdx += 1
simulated.splice(insertIdx, 0, moved) simulated.splice(insertIdx, 0, moved)
@@ -612,7 +553,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (!isChronological) { if (!isChronological) {
const placeTime = fromItem.data.place.place_time const placeTime = fromItem.data.place.place_time
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return return
} }
@@ -622,7 +563,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Build new order: remove the dragged item, insert at target position // Build new order: remove the dragged item, insert at target position
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) 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) { if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return return
@@ -630,7 +571,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const newOrder = [...m] const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1) 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 (adjustedTo === -1) adjustedTo = newOrder.length
if (insertAfter) adjustedTo += 1 if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved) newOrder.splice(adjustedTo, 0, moved)
@@ -644,7 +585,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const confirmTimeRemoval = async () => { const confirmTimeRemoval = async () => {
if (!timeConfirm) return if (!timeConfirm) return
const saved = { ...timeConfirm } const saved = { ...timeConfirm }
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
setTimeConfirm(null) setTimeConfirm(null)
// Remove time from assignment // Remove time from assignment
@@ -687,14 +628,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Drag & drop reorder // Drag & drop reorder
if (fromType && toType) { 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 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 if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
const newOrder = [...m] const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1) 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 (adjustedTo === -1) adjustedTo = newOrder.length
if (insertAfter) adjustedTo += 1 if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved) newOrder.splice(adjustedTo, 0, moved)
@@ -745,34 +685,26 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) }) pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
} }
const handleOptimize = async (dayId: number | null = selectedDayId) => { const handleOptimize = async () => {
if (!dayId) return if (!selectedDayId) return
const da = getDayAssignments(dayId) const da = getDayAssignments(selectedDayId)
if (da.length < 3) return if (da.length < 3) return
const prevIds = da.map(a => a.id) const prevIds = da.map(a => a.id)
// Separate fixed (stay at their index) and movable assignments. A place is // Separate locked (stay at their index) and unlocked assignments
// fixed if it's locked OR has a set time — timed places are anchored by their
// time, so the optimizer must not reshuffle them.
const locked = new Map() // index -> assignment const locked = new Map() // index -> assignment
const unlocked = [] const unlocked = []
da.forEach((a, i) => { 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) else unlocked.push(a)
}) })
// Optimize only unlocked assignments (work on assignments, not places) // Optimize only unlocked assignments (work on assignments, not places)
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng) const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
const unlockedNoCoords = 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 === dayId)
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
? getAccommodationAnchors(day, days, accommodations)
: {}
const optimizedAssignments = unlockedWithCoords.length >= 2 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 : unlockedWithCoords
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords] const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
@@ -784,10 +716,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (!result[i]) result[i] = optimizedQueue[qi++] if (!result[i]) result[i] = optimizedQueue[qi++]
} }
await onReorder(dayId, result.map(a => a.id)) await onReorder(selectedDayId, result.map(a => a.id))
const usedHotel = !!(anchors.start || anchors.end) toast.success(t('dayplan.toast.routeOptimized'))
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized')) const capturedDayId = selectedDayId
const capturedDayId = dayId
pushUndo?.(t('undo.optimize'), async () => { pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
}) })
@@ -871,8 +802,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onDayDetail, onDayDetail,
accommodations, accommodations,
onReorder, onReorder,
onReorderDays,
onAddDay,
onUpdateDayTitle, onUpdateDayTitle,
onRouteCalculated, onRouteCalculated,
onAssignToDay, onAssignToDay,
@@ -904,7 +833,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddBookingToAssignment, onAddBookingToAssignment,
initialScrollTop, initialScrollTop,
onScrollTopChange, onScrollTopChange,
showRouteToolsWhenExpanded,
toast, toast,
t, t,
language, language,
@@ -923,8 +851,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
cancelNote, cancelNote,
saveNote, saveNote,
deleteNote, deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
moveNote, moveNote,
expandedDays, expandedDays,
setExpandedDays, setExpandedDays,
@@ -1018,8 +944,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onDayDetail, onDayDetail,
accommodations, accommodations,
onReorder, onReorder,
onReorderDays,
onAddDay,
onUpdateDayTitle, onUpdateDayTitle,
onRouteCalculated, onRouteCalculated,
onAssignToDay, onAssignToDay,
@@ -1051,7 +975,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onAddBookingToAssignment, onAddBookingToAssignment,
initialScrollTop, initialScrollTop,
onScrollTopChange, onScrollTopChange,
showRouteToolsWhenExpanded,
toast, toast,
t, t,
language, language,
@@ -1070,8 +993,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
cancelNote, cancelNote,
saveNote, saveNote,
deleteNote, deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
moveNote, moveNote,
expandedDays, expandedDays,
setExpandedDays, setExpandedDays,
@@ -1172,9 +1093,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
undoHover={undoHover} undoHover={undoHover}
setUndoHover={setUndoHover} setUndoHover={setUndoHover}
lastActionLabel={lastActionLabel} lastActionLabel={lastActionLabel}
canEditDays={canEditDays}
onReorderDays={onReorderDays}
onAddDay={onAddDay}
/> />
{/* Tagesliste */} {/* Tagesliste */}
@@ -1377,8 +1295,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const isAfter = dropTargetRef.current.startsWith('transport-after-') const isAfter = dropTargetRef.current.startsWith('transport-after-')
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-') const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
const transportId = Number(parts[0]) const transportId = Number(parts[0])
const legPart = parts.find(p => /^leg\d+$/.test(p))
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
@@ -1386,15 +1302,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const r = reservations.find(x => x.id === Number(fromReservationId)) 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'))) } 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) { } 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) { } 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'))) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (assignmentId) { } 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) { } 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'))) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId) { } 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
return return
@@ -1440,10 +1356,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div> </div>
) : ( ) : (
merged.map((item, idx) => { merged.map((item, idx) => {
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : '' const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey 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') { if (item.type === 'place') {
const assignment = item.data const assignment = item.data
@@ -1791,13 +1706,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Subtitle aus Metadaten zusammensetzen // Subtitle aus Metadaten zusammensetzen
let subtitle = '' let subtitle = ''
if (res.__leg) { if (res.type === 'flight') {
// 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') {
const parts = [meta.airline, meta.flight_number].filter(Boolean) const parts = [meta.airline, meta.flight_number].filter(Boolean)
if (meta.departure_airport || meta.arrival_airport) if (meta.departure_airport || meta.arrival_airport)
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → ')) parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
@@ -1806,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(' · ') 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-day span phase
// multi-leg flight is shown as one row per leg, see below). const spanLabel = getSpanLabel(res, spanPhase)
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
const displayTime = getDisplayTimeForDay(res, day.id) const displayTime = getDisplayTimeForDay(res, day.id)
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
return ( return (
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}> <React.Fragment key={`transport-${res.id}-${day.id}`}>
<div <div
onClick={() => { onClick={() => {
if (!canEditDays) return if (!canEditDays) return
const target = reservations.find(x => x.id === res.id) ?? res if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target) else onEditReservation?.(res)
else onEditReservation?.(target)
}} }}
onDragOver={e => { onDragOver={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
const inBottom = e.clientY > rect.top + rect.height / 2 const inBottom = e.clientY > rect.top + rect.height / 2
const ls = res.__leg ? `-leg${res.__leg.index}` : '' const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key) if (dropTargetRef.current !== key) setDropTargetKey(key)
}} }}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg} draggable={canEditDays && spanPhase !== 'middle'}
onDragStart={e => { 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 // setData is required for the drag to start reliably (Firefox) and
// matches how place/note items initiate their drag. // matches how place/note items initiate their drag.
e.dataTransfer.setData('reservationId', String(res.id)) e.dataTransfer.setData('reservationId', String(res.id))
@@ -1852,15 +1757,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const r2 = reservations.find(x => x.id === Number(fromReservationId)) 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'))) } 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) { } 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) { } 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'))) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (fromAssignmentId) { } 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) { } 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'))) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId) { } 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}} }}
@@ -1880,7 +1785,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, 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' }}> <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} /> <GripVertical size={13} strokeWidth={1.8} />
</div> </div>
@@ -1925,7 +1830,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div> </div>
)} )}
</div> </div>
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => { {onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id) const active = visibleConnectionIds.includes(res.id)
return ( return (
<button <button
@@ -1949,7 +1854,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
) )
})()} })()}
</div> </div>
{routeLegs[res.id] && <RouteConnector seg={routeLegs[res.id]} profile={routeProfile} />}
</React.Fragment> </React.Fragment>
) )
} }
@@ -2004,7 +1908,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onContextMenu={canEditDays ? e => ctxMenu.open(e, [ onContextMenu={canEditDays ? e => ctxMenu.open(e, [
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) }, { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true }, { 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} ]) : undefined}
onMouseEnter={e => { onMouseEnter={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
@@ -2046,7 +1950,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div> </div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}> {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 => 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>} </div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}> {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> <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>
@@ -2101,7 +2005,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div> </div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && ( {isSelected && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button <button
@@ -2117,7 +2021,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<RouteIcon size={12} strokeWidth={2} /> <RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')} {t('dayplan.route')}
</button> </button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{ <button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none', padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
@@ -2146,7 +2050,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})} })}
</div> </div>
</div> </div>
{isSelected && routeInfo && ( {routeInfo && (
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}> <div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
<span>{routeInfo.distance}</span> <span>{routeInfo.distance}</span>
<span className="text-content-faint">·</span> <span className="text-content-faint">·</span>
@@ -2189,15 +2093,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
t={t} 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 */} {/* Transport-Detail-Modal */}
<DayPlanSidebarTransportDetailModal <DayPlanSidebarTransportDetailModal
transportDetail={transportDetail} transportDetail={transportDetail}
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
/> />
<textarea <textarea
value={ui.time} value={ui.time}
maxLength={250} maxLength={150}
rows={3} rows={3}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))} onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }} onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
className="text-content" className="text-content"
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }} style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
/> />
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div> <div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button> <button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}> <button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
@@ -1,7 +1,5 @@
import { useState } from 'react' import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2 } from 'lucide-react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
import { downloadTripPDF } from '../PDF/TripPDF' import { downloadTripPDF } from '../PDF/TripPDF'
import { DayReorderPopup } from './DayReorderPopup'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
@@ -29,18 +27,13 @@ interface DayPlanSidebarToolbarProps {
undoHover: boolean undoHover: boolean
setUndoHover: (v: boolean) => void setUndoHover: (v: boolean) => void
lastActionLabel: string | null lastActionLabel: string | null
canEditDays?: boolean
onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void
} }
export function DayPlanSidebarToolbar({ export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover, t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, onReorderDays, onAddDay,
}: DayPlanSidebarToolbarProps) { }: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
return ( return (
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}> <div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
@@ -204,38 +197,6 @@ export function DayPlanSidebarToolbar({
)} )}
</div> </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>
<DayReorderPopup
isOpen={reorderOpen}
days={days}
t={t}
locale={locale}
onReorder={onReorderDays}
onAddDay={() => onAddDay()}
onClose={() => setReorderOpen(false)}
/>
</div>
)}
</div> </div>
</div> </div>
) )
@@ -1,143 +0,0 @@
import { useState } from 'react'
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import type { Day } from '../../types'
interface DayReorderPopupProps {
isOpen: boolean
days: Day[]
t: (key: string, params?: Record<string, any>) => string
locale: string
onReorder: (orderedIds: number[]) => void
onAddDay: () => void
onClose: () => void
}
/**
* Modal 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({ isOpen, 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: 28, height: 28,
border: '1px solid var(--border-faint)', borderRadius: 7,
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
} as const
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('dayplan.reorderTitle')}
size="md"
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.close')}
</button>
<button
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={15} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
}
>
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
{t('dayplan.reorderHint')}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{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: 10, padding: '8px 10px',
borderRadius: 9,
border: '1px solid var(--border-faint)',
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
opacity: dragIndex === index ? 0.5 : 1,
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
outlineOffset: -2,
}}
>
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13.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={14} 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={14} strokeWidth={2} />
</button>
</div>
))}
</div>
</Modal>
)
}
@@ -253,101 +253,6 @@ describe('PlaceFormModal', () => {
delete window.__addToast; delete window.__addToast;
}); });
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
// it yields no usable place the modal must fall back to the reliable text search
// instead of dead-ending on "Place search failed".
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel');
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
return screen.findByText('Paris, France');
}
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
source: 'openstreetmap',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
const suggestion = await openSuggestion(user);
await user.click(suggestion);
// Form is populated from the search fallback, and no error toast is shown.
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
source: 'openstreetmap',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
const suggestion = await openSuggestion(user);
await user.click(suggestion);
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
);
render(<PlaceFormModal {...defaultProps} />);
const suggestion = await openSuggestion(user);
await user.click(suggestion);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
});
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => { it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
// hasMapsKey is false by default in beforeEach // hasMapsKey is false by default in beforeEach
render(<PlaceFormModal {...defaultProps} />); render(<PlaceFormModal {...defaultProps} />);
@@ -365,18 +270,6 @@ describe('PlaceFormModal', () => {
expect(screen.getByText(/No category/i)).toBeInTheDocument(); 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 () => { 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' }); const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input // Directly invoke handleCreateCategory by setting showNewCategory via the category name input
@@ -27,7 +27,7 @@ interface PlaceFormModalProps {
onClose: () => void onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
place: Place | null 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 tripId: number
categories: Category[] categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -39,31 +39,6 @@ interface PlaceFormModalProps {
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete, /** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
* category creation, file attachments and submit. Keeps PlaceFormModal a thin * category creation, file attachments and submit. Keeps PlaceFormModal a thin
* render over the form fields. */ * render over the form fields. */
// #1152: a manually-added place is treated as a likely duplicate of an existing
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
const DUP_COORD_TOLERANCE = 0.0001
function findDuplicatePlace(
form: PlaceFormData,
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
): { name?: string | null } | null {
const name = (form.name || '').trim().toLowerCase()
const gid = (form.google_place_id || '').trim()
const lat = form.lat ? parseFloat(form.lat) : null
const lng = form.lng ? parseFloat(form.lng) : null
for (const p of places || []) {
if (gid && p.google_place_id && p.google_place_id === gid) return p
if (name && p.name && p.name.trim().toLowerCase() === name) return p
if (
lat != null && lng != null && p.lat != null && p.lng != null &&
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
) return p
}
return null
}
function usePlaceFormModal(props: PlaceFormModalProps) { function usePlaceFormModal(props: PlaceFormModalProps) {
const { const {
isOpen, onClose, onSave, place, prefillCoords, tripId, categories, isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
@@ -76,7 +51,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false) const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
const [pendingFiles, setPendingFiles] = useState([]) const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null) const fileRef = useRef(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([]) const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
@@ -112,15 +86,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lng: String(prefillCoords.lng), lng: String(prefillCoords.lng),
name: prefillCoords.name || '', name: prefillCoords.name || '',
address: prefillCoords.address || '', address: prefillCoords.address || '',
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
}) })
} else { } else {
setForm(DEFAULT_FORM) setForm(DEFAULT_FORM)
} }
setPendingFiles([]) setPendingFiles([])
setDuplicateWarning(null)
}, [place, prefillCoords, isOpen]) }, [place, prefillCoords, isOpen])
// Derive location bias bounding box from the trip's existing places // Derive location bias bounding box from the trip's existing places
@@ -249,34 +219,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setForm(prev => ({ ...prev, name: suggestion.mainText })) setForm(prev => ({ ...prev, name: suggestion.mainText }))
setIsSearchingMaps(true) setIsSearchingMaps(true)
try { try {
// The details lookup is a fragile second hop — it can fail when the const result = await mapsApi.details(suggestion.placeId, language)
// details kill-switch is off, when the OSM Overpass mirror is overloaded, if (result.place) {
// or on any upstream error. Treat a missing/coordinate-less place as a handleSelectMapsResult(result.place)
// miss and fall back to the reliable text-search path the search button
// uses (its results already carry coordinates), so dropdown items stay
// clickable instead of dead-ending on "Place search failed". (#1192)
let place: Record<string, unknown> | null = null
try {
const result = await mapsApi.details(suggestion.placeId, language)
if (result.place && result.place.lat != null && result.place.lng != null) {
place = result.place
}
} catch (err) {
console.error('Failed to fetch place details:', err)
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language)
place = search.places?.[0] ?? null
}
if (place) {
handleSelectMapsResult(place)
} else { } else {
setMapsSearch(previousSearch) setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError')) toast.error(t('places.mapsSearchError'))
} }
} catch (err) { } catch (err) {
console.error('Place suggestion lookup failed:', err) console.error('Failed to fetch place details:', err)
setMapsSearch(previousSearch) setMapsSearch(previousSearch)
toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally { } finally {
@@ -355,17 +306,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
toast.error(t('places.nameRequired')) toast.error(t('places.nameRequired'))
return return
} }
// #1152: only for new places, and only on the first attempt — a second click
// (with the warning already showing) is the explicit "add anyway" confirmation.
if (!place && !duplicateWarning) {
const dup = findDuplicatePlace(form, places)
if (dup) {
const dupName = dup.name || form.name
setDuplicateWarning(dupName)
toast.warning(t('places.duplicateExists', { name: dupName }))
return
}
}
setIsSaving(true) setIsSaving(true)
try { try {
await onSave({ await onSave({
@@ -438,7 +378,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handlePaste, handlePaste,
hasTimeError, hasTimeError,
handleSubmit, handleSubmit,
duplicateWarning,
} }
} }
@@ -499,7 +438,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
handlePaste, handlePaste,
hasTimeError, hasTimeError,
handleSubmit, handleSubmit,
duplicateWarning,
} = S } = S
return ( return (
<Modal <Modal
@@ -522,7 +460,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
disabled={isSaving || hasTimeError} disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium" className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
> >
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')} {isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button> </button>
</div> </div>
} }
@@ -698,10 +636,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
options={[ options={[
{ value: '', label: t('places.noCategory') }, { value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({ ...(categories || []).map(c => ({
// form.category_id is a string; CustomSelect matches options by value: c.id,
// strict equality, so the option value must be a string too —
// otherwise the chosen category never renders in the trigger.
value: String(c.id),
label: c.name, label: c.name,
})), })),
]} ]}
@@ -647,43 +647,5 @@ describe('PlaceInspector', () => {
expect(screen.queryByText('Participants')).toBeNull(); expect(screen.queryByText('Participants')).toBeNull();
}); });
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
render(<PlaceInspector {...defaultProps} place={p} />);
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
expect(scroll.style.overflowY).toBe('auto');
expect(scroll.style.minHeight).toBe('0px');
// flex must allow the region to shrink/grow within the capped card
expect(scroll.style.flex).not.toBe('');
expect(scroll.style.flex).not.toBe('0 0 auto');
});
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
const longWord = 'https://example.com/' + 'a'.repeat(300);
const p = buildPlace({ id: 201, description: longWord } as any);
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
expect(descDiv).toBeTruthy();
expect(descDiv.style.overflowWrap).toBe('anywhere');
expect(descDiv.style.wordBreak).toBe('break-word');
});
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
// Both description and notes containers must keep their natural height
// (flex-shrink: 0) — otherwise they compress inside the flex column and
// overflow:hidden clips the text with no scroll (issue #1195).
expect(notes.length).toBe(2);
for (const el of notes) {
expect(el.style.flexShrink).toBe('0');
}
});
}); });
@@ -217,7 +217,7 @@ export default function PlaceInspector({
locale={locale} timeFormat={timeFormat} onClose={onClose} /> locale={locale} timeFormat={timeFormat} onClose={onClose} />
{/* Content — scrollable */} {/* Content — scrollable */}
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Info-Chips — hidden on mobile, shown on desktop */} {/* Info-Chips — hidden on mobile, shown on desktop */}
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}> <div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
@@ -253,14 +253,14 @@ export default function PlaceInspector({
{/* Description / Summary */} {/* Description / Summary */}
{(place.description || googleDetails?.summary) && ( {(place.description || googleDetails?.summary) && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}> <div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div> </div>
)} )}
{/* Notes */} {/* Notes */}
{place.notes && ( {place.notes && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}> <div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
</div> </div>
)} )}
@@ -279,7 +279,7 @@ export default function PlaceInspector({
</div> </div>
{/* Footer actions */} {/* Footer actions */}
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}> <div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{selectedDayId && ( {selectedDayId && (
assignmentInDay ? ( assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />} <ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue, function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) { commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
{/* Avatar with open/closed ring + tag */} {/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}> <div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{ <div style={{
@@ -1,12 +1,10 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { SidebarState } from './usePlacesSidebar' import type { SidebarState } from './usePlacesSidebar'
export function ListImportModal(S: SidebarState) { export function ListImportModal(S: SidebarState) {
const { const {
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders, setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport, listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
listImportEnrich, setListImportEnrich, canEnrichImport,
} = S } = S
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div <div
@@ -57,15 +55,6 @@ export function ListImportModal(S: SidebarState) {
fontFamily: 'inherit', boxSizing: 'border-box', fontFamily: 'inherit', boxSizing: 'border-box',
}} }}
/> />
{canEnrichImport && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
</div>
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button <button
onClick={() => { setListImportOpen(false); setListImportUrl('') }} onClick={() => { setListImportOpen(false); setListImportUrl('') }}
@@ -179,16 +179,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.needsReview')} {t('reservations.needsReview')}
</span> </span>
) : null} ) : null}
{r.external_source === 'airtrail' ? (
<span
className={r.sync_enabled ? 'text-[#2563eb] bg-[rgba(59,130,246,0.12)]' : 'text-content-faint bg-surface-tertiary'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, padding: '3px 8px', borderRadius: 6 }}
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : t('reservations.airtrail.notSyncedHint')}
>
<Plane size={11} />
{r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
</span>
) : null}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="text-content" style={{ <span className="text-content" style={{
@@ -281,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 || []
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) const from = eps.find(e => e.role === 'from')
if (eps.length < 2) return null const to = eps.find(e => e.role === 'to')
if (!from || !to) return null
return ( return (
<div className="bg-surface-tertiary text-content" style={{ <div className="bg-surface-tertiary text-content" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '8px 12px', borderRadius: 10, padding: '8px 12px', borderRadius: 10,
fontSize: 12.5, flexWrap: 'wrap', fontSize: 12.5,
}}> }}>
{eps.map((ep, i) => ( <span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}> <TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
{i > 0 && <TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />} <span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ep.name}</span>
</span>
))}
</div> </div>
) )
})()} })()}
@@ -482,8 +470,6 @@ interface ReservationsPanelProps {
onAdd: () => void onAdd: () => void
onImport?: () => void onImport?: () => void
bookingImportAvailable?: boolean bookingImportAvailable?: boolean
onAirTrailImport?: () => void
airTrailAvailable?: boolean
onEdit: (reservation: Reservation) => void onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void onDelete: (id: number) => void
onNavigateToFiles: () => void onNavigateToFiles: () => void
@@ -491,7 +477,7 @@ interface ReservationsPanelProps {
addManualKey?: string addManualKey?: string
} }
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const can = useCanDo() const can = useCanDo()
const trip = useTripStore((s) => s.trip) const trip = useTripStore((s) => s.trip)
@@ -614,21 +600,6 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<span className="hidden sm:inline">{t('reservations.import.cta')}</span> <span className="hidden sm:inline">{t('reservations.import.cta')}</span>
</button> </button>
)} )}
{onAirTrailImport && airTrailAvailable && (
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxSizing: 'border-box',
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
title={t('reservations.airtrail.title')}
>
<Plane size={14} strokeWidth={2} />
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
</button>
)}
<button onClick={onAdd} className="bg-accent text-accent-text" style={{ <button onClick={onAdd} className="bg-accent text-accent-text" style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
+106 -235
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams } from 'react-router-dom' 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 Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
@@ -14,7 +14,6 @@ import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types' 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 const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number] type TransportType = typeof TRANSPORT_TYPES[number]
@@ -24,7 +23,7 @@ interface EndpointPick {
location?: LocationPoint 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 { return {
role, sequence, role, sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name, name: a.city ? `${a.city} (${a.iata})` : a.name,
@@ -64,25 +63,6 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
return { name: e.name, lat: e.lat, lng: e.lng, address: null } 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
seat: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
}
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train }, { value: 'train', labelKey: 'reservations.type.train', Icon: Train },
@@ -142,8 +122,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = 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 [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false) const [showFilePicker, setShowFilePicker] = useState(false)
@@ -181,40 +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 : '', budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
if (type === 'flight') { if (type === 'flight') {
const orderedEps = orderedEndpoints(reservation) setFromPick({ airport: airportFromEndpoint(from) || undefined })
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : [] setToPick({ airport: airportFromEndpoint(to) || undefined })
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 ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
}
})
} 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 ?? ''
dep.seat = meta.seat ?? ''
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)
} else { } else {
setFromPick({ location: locationFromEndpoint(from) || undefined }) setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined }) setToPick({ location: locationFromEndpoint(to) || undefined })
@@ -223,7 +169,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' }) setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
setFromPick({}) setFromPick({})
setToPick({}) setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
} }
}, [isOpen, reservation, selectedDayId, budgetItems]) }, [isOpen, reservation, selectedDayId, budgetItems])
@@ -242,48 +187,18 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
return day?.date ? `${day.date}T${time}` : time return day?.date ? `${day.date}T${time}` : time
} }
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null const metadata: Record<string, string> = {}
// 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> = {}
if (form.type === 'flight') { if (form.type === 'flight') {
// Top-level keys mirror the first/last leg so legacy readers keep working. if (form.meta_airline) metadata.airline = form.meta_airline
if (firstWp?.airline) metadata.airline = firstWp.airline if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number if (fromPick.airport) {
if (firstWp?.airport) { metadata.departure_airport = fromPick.airport.iata
metadata.departure_airport = firstWp.airport.iata metadata.departure_timezone = fromPick.airport.tz
metadata.departure_timezone = firstWp.airport.tz
} }
if (lastWp?.airport) { if (toPick.airport) {
metadata.arrival_airport = lastWp.airport.iata metadata.arrival_airport = toPick.airport.iata
metadata.arrival_timezone = lastWp.airport.tz metadata.arrival_timezone = toPick.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 } : {}),
...(w.seat ? { seat: w.seat } : {}),
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 (firstWp?.seat) metadata.seat = firstWp.seat
} else if (form.type === 'train') { } else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_platform) metadata.platform = form.meta_platform
@@ -298,35 +213,21 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const endDate = (endDay ?? startDay)?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = [] const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (form.type === 'flight') { if (form.type === 'flight') {
flightWps.forEach((w, i) => { if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
const isFirst = i === 0 if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
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))
})
} else { } else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null)) 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)) 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 = { const payload = {
title: form.title, title: form.title,
type: form.type, type: form.type,
status: form.status, status: form.status,
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null), day_id: 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), end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
reservation_time: form.type === 'flight' reservation_time: buildTime(startDay, form.departure_time),
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '') reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
: 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),
location: null, location: null,
confirmation_number: form.confirmation_number || null, confirmation_number: form.confirmation_number || null,
notes: form.notes || null, notes: form.notes || null,
@@ -447,130 +348,100 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
placeholder={t('reservations.titlePlaceholder')} className={inputClass} /> placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
</div> </div>
{form.type === 'flight' ? ( {/* From / To endpoints */}
/* ── Flight route: ordered airports (origin · stops · destination) ── */ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div>
<label className={labelClass}>{t('reservations.layover.route')}</label> <label className={labelClass}>{t('reservations.meta.from')}</label>
{waypoints.map((wp, i) => { {form.type === 'flight' ? (
const isFirst = i === 0 <AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
const isLast = i === waypoints.length - 1 ) : (
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w))) <LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
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-3 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>
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" 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>
)
})}
</div> </div>
) : ( <div>
<> <label className={labelClass}>{t('reservations.meta.to')}</label>
{/* From / To endpoints (non-flight) */} {form.type === 'flight' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
<div> ) : (
<label className={labelClass}>{t('reservations.meta.from')}</label> <LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} /> )}
</div> </div>
<div> </div>
<label className={labelClass}>{t('reservations.meta.to')}</label>
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
</div>
</div>
{/* Departure row */} {/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label> <label className={labelClass}>
<CustomSelect value={form.start_day_id} onChange={value => set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> {form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
</div> </label>
<div style={{ flex: 1, minWidth: 0 }}> <CustomSelect
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label> value={form.start_day_id}
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} /> 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> </div>
)}
</div>
{/* Arrival row */} {/* Arrival row */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label> <label className={labelClass}>
<CustomSelect value={form.end_day_id} onChange={value => set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> {form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
</div> </label>
<div style={{ flex: 1, minWidth: 0 }}> <CustomSelect
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label> value={form.end_day_id}
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} /> 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> </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 */} {/* Train-specific fields */}
@@ -7,7 +7,6 @@ import { useContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client' import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types' import type { Place, Category, Day, AssignmentsMap } from '../../types'
export interface PlacesSidebarProps { export interface PlacesSidebarProps {
@@ -50,8 +49,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const loadTrip = useTripStore((s) => s.loadTrip) const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo() const can = useCanDo()
const canEditPlaces = can('place_edit', trip) const canEditPlaces = can('place_edit', trip)
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
const isNaverListImportEnabled = true const isNaverListImportEnabled = true
const [fileImportOpen, setFileImportOpen] = useState(false) const [fileImportOpen, setFileImportOpen] = useState(false)
@@ -97,7 +94,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [listImportUrl, setListImportUrl] = useState('') const [listImportUrl, setListImportUrl] = useState('')
const [listImportLoading, setListImportLoading] = useState(false) const [listImportLoading, setListImportLoading] = useState(false)
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google') const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
const [listImportEnrich, setListImportEnrich] = useState(false)
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google'] const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
const hasMultipleListImportProviders = availableListImportProviders.length > 1 const hasMultipleListImportProviders = availableListImportProviders.length > 1
@@ -112,10 +108,9 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
setListImportLoading(true) setListImportLoading(true)
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google' const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
try { try {
const enrich = listImportEnrich && canEnrichImport
const result = provider === 'google' const result = provider === 'google'
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich) ? await placesApi.importGoogleList(tripId, listImportUrl.trim())
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich) : await placesApi.importNaverList(tripId, listImportUrl.trim())
await loadTrip(tripId) await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) { if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped')) toast.warning(t('places.importAllSkipped'))
@@ -228,7 +223,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
scrollContainerRef, onScrollTopChange, scrollContainerRef, onScrollTopChange,
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl, listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
listImportLoading, listImportProvider, setListImportProvider, listImportLoading, listImportProvider, setListImportProvider,
listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport, availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal, search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
@@ -1,147 +0,0 @@
import React, { useEffect, useState } from 'react'
import { Plane, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi } from '../../api/client'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
/**
* Settings Integrations AirTrail. Per-user connection to a self-hosted
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
* connection layout: stacked fields, a toggle, then Save / Test-connection with
* a status badge. The key is stored encrypted and never prefilled.
*/
export default function AirTrailConnectionSection(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [url, setUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
useEffect(() => {
airtrailApi
.getSettings()
.then(d => {
setUrl(d.url || '')
setAllowInsecureTls(!!d.allowInsecureTls)
setConnected(!!d.connected)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Send the key only when the user typed a new one — never prefilled, so a blank
// field means "keep the stored key".
const keyPayload = (): { apiKey?: string } => {
const k = apiKey.trim()
return k ? { apiKey: k } : {}
}
const handleSave = async () => {
setSaving(true)
try {
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
const status = await airtrailApi.status().catch(() => ({ connected: false }))
setConnected(!!status.connected)
setApiKey('')
if (d?.warning) toast.warning(d.warning)
else toast.success(t('settings.airtrail.toast.saved'))
} catch (err: any) {
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
} finally {
setSaving(false)
}
}
const handleTest = async () => {
setTesting(true)
try {
const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
setConnected(!!d.connected)
if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 }))
else toast.error(d.error || t('settings.airtrail.test.failed'))
} catch {
toast.error(t('settings.airtrail.test.failed'))
} finally {
setTesting(false)
}
}
const canSave = !!url.trim() && (connected || !!apiKey.trim())
return (
<Section title={t('settings.airtrail.title')} icon={Plane}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://airtrail.example.com"
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
</div>
<div className="flex items-center gap-3">
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
disabled={saving || loading || !canSave}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button
onClick={handleTest}
disabled={testing || loading || !url.trim()}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
>
{testing ? (
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
) : (
<Plane className="w-4 h-4" />
)}
{t('settings.airtrail.test.button')}
</button>
{connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('settings.airtrail.connected')}
</span>
) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('settings.airtrail.notConnected')}
</span>
)}
</div>
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
</div>
</Section>
)
}
@@ -262,37 +262,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p> <p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
</div> </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 */} {/* Blur Booking Codes */}
<div> <div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label> <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>
</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> </Section>
) )
} }
@@ -6,7 +6,6 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh
import { authApi, oauthApi } from '../../api/client' import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection' import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import { ALL_SCOPES } from '../../api/oauthScopes' import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker' import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
@@ -98,7 +97,6 @@ export default function IntegrationsTab(): React.ReactElement {
return ( return (
<> <>
<PhotoProvidersSection /> <PhotoProvidersSection />
{S.airtrailEnabled && <AirTrailConnectionSection />}
{S.mcpEnabled && <IntegrationsMcpSection {...S} />} {S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} /> <McpTokenModals {...S} />
<OAuthClientModals {...S} /> <OAuthClientModals {...S} />
@@ -111,7 +109,6 @@ function useIntegrations() {
const toast = useToast() const toast = useToast()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore() const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const mcpEnabled = addonEnabled('mcp') const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
useEffect(() => { useEffect(() => {
loadAddons() loadAddons()
@@ -292,7 +289,7 @@ function useIntegrations() {
return { return {
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession, t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} }
} }
@@ -21,7 +21,6 @@ interface CachedTripRow {
export default function OfflineTab(): React.ReactElement { export default function OfflineTab(): React.ReactElement {
const [rows, setRows] = useState<CachedTripRow[]>([]) const [rows, setRows] = useState<CachedTripRow[]>([])
const [pendingCount, setPendingCount] = useState(0) const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [clearing, setClearing] = useState(false) const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -29,13 +28,11 @@ export default function OfflineTab(): React.ReactElement {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const [metas, pending, failed] = await Promise.all([ const [metas, pending] = await Promise.all([
offlineDb.syncMeta.toArray(), offlineDb.syncMeta.toArray(),
mutationQueue.pendingCount(), mutationQueue.pendingCount(),
mutationQueue.failedCount(),
]) ])
setPendingCount(pending) setPendingCount(pending)
setFailedCount(failed)
const result: CachedTripRow[] = [] const result: CachedTripRow[] = []
for (const meta of metas) { for (const meta of metas) {
@@ -88,7 +85,6 @@ export default function OfflineTab(): React.ReactElement {
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Stat label="Cached trips" value={rows.length} /> <Stat label="Cached trips" value={rows.length} />
<Stat label="Pending changes" value={pendingCount} /> <Stat label="Pending changes" value={pendingCount} />
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
</div> </div>
{/* Actions */} {/* Actions */}
@@ -169,14 +165,13 @@ export default function OfflineTab(): React.ReactElement {
) )
} }
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) { function Stat({ label, value }: { label: string; value: number }) {
return ( return (
<div className="border border-edge bg-surface-secondary" style={{ <div className="border border-edge bg-surface-secondary" style={{
padding: '8px 14px', borderRadius: 8, padding: '8px 14px', borderRadius: 8,
minWidth: 100, minWidth: 100,
}}> }}>
<div style={{ fontSize: 20, fontWeight: 700, color: danger ? '#ef4444' : undefined }} <div className="text-content" style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
className={danger ? undefined : 'text-content'}>{value}</div>
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div> <div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
</div> </div>
) )
+15 -47
View File
@@ -277,7 +277,6 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
const [desc, setDesc] = useState(item.description || '') const [desc, setDesc] = useState(item.description || '')
const [dueDate, setDueDate] = useState(item.due_date || '') const [dueDate, setDueDate] = useState(item.due_date || '')
const [category, setCategory] = useState(item.category || '') const [category, setCategory] = useState(item.category || '')
const [addingCategory, setAddingCategoryInline] = useState(false)
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id) const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
const [priority, setPriority] = useState(item.priority || 0) const [priority, setPriority] = useState(item.priority || 0)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -379,52 +378,21 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
{/* Category */} {/* Category */}
<div> <div>
<label className={labelClass}>{t('todo.detail.category')}</label> <label className={labelClass}>{t('todo.detail.category')}</label>
{addingCategory ? ( <CustomSelect
<div style={{ display: 'flex', gap: 4 }}> value={category}
<input onChange={v => setCategory(String(v))}
autoFocus options={[
value={category} { value: '', label: t('todo.noCategory') },
onChange={e => setCategory(e.target.value)} ...categories.map(c => ({
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }} value: c,
placeholder={t('todo.newCategory')} label: c,
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
/> })),
<button type="button" onClick={() => setAddingCategoryInline(false)} ]}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}> placeholder={t('todo.noCategory')}
<Check size={14} /> size="sm"
</button> disabled={!canEdit}
</div> />
) : (
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={category}
onChange={v => setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
disabled={!canEdit}
/>
</div>
{canEdit && (
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
title={t('todo.newCategory')}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={14} />
</button>
)}
</div>
)}
</div> </div>
{/* Due date */} {/* Due date */}
+3 -155
View File
@@ -27,12 +27,6 @@ export interface QueuedMutation {
tempId?: number; tempId?: number;
/** For DELETE mutations: the entity id to remove from Dexie on flush */ /** For DELETE mutations: the entity id to remove from Dexie on flush */
entityId?: number; entityId?: number;
/**
* For PUT/DELETE enqueued offline against a still-unsynced (negative-id) entity:
* the temp id of the target. The url carries an `{id}` placeholder that the
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
*/
tempEntityId?: number;
} }
export interface SyncMeta { export interface SyncMeta {
@@ -47,48 +41,13 @@ export interface SyncMeta {
export interface BlobCacheEntry { export interface BlobCacheEntry {
/** Relative URL, e.g. "/api/files/42/download" */ /** Relative URL, e.g. "/api/files/42/download" */
url: string; url: string;
/**
* Trip this blob belongs to, so it is evicted together with the trip in
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
*/
tripId: number;
blob: Blob; blob: Blob;
/** Byte size captured at insert time Blob.size is not reliably preserved
* across IndexedDB round-trips, so the LRU budget reads this instead. */
bytes: number;
mime: string; mime: string;
cachedAt: number; cachedAt: number;
} }
// ── Dexie class ──────────────────────────────────────────────────────────────── // ── Dexie class ────────────────────────────────────────────────────────────────
/**
* The offline DB is scoped per user so that one account can never read another
* account's cached data on a shared device. Anonymous (logged-out) state uses
* the base name; a logged-in user uses `trek-offline-u<userId>`.
*/
const ANON_DB_NAME = 'trek-offline';
function userDbName(userId: number | string): string {
return `trek-offline-u${userId}`;
}
/**
* Best-effort read of the persisted auth snapshot so the very first DB opened on
* app load (before loadUser resolves) is already the correct per-user one the
* PWA can render cached data offline without leaking across users.
*/
function initialDbName(): string {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
if (!raw) return ANON_DB_NAME;
const id = JSON.parse(raw)?.state?.user?.id;
return id != null ? userDbName(id) : ANON_DB_NAME;
} catch {
return ANON_DB_NAME;
}
}
class TrekOfflineDb extends Dexie { class TrekOfflineDb extends Dexie {
trips!: Table<Trip, number>; trips!: Table<Trip, number>;
days!: Table<Day, number>; days!: Table<Day, number>;
@@ -106,8 +65,8 @@ class TrekOfflineDb extends Dexie {
syncMeta!: Table<SyncMeta, number>; syncMeta!: Table<SyncMeta, number>;
blobCache!: Table<BlobCacheEntry, string>; blobCache!: Table<BlobCacheEntry, string>;
constructor(name: string = ANON_DB_NAME) { constructor() {
super(name); super('trek-offline');
this.version(1).stores({ this.version(1).stores({
trips: 'id', trips: 'id',
@@ -129,67 +88,10 @@ class TrekOfflineDb extends Dexie {
tags: 'id', tags: 'id',
categories: 'id', categories: 'id',
}); });
// v3: scope the blob cache by trip so it can be evicted with the trip and
// bounded by an LRU budget (see enforceBlobBudget).
this.version(3).stores({
blobCache: 'url, cachedAt, tripId',
}).upgrade(async (tx) => {
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
if (row.tripId == null) row.tripId = -1;
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
});
});
} }
} }
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous. export const offlineDb = new TrekOfflineDb();
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
// import it directly, while every access forwards to the current connection.
let _db = new TrekOfflineDb(initialDbName());
export const offlineDb = new Proxy({} as TrekOfflineDb, {
get(_target, prop) {
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
},
set(_target, prop, value) {
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
return true;
},
}) as TrekOfflineDb;
async function switchTo(name: string): Promise<void> {
if (_db.name === name) {
if (!_db.isOpen()) await _db.open();
return;
}
if (_db.isOpen()) _db.close();
_db = new TrekOfflineDb(name);
await _db.open();
}
/** Point the offline DB at a specific user's scoped database (call on login). */
export async function reopenForUser(userId: number | string): Promise<void> {
await switchTo(userDbName(userId));
}
/** Point the offline DB at the anonymous database (call on logout). */
export async function reopenAnonymous(): Promise<void> {
await switchTo(ANON_DB_NAME);
}
/**
* Delete the current user's scoped database entirely and return to the anonymous
* DB. Used on logout so no trace of the account's data remains on the device.
*/
export async function deleteCurrentUserDb(): Promise<void> {
if (_db.name !== ANON_DB_NAME) {
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
}
_db = new TrekOfflineDb(ANON_DB_NAME);
await _db.open();
}
// ── Bulk upsert helpers ──────────────────────────────────────────────────────── // ── Bulk upsert helpers ────────────────────────────────────────────────────────
@@ -246,58 +148,6 @@ export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
await offlineDb.syncMeta.put(meta); 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;
}
}
// ── Blob-cache budget ───────────────────────────────────────────────────────
/**
* Upper bounds for the offline file-blob cache. Kept conservative so trip
* documents never starve the map-tile cache (sized at MAX_TILES in
* tilePrefetcher.ts) for the origin's storage quota.
*/
export const BLOB_CACHE_MAX_ENTRIES = 200;
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
/**
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
* is a reasonable proxy for access for write-once document blobs.
*/
export async function enforceBlobBudget(
maxCount = BLOB_CACHE_MAX_ENTRIES,
maxBytes = BLOB_CACHE_MAX_BYTES,
): Promise<void> {
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
let count = entries.length;
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
if (count <= maxCount && totalBytes <= maxBytes) return;
const toDelete: string[] = [];
for (const e of entries) {
if (count <= maxCount && totalBytes <= maxBytes) break;
toDelete.push(e.url);
totalBytes -= e.bytes ?? 0;
count -= 1;
}
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
}
// ── Eviction / cleanup ──────────────────────────────────────────────────────── // ── Eviction / cleanup ────────────────────────────────────────────────────────
/** Delete all cached data for one trip (eviction or explicit clear). */ /** Delete all cached data for one trip (eviction or explicit clear). */
@@ -316,7 +166,6 @@ export async function clearTripData(tripId: number): Promise<void> {
offlineDb.tripMembers, offlineDb.tripMembers,
offlineDb.mutationQueue, offlineDb.mutationQueue,
offlineDb.syncMeta, offlineDb.syncMeta,
offlineDb.blobCache,
], ],
async () => { async () => {
await offlineDb.days.where('trip_id').equals(tripId).delete(); await offlineDb.days.where('trip_id').equals(tripId).delete();
@@ -330,7 +179,6 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.tripMembers.where('tripId').equals(tripId).delete(); await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete(); await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
await offlineDb.syncMeta.where('tripId').equals(tripId).delete(); await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
}, },
); );
// Remove the trip row itself outside the transaction since it's a separate table // Remove the trip row itself outside the transaction since it's a separate table
-31
View File
@@ -1,31 +0,0 @@
import { useEffect, useState } from 'react'
import { airtrailApi } from '../api/client'
import { useAddonStore } from '../store/addonStore'
/**
* Resolves whether the current user can use AirTrail in a trip: the addon must
* be enabled globally AND the user must have a working connection. Drives the
* "AirTrail Import/Sync" button visibility in the Transport panel.
*/
export function useAirtrailConnection() {
const airtrailEnabled = useAddonStore(s => s.isEnabled('airtrail'))
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!airtrailEnabled) {
setConnected(false)
return
}
let cancelled = false
setLoading(true)
airtrailApi
.status()
.then(d => { if (!cancelled) setConnected(!!d.connected) })
.catch(() => { if (!cancelled) setConnected(false) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [airtrailEnabled])
return { airtrailEnabled, connected, available: airtrailEnabled && connected, loading }
}
+8 -25
View File
@@ -53,44 +53,29 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
return pos != null return pos != null
}) })
// The departure/arrival coordinate of a transport, if its endpoints carry one. // Build a unified list of places + transports sorted by effective position,
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => { // then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
const e = (r.endpoints || []).find((x: any) => x.role === role) type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null const entries: (Entry & { pos: number })[] = [
}
// 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[] = [
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({ ...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, kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
})), })),
...dayTransports.map(r => ({ ...dayTransports.map(r => ({
kind: 'transport' as const, 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, pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
})), })),
].sort((a, b) => a.pos - b.pos) ].sort((a, b) => a.pos - b.pos)
// Group located places into driving runs. // Group consecutive located places into runs, resetting whenever a transport
// - A transport WITH a location anchors the route to its departure point (you // appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
// 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.
const runs: { lat: number; lng: number }[][] = [] const runs: { lat: number; lng: number }[][] = []
let currentRun: { lat: number; lng: number }[] = [] let currentRun: { lat: number; lng: number }[] = []
for (const entry of entries) { for (const entry of entries) {
if (entry.kind === 'place') { if (entry.kind === 'place') {
currentRun.push({ lat: entry.lat, lng: entry.lng }) currentRun.push({ lat: entry.lat, lng: entry.lng })
} else if (entry.from || entry.to) { } else {
if (entry.from) currentRun.push(entry.from)
if (currentRun.length >= 2) runs.push(currentRun) if (currentRun.length >= 2) runs.push(currentRun)
currentRun = [] currentRun = []
if (entry.to) currentRun.push(entry.to)
} }
} }
if (currentRun.length >= 2) runs.push(currentRun) 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)) .filter(r => TRANSPORT_TYPES.includes(r.type))
.map(r => { .map(r => {
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position 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. return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
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}`
}) })
.sort() .sort()
.join('|') .join('|')
-17
View File
@@ -35,23 +35,6 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
color: var(--text-primary) !important; color: var(--text-primary) !important;
} }
/* Mapbox GL hover popup the name/category/address card on marker hover.
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
onto the popup never steals the marker's mouseleave and causes flicker. */
.trek-map-popup { pointer-events: none; }
.trek-map-popup .mapboxgl-popup-content {
padding: 7px 10px;
border-radius: 10px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
}
.trek-map-popup .mapboxgl-popup-tip {
border-top-color: #fff;
border-bottom-color: #fff;
border-left-color: #fff;
border-right-color: #fff;
}
.atlas-tooltip { .atlas-tooltip {
background: rgba(10, 10, 20, 0.6) !important; background: rgba(10, 10, 20, 0.6) !important;
backdrop-filter: blur(20px) saturate(180%) !important; backdrop-filter: blur(20px) saturate(180%) !important;
-3
View File
@@ -15,11 +15,8 @@ import '@fontsource/geist-sans/500.css'
import '@fontsource/geist-sans/600.css' import '@fontsource/geist-sans/600.css'
import './index.css' import './index.css'
import { startConnectivityProbe } from './sync/connectivity' import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage'
startConnectivityProbe() startConnectivityProbe()
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
requestPersistentStorage()
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+145 -51
View File
@@ -175,9 +175,6 @@ function useDefaultAtlasHandlers() {
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)), http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })), http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: {} })), 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) // Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })), http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
); );
@@ -190,6 +187,18 @@ beforeEach(() => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) }); 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(); useDefaultAtlasHandlers();
}); });
@@ -460,9 +469,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => { describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
it('typing in search updates the input value', async () => { it('typing in search updates the input value', async () => {
// Override fetch to return GeoJSON with FR feature // Override fetch to return GeoJSON with FR feature
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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(); const user = userEvent.setup();
render(<AtlasPage />); render(<AtlasPage />);
@@ -503,9 +519,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-019: confirm popup shows via Enter on search with GeoJSON', () => { 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 () => { it('pressing Enter in search with matching GeoJSON result triggers confirm popup', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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( server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })), 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', () => { 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 () => { it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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(); const user = userEvent.setup();
render(<AtlasPage />); render(<AtlasPage />);
@@ -612,9 +642,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-031: confirm popup opens and mark-visited action works', () => { 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 () => { it('opens confirm popup via search and clicking Mark as visited closes it', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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( server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })), 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', () => { 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 () => { it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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(); const user = userEvent.setup();
render(<AtlasPage />); render(<AtlasPage />);
@@ -807,9 +851,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-029: confirm popup opens via search dropdown click', () => { 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 () => { it('clicking a country in the search dropdown opens the confirm action popup', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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( server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })), 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', () => { describe('FE-PAGE-ATLAS-030: confirm popup overlay click closes it', () => {
it('clicking the overlay backdrop closes the confirm popup', async () => { it('clicking the overlay backdrop closes the confirm popup', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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(); const user = userEvent.setup();
render(<AtlasPage />); 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 }, { type: 'Feature', properties: { ISO_A2: 'DE', ADM0_A3: 'DEU', ISO_A3: 'DEU', NAME: 'Germany', ADMIN: 'Germany' }, geometry: null },
], ],
}; };
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandDE)), 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 />); render(<AtlasPage />);
@@ -961,9 +1023,13 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => { describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
it('clicking France dropdown button covers onClick and mouse event handlers', async () => { it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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( server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })), 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.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })), http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
); );
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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(); const user = userEvent.setup();
render(<AtlasPage />); render(<AtlasPage />);
@@ -1088,9 +1158,13 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => { describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
it('submits a bucket list item from the confirm popup', async () => { it('submits a bucket list item from the confirm popup', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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( server.use(
http.post('/api/addons/atlas/bucket-list', () => http.post('/api/addons/atlas/bucket-list', () =>
@@ -1247,9 +1321,13 @@ describe('AtlasPage', () => {
}, },
], ],
}; };
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithXK)), 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 />); render(<AtlasPage />);
@@ -1267,9 +1345,13 @@ describe('AtlasPage', () => {
{ a3: 'FRA', name: 'France', query: 'france' }, { a3: 'FRA', name: 'France', query: 'france' },
{ a3: 'NOR', name: 'Norway', query: 'norway' }, { 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 }) => { ])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(makeGeoJsonWithA3Fallback(a3, name))), 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(); const user = userEvent.setup();
render(<AtlasPage />); render(<AtlasPage />);
@@ -1377,9 +1459,13 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-044: direct France dropdown button click', () => { 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 () => { it('directly finds and clicks the France button in the dropdown to cover onClick', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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( server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })), 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', () => { 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 () => { it('switching to dark mode re-initializes map and covers region loading code path', async () => {
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), 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( server.use(
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })), 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 }, { type: 'Feature', properties: { ISO_A2: 'IT', ADM0_A3: 'ITA', ISO_A3: 'ITA', NAME: 'Italy', ADMIN: 'Italy' }, geometry: null },
], ],
}; };
server.use( vi.spyOn(global, 'fetch').mockImplementation((url) => {
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandIT)), 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 />); render(<AtlasPage />);
+5 -5
View File
@@ -226,7 +226,7 @@ describe('DashboardPage', () => {
await user.click(archiveButtons[0]); await user.click(archiveButtons[0]);
// Switch to the archive filter segment // Switch to the archive filter segment
await user.click(screen.getByText('Archived')); await user.click(screen.getByText('Archive'));
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
@@ -293,7 +293,7 @@ describe('DashboardPage', () => {
}); });
// Switch to the archive filter // Switch to the archive filter
await user.click(screen.getByText('Archived')); await user.click(screen.getByText('Archive'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument(); expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
@@ -442,7 +442,7 @@ describe('DashboardPage', () => {
}); });
// Switch to the archive filter // Switch to the archive filter
await user.click(screen.getByText('Archived')); await user.click(screen.getByText('Archive'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument(); expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
@@ -644,7 +644,7 @@ describe('DashboardPage', () => {
}); });
// Archive filter reveals the archived trip // Archive filter reveals the archived trip
await user.click(screen.getByText('Archived')); await user.click(screen.getByText('Archive'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument(); expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
}); });
@@ -687,7 +687,7 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument(); expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
}); });
await user.click(screen.getByText('Archived')); await user.click(screen.getByText('Archive'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Restored Trip')).toBeInTheDocument(); expect(screen.getByText('Restored Trip')).toBeInTheDocument();
+5 -2
View File
@@ -16,7 +16,7 @@ import {
import { import {
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin, Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar, Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, Ticket, X, LayoutGrid, List, SlidersHorizontal, Ticket, X,
} from 'lucide-react' } from 'lucide-react'
import '../styles/dashboard.css' import '../styles/dashboard.css'
@@ -120,12 +120,15 @@ export default function DashboardPage(): React.ReactElement {
<div className="sec-tools"> <div className="sec-tools">
<div className="seg"> <div className="seg">
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button> <button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button> <button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button> <button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
</div> </div>
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}> <button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />} {viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
</button> </button>
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
<SlidersHorizontal size={17} />
</button>
</div> </div>
</div> </div>
-32
View File
@@ -103,38 +103,6 @@ describe('LoginPage', () => {
}); });
}); });
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
it('renders an unchecked checkbox and forwards remember_me: true when ticked', async () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/auth/login', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
const checkbox = screen.getByRole('checkbox', { name: /remember me/i });
expect(checkbox).not.toBeChecked();
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
});
});
});
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => { describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
it('shows a Register button to switch to registration mode', async () => { it('shows a Register button to switch to registration mode', async () => {
// Default appConfig has allow_registration: true, has_users: true // Default appConfig has allow_registration: true, has_users: true
+2 -12
View File
@@ -9,7 +9,7 @@ export default function LoginPage(): React.ReactElement {
const { const {
navigate, navigate,
mode, setMode, mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword, username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken, isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal, langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
@@ -491,7 +491,6 @@ export default function LoginPage(): React.ReactElement {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
placeholder="000000 or XXXX-XXXX" placeholder="000000 or XXXX-XXXX"
required required
autoFocus
style={inputBase} style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'} onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'} onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
@@ -572,16 +571,7 @@ export default function LoginPage(): React.ReactElement {
</button> </button>
</div> </div>
{mode === 'login' && ( {mode === 'login' && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}> <div style={{ textAlign: 'right', marginTop: 6 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500 }}>
<input
type="checkbox"
checked={rememberMe}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRememberMe(e.target.checked)}
style={{ width: 15, height: 15, accentColor: '#111827', cursor: 'pointer', flexShrink: 0 }}
/>
{t('login.rememberMe')}
</label>
<button type="button" onClick={() => navigate('/forgot-password')} style={{ <button type="button" onClick={() => navigate('/forgot-password')} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit', color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
+4 -31
View File
@@ -5,7 +5,6 @@ import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore' import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto' import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService' import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -18,7 +17,6 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal' import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal' import { TransportModal } from '../components/Planner/TransportModal'
import BookingImportModal from '../components/Planner/BookingImportModal' import BookingImportModal from '../components/Planner/BookingImportModal'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon // MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel' import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel' import PackingListPanel from '../components/Packing/PackingListPanel'
@@ -44,8 +42,6 @@ import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types' import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react' import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
import { useTripPlanner } from './tripPlanner/useTripPlanner' 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[] }) { function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => { const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
@@ -57,7 +53,6 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0) const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
const [addTodoSignal, setAddTodoSignal] = useState(0) const [addTodoSignal, setAddTodoSignal] = useState(0)
const { t } = useTranslation() const { t } = useTranslation()
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
const tabs = [ const tabs = [
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length }, { id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
@@ -126,7 +121,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
className={`${sharedBtnClass} bg-accent text-accent-text`} className={`${sharedBtnClass} bg-accent text-accent-text`}
style={sharedBtnStyle} style={sharedBtnStyle}
/> />
{isAdmin && packingItems.length > 0 && ( {packingItems.length > 0 && (
<button onClick={() => setSaveTemplateSignal(s => s + 1)} <button onClick={() => setSaveTemplateSignal(s => s + 1)}
className={`${sharedBtnClass} bg-accent text-accent-text`} className={`${sharedBtnClass} bg-accent text-accent-text`}
style={sharedBtnStyle} style={sharedBtnStyle}
@@ -189,7 +184,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportAvailable, showBookingImport, setShowBookingImport, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
bookingForAssignmentId, setBookingForAssignmentId, bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId, transportModalDayId, setTransportModalDayId,
@@ -200,18 +194,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter, isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces, expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle, handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation, handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces, selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone, mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
} = useTripPlanner() } = useTripPlanner()
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
if (isLoading || !splashDone) { if (isLoading || !splashDone) {
return ( return (
<div className="bg-surface" style={{ <div className="bg-surface" style={{
@@ -309,20 +299,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = reservations.find(x => x.id === rid) const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r) if (r) setMapTransportDetail(r)
}} }}
pois={poi.pois}
onPoiClick={openAddPlaceFromPoi}
onViewportChange={poi.onViewportChange}
onMapReady={setGlMap}
/> />
{(poiPillEnabled || glMap) && (
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
{poiPillEnabled && (
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
)}
{glMap && <MapCompassPill map={glMap} />}
</div>
)}
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}> <div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<button onClick={() => setLeftCollapsed(c => !c)} <button onClick={() => setLeftCollapsed(c => !c)}
@@ -363,8 +341,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
onSelectDay={handleSelectDay} onSelectDay={handleSelectDay}
onPlaceClick={handlePlaceClick} onPlaceClick={handlePlaceClick}
onReorder={handleReorder} onReorder={handleReorder}
onReorderDays={handleReorderDays}
onAddDay={handleAddDay}
onUpdateDayTitle={handleUpdateDayTitle} onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay} onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }}
@@ -616,7 +592,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {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 }} showRouteToolsWhenExpanded /> ? <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 }} /> : <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> </div>
@@ -636,8 +612,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments} assignments={assignments}
files={files} files={files}
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }} onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
onAirTrailImport={() => setShowAirTrailImport(true)}
airTrailAvailable={airTrailAvailable}
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }} onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
onDelete={handleDeleteReservation} onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')} onNavigateToFiles={() => handleTabChange('dateien')}
@@ -707,7 +681,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} /> <ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />} {showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} /> <BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog <ConfirmDialog
isOpen={!!deletePlaceId} isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)} onClose={() => setDeletePlaceId(null)}
+15 -39
View File
@@ -2,7 +2,7 @@ import React from 'react'
import { adminApi } from '../../api/client' import { adminApi } from '../../api/client'
import Modal from '../../components/shared/Modal' import Modal from '../../components/shared/Modal'
import CustomSelect from '../../components/shared/CustomSelect' import CustomSelect from '../../components/shared/CustomSelect'
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react' import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin' import type { useAdmin } from './useAdmin'
@@ -22,8 +22,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt, showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
handleCreateUser, handleSaveUser, handleCreateUser, handleSaveUser,
} = admin } = admin
const [showCreatePw, setShowCreatePw] = React.useState(false)
const [showEditPw, setShowEditPw] = React.useState(false)
return ( return (
<> <>
@@ -73,24 +71,13 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
<div className="relative"> <input
<input type="password"
type={showCreatePw ? 'text' : 'password'} value={createForm.password}
value={createForm.password} onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))} placeholder={t('common.password')}
placeholder={t('common.password')} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
/>
<button
type="button"
onClick={() => setShowCreatePw(v => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
@@ -151,24 +138,13 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
<div className="relative"> <input
<input type="password"
type={showEditPw ? 'text' : 'password'} value={editForm.password}
value={editForm.password} onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))} placeholder={t('admin.newPasswordPlaceholder')}
placeholder={t('admin.newPasswordPlaceholder')} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
/>
<button
type="button"
onClick={() => setShowEditPw(v => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
+4 -4
View File
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own. // create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement { export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
const { const {
hour12, currentUser, serverTimezone, hour12, currentUser,
users, isLoading, users, isLoading,
setShowCreateUser, setShowCreateUser,
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
</span> </span>
</td> </td>
<td className="px-5 py-3 text-sm text-slate-500"> <td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString(locale)} {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
</td> </td>
<td className="px-5 py-3 text-sm text-slate-500"> <td className="px-5 py-3 text-sm text-slate-500">
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'} {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
</td> </td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
</div> </div>
<div className="text-xs text-slate-400 mt-0.5"> <div className="text-xs text-slate-400 mt-0.5">
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')} {inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`} {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`} {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
</div> </div>
</div> </div>
+7 -8
View File
@@ -132,19 +132,18 @@ export function useAtlas() {
}).catch(() => setLoading(false)) }).catch(() => setLoading(false))
}, []) }, [])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side — // Load GeoJSON world data (direct GeoJSON, no conversion needed)
// no third-party fetch from the browser).
useEffect(() => { useEffect(() => {
apiClient.get('/addons/atlas/countries/geo') fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(res => { .then(r => r.json())
const geo = res.data .then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON // Dynamically build A2→A3 mapping from GeoJSON
for (const f of geo.features) { for (const f of geo.features) {
const a2 = f.properties?.ISO_A2 const a2 = f.properties?.ISO_A2
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3 const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
// Only accept clean 2-letter ISO codes and never overwrite an existing // Only real 2-letter ISO codes: natural-earth uses subdivision-style
// mapping: some datasets carry subdivision-style values like "CN-TW" for // values like "CN-TW" for Taiwan, which would otherwise overwrite the
// Taiwan, which would clobber the legitimate TWN->TW entry (#1049). // legitimate TWN->TW reverse mapping and break the country (#1049).
if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) { if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
A2_TO_A3[a2] = a3 A2_TO_A3[a2] = a3
} }
+3 -4
View File
@@ -37,7 +37,6 @@ export function useLogin() {
const [username, setUsername] = useState<string>('') const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('') const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('') const [password, setPassword] = useState<string>('')
const [rememberMe, setRememberMe] = useState<boolean>(false)
const [showPassword, setShowPassword] = useState<boolean>(false) const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false) const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
@@ -243,7 +242,7 @@ export function useLogin() {
setIsLoading(false) setIsLoading(false)
return return
} }
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe) const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
if ('user' in mfaResult && mfaResult.user?.must_change_password) { if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password) setSavedLoginPassword(password)
setPasswordChangeStep(true) setPasswordChangeStep(true)
@@ -259,7 +258,7 @@ export function useLogin() {
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return } if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
await register(username, email, password, inviteToken || undefined) await register(username, email, password, inviteToken || undefined)
} else { } else {
const result = await login(email, password, rememberMe) const result = await login(email, password)
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) { if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token) setMfaToken(result.mfa_token)
setMfaStep(true) setMfaStep(true)
@@ -290,7 +289,7 @@ export function useLogin() {
return { return {
navigate, navigate,
mode, setMode, mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword, username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken, isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal, langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
+1 -2
View File
@@ -16,8 +16,7 @@ export function useSettings() {
const memoriesEnabled = addonEnabled('memories') const memoriesEnabled = addonEnabled('memories')
const mcpEnabled = addonEnabled('mcp') const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail') const hasIntegrations = memoriesEnabled || mcpEnabled
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
const [appVersion, setAppVersion] = useState<string | null>(null) const [appVersion, setAppVersion] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('display') const [activeTab, setActiveTab] = useState('display')
+13 -56
View File
@@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast' import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo' import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb } from '../../db/offlineDb' import { offlineDb } from '../../db/offlineDb'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
@@ -16,7 +16,6 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation' import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory' import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types' import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
/** /**
@@ -124,7 +123,7 @@ export function useTripPlanner() {
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false) const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false) const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null) 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 [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@@ -141,18 +140,6 @@ export function useTripPlanner() {
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null) const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [showBookingImport, setShowBookingImport] = useState<boolean>(false) const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false) const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
const { available: airTrailAvailable } = useAirtrailConnection()
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(false)
// Pull this user's AirTrail edits as soon as they open the trip, so changes
// made in AirTrail show up without waiting for the background poll.
const airtrailSyncedRef = useRef<number | null>(null)
useEffect(() => {
if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return
airtrailSyncedRef.current = tripId
airtrailApi.sync()
.then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) })
.catch(() => {})
}, [airTrailAvailable, tripId, tripActions])
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null) const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
const [showTransportModal, setShowTransportModal] = useState<boolean>(false) const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null) const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
@@ -221,12 +208,11 @@ export function useTripPlanner() {
} }
}, [isLoading, places]) }, [isLoading, places])
// Load the trip. loadTrip hydrates every trip-scoped slice (days, places, // Load trip + files (needed for place inspector file section)
// packing, todo, budget, reservations, files) so offline hydration is uniform
// and there's no cross-trip bleed; members/accommodations load alongside.
useEffect(() => { useEffect(() => {
if (tripId) { if (tripId) {
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId)
loadAccommodations() loadAccommodations()
if (!navigator.onLine) { if (!navigator.onLine) {
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray() offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
@@ -241,6 +227,13 @@ export function useTripPlanner() {
} }
}, [tripId]) }, [tripId])
useEffect(() => {
if (tripId) {
tripActions.loadReservations(tripId)
tripActions.loadBudgetItems?.(tripId)
}
}, [tripId])
useTripWebSocket(tripId) useTripWebSocket(tripId)
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set()) const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
@@ -363,24 +356,6 @@ export function useTripPlanner() {
} catch { /* best effort */ } } catch { /* best effort */ }
}, [language]) }, [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 handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles const pendingFiles = data._pendingFiles
delete data._pendingFiles delete data._pendingFiles
@@ -548,23 +523,6 @@ export function useTripPlanner() {
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast]) }, [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 }) => { const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
try { try {
if (editingReservation) { if (editingReservation) {
@@ -673,7 +631,6 @@ export function useTripPlanner() {
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportAvailable, showBookingImport, setShowBookingImport, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
bookingForAssignmentId, setBookingForAssignmentId, bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId, transportModalDayId, setTransportModalDayId,
@@ -684,9 +641,9 @@ export function useTripPlanner() {
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter, isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces, expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle, handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation, handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces, selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone, mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
+8 -12
View File
@@ -1,20 +1,16 @@
import { accommodationsApi } from '../api/client' import { accommodationsApi } from '../api/client'
import { offlineDb, upsertAccommodations } from '../db/offlineDb' import { offlineDb, upsertAccommodations } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Accommodation } from '../types' import type { Accommodation } from '../types'
export const accommodationRepo = { export const accommodationRepo = {
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> { async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const accommodations = await offlineDb.accommodations
const result = await accommodationsApi.list(tripId) .where('trip_id').equals(Number(tripId)).toArray()
upsertAccommodations(result.accommodations || []).catch(() => {}) return { accommodations }
return result }
}, const result = await accommodationsApi.list(tripId)
async () => ({ upsertAccommodations(result.accommodations || []).catch(() => {})
accommodations: await offlineDb.accommodations return result
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
}, },
} }
+10 -12
View File
@@ -1,20 +1,18 @@
import { budgetApi } from '../api/client' import { budgetApi } from '../api/client'
import { offlineDb, upsertBudgetItems } from '../db/offlineDb' import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { BudgetItem } from '../types' import type { BudgetItem } from '../types'
export const budgetRepo = { export const budgetRepo = {
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> { async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.budgetItems
const result = await budgetApi.list(tripId) .where('trip_id')
upsertBudgetItems(result.items) .equals(Number(tripId))
return result .toArray()
}, return { items: cached }
async () => ({ }
items: await offlineDb.budgetItems const result = await budgetApi.list(tripId)
.where('trip_id').equals(Number(tripId)).toArray(), upsertBudgetItems(result.items)
}), return result
)
}, },
} }
+10 -14
View File
@@ -1,22 +1,18 @@
import { daysApi } from '../api/client' import { daysApi } from '../api/client'
import { offlineDb, upsertDays } from '../db/offlineDb' import { offlineDb, upsertDays } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Day } from '../types' import type { Day } from '../types'
export const dayRepo = { export const dayRepo = {
async list(tripId: number | string): Promise<{ days: Day[] }> { async list(tripId: number | string): Promise<{ days: Day[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.days
const result = await daysApi.list(tripId) .where('trip_id')
upsertDays(result.days) .equals(Number(tripId))
return result .sortBy('day_number' as keyof Day)
}, return { days: cached as Day[] }
async () => ({ }
days: (await offlineDb.days const result = await daysApi.list(tripId)
.where('trip_id') upsertDays(result.days)
.equals(Number(tripId)) return result
.sortBy('day_number' as keyof Day)) as Day[],
}),
)
}, },
} }
+10 -12
View File
@@ -1,20 +1,18 @@
import { filesApi } from '../api/client' import { filesApi } from '../api/client'
import { offlineDb, upsertTripFiles } from '../db/offlineDb' import { offlineDb, upsertTripFiles } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { TripFile } from '../types' import type { TripFile } from '../types'
export const fileRepo = { export const fileRepo = {
async list(tripId: number | string): Promise<{ files: TripFile[] }> { async list(tripId: number | string): Promise<{ files: TripFile[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.tripFiles
const result = await filesApi.list(tripId) .where('trip_id')
upsertTripFiles(result.files) .equals(Number(tripId))
return result .toArray()
}, return { files: cached }
async () => ({ }
files: await offlineDb.tripFiles const result = await filesApi.list(tripId)
.where('trip_id').equals(Number(tripId)).toArray(), upsertTripFiles(result.files)
}), return result
)
}, },
} }
+14 -21
View File
@@ -1,27 +1,25 @@
import { packingApi } from '../api/client' import { packingApi } from '../api/client'
import { offlineDb, upsertPackingItems } from '../db/offlineDb' import { offlineDb, upsertPackingItems } from '../db/offlineDb'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue' import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import { onlineThenCache } from './withOfflineFallback'
import type { PackingItem } from '../types' import type { PackingItem } from '../types'
export const packingRepo = { export const packingRepo = {
async list(tripId: number | string): Promise<{ items: PackingItem[] }> { async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.packingItems
const result = await packingApi.list(tripId) .where('trip_id')
upsertPackingItems(result.items) .equals(Number(tripId))
return result .toArray()
}, return { items: cached }
async () => ({ }
items: await offlineDb.packingItems const result = await packingApi.list(tripId)
.where('trip_id').equals(Number(tripId)).toArray(), upsertPackingItems(result.items)
}), return result
)
}, },
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> { async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
if (!navigator.onLine) { if (!navigator.onLine) {
const tempId = nextTempId() const tempId = -(Date.now())
const tempItem: PackingItem = { const tempItem: PackingItem = {
...(data as Partial<PackingItem>), ...(data as Partial<PackingItem>),
id: tempId, id: tempId,
@@ -53,16 +51,13 @@ export const packingRepo = {
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id } const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic) await offlineDb.packingItems.put(optimistic)
const mutId = generateUUID() const mutId = generateUUID()
const isTemp = id < 0
await mutationQueue.enqueue({ await mutationQueue.enqueue({
id: mutId, id: mutId,
tripId: Number(tripId), tripId: Number(tripId),
method: 'PUT', method: 'PUT',
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`, url: `/trips/${tripId}/packing/${id}`,
body: data, body: data,
resource: 'packingItems', resource: 'packingItems',
entityId: id,
...(isTemp ? { tempEntityId: id } : {}),
}) })
return { item: optimistic } return { item: optimistic }
} }
@@ -75,16 +70,14 @@ export const packingRepo = {
if (!navigator.onLine) { if (!navigator.onLine) {
await offlineDb.packingItems.delete(id) await offlineDb.packingItems.delete(id)
const mutId = generateUUID() const mutId = generateUUID()
const isTemp = id < 0
await mutationQueue.enqueue({ await mutationQueue.enqueue({
id: mutId, id: mutId,
tripId: Number(tripId), tripId: Number(tripId),
method: 'DELETE', method: 'DELETE',
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`, url: `/trips/${tripId}/packing/${id}`,
body: undefined, body: undefined,
resource: 'packingItems', resource: 'packingItems',
entityId: id, entityId: id,
...(isTemp ? { tempEntityId: id } : {}),
}) })
return { success: true } return { success: true }
} }
+15 -24
View File
@@ -1,27 +1,25 @@
import { placesApi } from '../api/client' import { placesApi } from '../api/client'
import { offlineDb, upsertPlaces } from '../db/offlineDb' import { offlineDb, upsertPlaces } from '../db/offlineDb'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue' import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import { onlineThenCache } from './withOfflineFallback'
import type { Place } from '../types' import type { Place } from '../types'
export const placeRepo = { export const placeRepo = {
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> { async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.places
const result = await placesApi.list(tripId, params) .where('trip_id')
upsertPlaces(result.places) .equals(Number(tripId))
return result .toArray()
}, return { places: cached }
async () => ({ }
places: await offlineDb.places const result = await placesApi.list(tripId, params)
.where('trip_id').equals(Number(tripId)).toArray(), upsertPlaces(result.places)
}), return result
)
}, },
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> { async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
if (!navigator.onLine) { if (!navigator.onLine) {
const tempId = nextTempId() const tempId = -(Date.now())
const tempPlace: Place = { const tempPlace: Place = {
...(data as Partial<Place>), ...(data as Partial<Place>),
id: tempId, id: tempId,
@@ -52,16 +50,13 @@ export const placeRepo = {
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) } const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
await offlineDb.places.put(optimistic) await offlineDb.places.put(optimistic)
const mutId = generateUUID() const mutId = generateUUID()
const isTemp = Number(id) < 0
await mutationQueue.enqueue({ await mutationQueue.enqueue({
id: mutId, id: mutId,
tripId: Number(tripId), tripId: Number(tripId),
method: 'PUT', method: 'PUT',
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`, url: `/trips/${tripId}/places/${id}`,
body: data, body: data,
resource: 'places', resource: 'places',
entityId: Number(id),
...(isTemp ? { tempEntityId: Number(id) } : {}),
}) })
return { place: optimistic } return { place: optimistic }
} }
@@ -74,16 +69,14 @@ export const placeRepo = {
if (!navigator.onLine) { if (!navigator.onLine) {
await offlineDb.places.delete(Number(id)) await offlineDb.places.delete(Number(id))
const mutId = generateUUID() const mutId = generateUUID()
const isTemp = Number(id) < 0
await mutationQueue.enqueue({ await mutationQueue.enqueue({
id: mutId, id: mutId,
tripId: Number(tripId), tripId: Number(tripId),
method: 'DELETE', method: 'DELETE',
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`, url: `/trips/${tripId}/places/${id}`,
body: undefined, body: undefined,
resource: 'places', resource: 'places',
entityId: Number(id), entityId: Number(id),
...(isTemp ? { tempEntityId: Number(id) } : {}),
}) })
return { success: true } return { success: true }
} }
@@ -97,16 +90,14 @@ export const placeRepo = {
await offlineDb.places.bulkDelete(ids) await offlineDb.places.bulkDelete(ids)
for (const id of ids) { for (const id of ids) {
const mutId = generateUUID() const mutId = generateUUID()
const isTemp = id < 0
await mutationQueue.enqueue({ await mutationQueue.enqueue({
id: mutId, id: mutId,
tripId: Number(tripId), tripId: Number(tripId),
method: 'DELETE', method: 'DELETE',
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`, url: `/trips/${tripId}/places/${id}`,
body: undefined, body: undefined,
resource: 'places', resource: 'places',
entityId: id, entityId: id,
...(isTemp ? { tempEntityId: id } : {}),
}) })
} }
return { deleted: ids, count: ids.length } return { deleted: ids, count: ids.length }
+10 -12
View File
@@ -1,20 +1,18 @@
import { reservationsApi } from '../api/client' import { reservationsApi } from '../api/client'
import { offlineDb, upsertReservations } from '../db/offlineDb' import { offlineDb, upsertReservations } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Reservation } from '../types' import type { Reservation } from '../types'
export const reservationRepo = { export const reservationRepo = {
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> { async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.reservations
const result = await reservationsApi.list(tripId) .where('trip_id')
upsertReservations(result.reservations) .equals(Number(tripId))
return result .toArray()
}, return { reservations: cached }
async () => ({ }
reservations: await offlineDb.reservations const result = await reservationsApi.list(tripId)
.where('trip_id').equals(Number(tripId)).toArray(), upsertReservations(result.reservations)
}), return result
)
}, },
} }
+10 -12
View File
@@ -1,20 +1,18 @@
import { todoApi } from '../api/client' import { todoApi } from '../api/client'
import { offlineDb, upsertTodoItems } from '../db/offlineDb' import { offlineDb, upsertTodoItems } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { TodoItem } from '../types' import type { TodoItem } from '../types'
export const todoRepo = { export const todoRepo = {
async list(tripId: number | string): Promise<{ items: TodoItem[] }> { async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.todoItems
const result = await todoApi.list(tripId) .where('trip_id')
upsertTodoItems(result.items) .equals(Number(tripId))
return result .toArray()
}, return { items: cached }
async () => ({ }
items: await offlineDb.todoItems const result = await todoApi.list(tripId)
.where('trip_id').equals(Number(tripId)).toArray(), upsertTodoItems(result.items)
}), return result
)
}, },
} }
+22 -31
View File
@@ -1,42 +1,33 @@
import { tripsApi } from '../api/client' import { tripsApi } from '../api/client'
import { offlineDb, upsertTrip } from '../db/offlineDb' import { offlineDb, upsertTrip } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Trip } from '../types' import type { Trip } from '../types'
export const tripRepo = { export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> { async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const all = await offlineDb.trips.toArray()
const [active, archived] = await Promise.all([ return {
tripsApi.list(), trips: all.filter(t => !t.is_archived),
tripsApi.list({ archived: 1 }), archivedTrips: all.filter(t => t.is_archived),
]) }
active.trips.forEach(t => upsertTrip(t)) }
archived.trips.forEach(t => upsertTrip(t)) const [active, archived] = await Promise.all([
return { trips: active.trips, archivedTrips: archived.trips } tripsApi.list(),
}, tripsApi.list({ archived: 1 }),
async () => { ])
const all = await offlineDb.trips.toArray() active.trips.forEach(t => upsertTrip(t))
return { archived.trips.forEach(t => upsertTrip(t))
trips: all.filter(t => !t.is_archived), return { trips: active.trips, archivedTrips: archived.trips }
archivedTrips: all.filter(t => t.is_archived),
}
},
)
}, },
async get(tripId: number | string): Promise<{ trip: Trip }> { async get(tripId: number | string): Promise<{ trip: Trip }> {
return onlineThenCache( if (!navigator.onLine) {
async () => { const cached = await offlineDb.trips.get(Number(tripId))
const result = await tripsApi.get(tripId) if (cached) return { trip: cached }
upsertTrip(result.trip) throw new Error('No cached trip data available offline')
return result }
}, const result = await tripsApi.get(tripId)
async () => { upsertTrip(result.trip)
const cached = await offlineDb.trips.get(Number(tripId)) return result
if (cached) return { trip: cached }
throw new Error('No cached trip data available offline')
},
)
}, },
} }
-48
View File
@@ -1,48 +0,0 @@
/**
* True when an error means the request never reached the server a network-level
* failure (offline, captive portal, proxy auth wall, dropped connection, CORS).
* Axios sets `response` only when the server actually replied; its absence (on an
* Axios error) means we never got one. A real HTTP error (4xx/5xx) HAS a response
* and must NOT be treated as a network failure the server spoke, so the caller
* needs to see it. Non-Axios errors are surfaced too.
*/
function isNetworkError(err: unknown): boolean {
const e = err as { isAxiosError?: boolean; response?: unknown } | null
return !!e && e.isAxiosError === true && e.response == null
}
/**
* Read-through cache pattern shared by every repo's read methods.
*
* Reads degrade to the local Dexie cache in two situations:
* 1. The browser reports it is offline (`navigator.onLine` false) skip the
* doomed request entirely.
* 2. The browser *thinks* it is online but the request fails at the network
* level a lying `navigator.onLine` on a captive portal, a dropped
* connection (H2). Rather than surfacing that (which blanks the trip even
* though a good cached copy exists), we fall back to the cache.
*
* We intentionally gate only on `navigator.onLine`, NOT the connectivity probe:
* the probe is a coarse global flag, and a single failed health check would
* otherwise force every read to the (possibly empty) cache even when the request
* itself would succeed. The network-error catch below covers the captive-portal
* case the probe was meant to.
*
* A genuine HTTP error (404/403/500 the server responded) is NOT swallowed: it
* is rethrown so callers can set error state, navigate away, etc.
*
* Writes must NOT use this they go through the mutation queue so failures are
* surfaced and retried, not silently swallowed.
*/
export async function onlineThenCache<T>(
onlineFn: () => Promise<T>,
cacheFn: () => Promise<T>,
): Promise<T> {
if (!navigator.onLine) return cacheFn()
try {
return await onlineFn()
} catch (err) {
if (isNetworkError(err)) return cacheFn()
throw err
}
}
+1 -3
View File
@@ -24,7 +24,6 @@ interface Addon {
interface AddonState { interface AddonState {
addons: Addon[] addons: Addon[]
bagTracking: boolean
loaded: boolean loaded: boolean
loadAddons: () => Promise<void> loadAddons: () => Promise<void>
isEnabled: (id: string) => boolean isEnabled: (id: string) => boolean
@@ -32,13 +31,12 @@ interface AddonState {
export const useAddonStore = create<AddonState>((set, get) => ({ export const useAddonStore = create<AddonState>((set, get) => ({
addons: [], addons: [],
bagTracking: false,
loaded: false, loaded: false,
loadAddons: async () => { loadAddons: async () => {
try { try {
const data = await addonsApi.enabled() const data = await addonsApi.enabled()
set({ addons: data.addons || [], bagTracking: !!data.bagTracking, loaded: true }) set({ addons: data.addons || [], loaded: true })
} catch { } catch {
set({ loaded: true }) set({ loaded: true })
} }
+16 -45
View File
@@ -5,9 +5,7 @@ import { connect, disconnect } from '../api/websocket'
import type { User } from '../types' import type { User } from '../types'
import { getApiErrorMessage } from '../types' import { getApiErrorMessage } from '../types'
import { tripSyncManager } from '../sync/tripSyncManager' import { tripSyncManager } from '../sync/tripSyncManager'
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb' import { clearAll } from '../db/offlineDb'
import { setAuthed } from '../sync/authGate'
import { unregisterSyncTriggers } from '../sync/syncTriggers'
import { useSystemNoticeStore } from './systemNoticeStore.js' import { useSystemNoticeStore } from './systemNoticeStore.js'
interface AuthResponse { interface AuthResponse {
@@ -39,10 +37,10 @@ interface AuthState {
placesAutocompleteEnabled: boolean placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean placesDetailsEnabled: boolean
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult> login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse> completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse> register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
logout: () => Promise<void> logout: () => void
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */ /** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
loadUser: (opts?: { silent?: boolean }) => Promise<void> loadUser: (opts?: { silent?: boolean }) => Promise<void>
updateMapsKey: (key: string | null) => Promise<void> updateMapsKey: (key: string | null) => Promise<void>
@@ -67,19 +65,6 @@ interface AuthState {
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state // Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
let authSequence = 0 let authSequence = 0
/**
* Mark the session authenticated and point the offline DB at this user's scoped
* database before any background sync runs, so cached data never crosses users.
*/
async function onAuthSuccess(userId: number): Promise<void> {
setAuthed(true)
try {
await reopenForUser(userId)
} catch (err) {
console.error('[auth] failed to open user-scoped offline DB', err)
}
}
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
@@ -99,11 +84,11 @@ export const useAuthStore = create<AuthState>()(
placesAutocompleteEnabled: true, placesAutocompleteEnabled: true,
placesDetailsEnabled: true, placesDetailsEnabled: true,
login: async (email: string, password: string, rememberMe?: boolean) => { login: async (email: string, password: string) => {
authSequence++ authSequence++
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string } const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
if (data.mfa_required && data.mfa_token) { if (data.mfa_required && data.mfa_token) {
set({ isLoading: false, error: null }) set({ isLoading: false, error: null })
return { mfa_required: true as const, mfa_token: data.mfa_token } return { mfa_required: true as const, mfa_token: data.mfa_token }
@@ -114,7 +99,6 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
error: null, error: null,
}) })
await onAuthSuccess(data.user.id)
connect() connect()
tripSyncManager.syncAll().catch(console.error) tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) { if (!data.user?.must_change_password) {
@@ -128,18 +112,17 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => { completeMfaLogin: async (mfaToken: string, code: string) => {
authSequence++ authSequence++
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe }) const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
set({ set({
user: data.user, user: data.user,
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
error: null, error: null,
}) })
await onAuthSuccess(data.user.id)
connect() connect()
tripSyncManager.syncAll().catch(console.error) tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) { if (!data.user?.must_change_password) {
@@ -164,7 +147,6 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
error: null, error: null,
}) })
await onAuthSuccess(data.user.id)
connect() connect()
tripSyncManager.syncAll().catch(console.error) tripSyncManager.syncAll().catch(console.error)
useSystemNoticeStore.getState().fetch() useSystemNoticeStore.getState().fetch()
@@ -176,27 +158,18 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
logout: async () => { logout: () => {
// 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB.
setAuthed(false)
set({ isAuthenticated: false })
// 2. Stop background sync triggers (30s interval, WS pre-reconnect hook, listeners).
unregisterSyncTriggers()
// 3. Tear down the live connection.
disconnect() disconnect()
useSystemNoticeStore.getState().reset() useSystemNoticeStore.getState().reset()
// 4. Tell server to clear the httpOnly cookie (best-effort). // Tell server to clear the httpOnly cookie
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
// 5. Clear service worker caches containing sensitive data. // Clear service worker caches containing sensitive data
if ('caches' in window) { if ('caches' in window) {
await Promise.all([ caches.delete('api-data').catch(() => {})
caches.delete('api-data').catch(() => {}), caches.delete('user-uploads').catch(() => {})
caches.delete('user-uploads').catch(() => {}),
])
} }
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB. // Purge all cached trip data from IndexedDB
await deleteCurrentUserDb().catch(console.error) clearAll().catch(console.error)
// 7. Finish clearing auth state.
set({ set({
user: null, user: null,
isAuthenticated: false, isAuthenticated: false,
@@ -216,7 +189,6 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}) })
await onAuthSuccess(data.user.id)
connect() connect()
} catch (err: unknown) { } catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore if (seq !== authSequence) return // stale response — ignore
@@ -310,7 +282,6 @@ export const useAuthStore = create<AuthState>()(
demoMode: true, demoMode: true,
error: null, error: null,
}) })
await onAuthSuccess(data.user.id)
connect() connect()
return data return data
} catch (err: unknown) { } catch (err: unknown) {
-2
View File
@@ -32,9 +32,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
temperature_unit: 'fahrenheit', temperature_unit: 'fahrenheit',
time_format: '12h', time_format: '12h',
show_place_description: false, show_place_description: false,
optimize_from_accommodation: true,
map_provider: 'leaflet', map_provider: 'leaflet',
map_poi_pill_enabled: true,
mapbox_access_token: '', mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard', mapbox_style: 'mapbox://styles/mapbox/standard',
mapbox_3d_enabled: true, 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'))
}
},
})
+14 -42
View File
@@ -193,34 +193,25 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
// Assignments // Assignments
case 'assignment:created': { case 'assignment:created': {
const incoming = payload.assignment as Assignment const dayKey = String((payload.assignment as Assignment).day_id)
const dayKey = String(incoming.day_id) const existing = (state.assignments[dayKey] || [])
const existing = state.assignments[dayKey] || [] const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).place_id
const placeId = incoming.place?.id ?? incoming.place_id if (existing.some(a => a.id === (payload.assignment as Assignment).id || (placeId && a.place?.id === placeId))) {
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
// Already have this exact assignment id → duplicate broadcast or the if (hasTempVersion) {
// echo of an already-committed assignment. No-op. return {
if (existing.some(a => a.id === incoming.id)) return {} assignments: {
...state.assignments,
// Reconcile our own optimistic create: replace the temp (negative-id) [dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
// assignment of the same place on this day with the real one. Guarded on }
// a real placeId so an assignment with no place can never collapse onto }
// another place-less one (undefined === undefined).
if (placeId != null) {
const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId)
if (tempIdx !== -1) {
const next = existing.slice()
next[tempIdx] = incoming
return { assignments: { ...state.assignments, [dayKey]: next } }
} }
return {}
} }
// Genuinely new — including a legitimate second assignment of a place
// already on this day (no temp version to reconcile). Append.
return { return {
assignments: { assignments: {
...state.assignments, ...state.assignments,
[dayKey]: [...existing, incoming], [dayKey]: [...existing, payload.assignment as Assignment],
} }
} }
} }
@@ -292,15 +283,6 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
dayNotes: newDayNotes, 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 // Day Notes
case 'dayNote:created': { case 'dayNote:created': {
@@ -460,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 // Write the change through to IndexedDB using the post-update state
writeToDexie(type, payload as Record<string, unknown>, get()) writeToDexie(type, payload as Record<string, unknown>, get())
} }
+1 -54
View File
@@ -7,12 +7,8 @@ import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo' import { placeRepo } from '../repo/placeRepo'
import { packingRepo } from '../repo/packingRepo' import { packingRepo } from '../repo/packingRepo'
import { todoRepo } from '../repo/todoRepo' import { todoRepo } from '../repo/todoRepo'
import { budgetRepo } from '../repo/budgetRepo'
import { reservationRepo } from '../repo/reservationRepo'
import { fileRepo } from '../repo/fileRepo'
import { createPlacesSlice } from './slices/placesSlice' import { createPlacesSlice } from './slices/placesSlice'
import { createAssignmentsSlice } from './slices/assignmentsSlice' import { createAssignmentsSlice } from './slices/assignmentsSlice'
import { createDaysSlice } from './slices/daysSlice'
import { createDayNotesSlice } from './slices/dayNotesSlice' import { createDayNotesSlice } from './slices/dayNotesSlice'
import { createPackingSlice } from './slices/packingSlice' import { createPackingSlice } from './slices/packingSlice'
import { createTodoSlice } from './slices/todoSlice' import { createTodoSlice } from './slices/todoSlice'
@@ -28,7 +24,6 @@ import type {
import { getApiErrorMessage } from '../types' import { getApiErrorMessage } from '../types'
import type { PlacesSlice } from './slices/placesSlice' import type { PlacesSlice } from './slices/placesSlice'
import type { AssignmentsSlice } from './slices/assignmentsSlice' import type { AssignmentsSlice } from './slices/assignmentsSlice'
import type { DaysSlice } from './slices/daysSlice'
import type { DayNotesSlice } from './slices/dayNotesSlice' import type { DayNotesSlice } from './slices/dayNotesSlice'
import type { PackingSlice } from './slices/packingSlice' import type { PackingSlice } from './slices/packingSlice'
import type { TodoSlice } from './slices/todoSlice' import type { TodoSlice } from './slices/todoSlice'
@@ -39,7 +34,6 @@ import type { FilesSlice } from './slices/filesSlice'
export interface TripStoreState export interface TripStoreState
extends PlacesSlice, extends PlacesSlice,
AssignmentsSlice, AssignmentsSlice,
DaysSlice,
DayNotesSlice, DayNotesSlice,
PackingSlice, PackingSlice,
TodoSlice, TodoSlice,
@@ -64,9 +58,7 @@ export interface TripStoreState
setSelectedDay: (dayId: number | null) => void setSelectedDay: (dayId: number | null) => void
handleRemoteEvent: (event: WebSocketEvent) => void handleRemoteEvent: (event: WebSocketEvent) => void
resetTrip: () => void
loadTrip: (tripId: number | string) => Promise<void> loadTrip: (tripId: number | string) => Promise<void>
hydrateActiveTrip: (tripId: number | string) => Promise<void>
refreshDays: (tripId: number | string) => Promise<void> refreshDays: (tripId: number | string) => Promise<void>
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip> updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag> addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
@@ -94,40 +86,15 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event), handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
// Clear every trip-scoped slice so switching trips (or losing access to one)
// can never leave a previous trip's data visible. Global tags/categories are
// left intact. Called at the top of loadTrip.
resetTrip: () => set({
trip: null,
days: [],
places: [],
assignments: {},
dayNotes: {},
packingItems: [],
todoItems: [],
budgetItems: [],
files: [],
reservations: [],
selectedDayId: null,
error: null,
}),
loadTrip: async (tripId: number | string) => { loadTrip: async (tripId: number | string) => {
get().resetTrip()
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
const [tripData, daysData, placesData, packingData, todoData, budgetData, reservationsData, filesData, tagsData, categoriesData] = await Promise.all([ const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
tripRepo.get(tripId), tripRepo.get(tripId),
dayRepo.list(tripId), dayRepo.list(tripId),
placeRepo.list(tripId), placeRepo.list(tripId),
packingRepo.list(tripId), packingRepo.list(tripId),
todoRepo.list(tripId), todoRepo.list(tripId),
// Budget / reservations / files are hydrated here too so the offline
// path is uniform (no separate tab-gated effects). Non-fatal: a failure
// in any of these must not blank the whole trip.
budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })),
reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })),
fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })),
navigator.onLine navigator.onLine
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags }))) ? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
: offlineDb.tags.toArray().then(tags => ({ tags })), : offlineDb.tags.toArray().then(tags => ({ tags })),
@@ -151,9 +118,6 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
dayNotes: dayNotesMap, dayNotes: dayNotesMap,
packingItems: packingData.items, packingItems: packingData.items,
todoItems: todoData.items, todoItems: todoData.items,
budgetItems: budgetData.items,
reservations: reservationsData.reservations,
files: filesData.files,
tags: tagsData.tags, tags: tagsData.tags,
categories: categoriesData.categories, categories: categoriesData.categories,
isLoading: false, isLoading: false,
@@ -165,22 +129,6 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
} }
}, },
// Silently re-fetch the active trip's collaborative state into the store after
// the network comes back (WS reconnect or `online` event) so edits missed while
// offline appear in place — no splash, no resetTrip. Each resource is
// best-effort; a failure on one must not wipe the others.
hydrateActiveTrip: async (tripId: number | string) => {
await Promise.all([
get().refreshDays(tripId),
placeRepo.list(tripId).then(d => set({ places: d.places })).catch(() => {}),
packingRepo.list(tripId).then(d => set({ packingItems: d.items })).catch(() => {}),
todoRepo.list(tripId).then(d => set({ todoItems: d.items })).catch(() => {}),
get().loadBudgetItems(tripId),
get().loadReservations(tripId),
get().loadFiles(tripId),
])
},
refreshDays: async (tripId: number | string) => { refreshDays: async (tripId: number | string) => {
try { try {
const daysData = await dayRepo.list(tripId) const daysData = await dayRepo.list(tripId)
@@ -236,7 +184,6 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
...createPlacesSlice(set, get), ...createPlacesSlice(set, get),
...createAssignmentsSlice(set, get), ...createAssignmentsSlice(set, get),
...createDaysSlice(set, get),
...createDayNotesSlice(set, get), ...createDayNotesSlice(set, get),
...createPackingSlice(set, get), ...createPackingSlice(set, get),
...createTodoSlice(set, get), ...createTodoSlice(set, get),
+3 -35
View File
@@ -378,12 +378,8 @@
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; } .trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; } .trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; } .trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; } .trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; }
/* Date rendered as a peer of the counts, set off by a vertical divider rather than .trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
floating alone at the far left. */
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; gap: 6px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 15px; font-weight: 600; color: var(--ink); }
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 28px; padding: 0 0 0 28px; border: none; border-left: 1px solid var(--line); }
.trek-dash .trip-card { .trek-dash .trip-card {
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg); position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
@@ -530,9 +526,6 @@
/* Hero — immersive cover, title only (the pass is its own card below) */ /* Hero — immersive cover, title only (the pass is its own card below) */
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); } .trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
/* No hover on touch — the lift/zoom just sticks after a tap and looks broken. */
.trek-dash .hero-trip:hover { transform: none; box-shadow: var(--sh-lg); }
.trek-dash .hero-trip:hover img.bg { transform: none; }
.trek-dash .hero-content { padding: 18px; } .trek-dash .hero-content { padding: 18px; }
/* the page already opens with the notification/profile strip, trim its top gap */ /* the page already opens with the notification/profile strip, trim its top gap */
.trek-dash .page { padding-top: 4px; } .trek-dash .page { padding-top: 4px; }
@@ -587,33 +580,8 @@
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; } .trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
.trek-dash .add-trip-card { min-height: 180px; } .trek-dash .add-trip-card { min-height: 180px; }
/* Touch devices have no hover keep the edit/copy/archive/delete actions
visible at all times instead of revealing them on hover. */
.trek-dash .trip-actions { opacity: 1; }
/* 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). */
/* Mobile list row stacked two-row: row 1 is a slim full-width cover banner
(image + title overlay + status top-left), row 2 is just the date, centred.
The counts stay grid-view-only on mobile. */
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
.trek-dash .trips.list-view .trip-name {
font-size: 18px; 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: center; padding: 10px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
.trek-dash .trips.list-view .trip-meta { display: none; }
/* Tools — stacked full-width cards (mockup) */ /* Tools — stacked full-width cards (mockup) */
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0 0 40px; padding: 0; } .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; } .trek-dash .page-sidebar .tool { flex: none; width: auto; }
} }
-18
View File
@@ -1,18 +0,0 @@
/**
* Auth gate a single boolean the sync layer checks before touching the
* offline DB. It lets logout disable all background sync (flush / syncAll /
* periodic triggers) *before* awaiting the DB swap, so an in-flight loop can't
* re-seed the database after the user has logged out.
*
* Kept separate from authStore to avoid an import cycle
* (authStore tripSyncManager authStore).
*/
let _authed = false
export function setAuthed(value: boolean): void {
_authed = value
}
export function isAuthed(): boolean {
return _authed
}
+10 -88
View File
@@ -7,7 +7,6 @@
*/ */
import { offlineDb } from '../db/offlineDb' import { offlineDb } from '../db/offlineDb'
import { apiClient } from '../api/client' import { apiClient } from '../api/client'
import { isAuthed } from './authGate'
import type { QueuedMutation } from '../db/offlineDb' import type { QueuedMutation } from '../db/offlineDb'
import type { Table } from 'dexie' import type { Table } from 'dexie'
@@ -40,27 +39,6 @@ let _flushing = false
// Monotonically increasing timestamp so same-millisecond enqueues // Monotonically increasing timestamp so same-millisecond enqueues
// still get a deterministic FIFO order when sorted by createdAt. // still get a deterministic FIFO order when sorted by createdAt.
let _lastTs = 0 let _lastTs = 0
// Monotonic counter for offline temp ids. Date.now() alone collides when two
// creates land in the same millisecond (bulk import, rapid tapping), which would
// overwrite one optimistic Dexie row. This guarantees distinct negative ids.
let _lastTempId = 0
/**
* Mint a collision-free temporary (negative) id for an offline-created entity.
* Monotonic across the session so same-millisecond creates never collide.
*/
export function nextTempId(): number {
const now = Date.now()
_lastTempId = now > _lastTempId ? now : _lastTempId + 1
return -_lastTempId
}
/** HTTP statuses that should be retried later rather than treated as terminal. */
function isRetryableStatus(status: number | undefined): boolean {
// 401: token expired mid-flush (offline window) — retry after re-auth.
// 408/425/429: timeout / too-early / rate-limited — transient.
return status === 401 || status === 408 || status === 425 || status === 429
}
export const mutationQueue = { export const mutationQueue = {
/** /**
@@ -89,12 +67,8 @@ export const mutationQueue = {
* 4xx responses are marked failed and skipped. * 4xx responses are marked failed and skipped.
*/ */
async flush(): Promise<void> { async flush(): Promise<void> {
if (_flushing || !navigator.onLine || !isAuthed()) return if (_flushing || !navigator.onLine) return
_flushing = true _flushing = true
// tempId → realId learned during this flush, so a dependent edit/delete
// queued against an offline-created entity (still holding the negative id)
// can be rewritten to the server id before it is replayed.
const idMap = new Map<number, number>()
try { try {
const pending = await offlineDb.mutationQueue const pending = await offlineDb.mutationQueue
.where('status') .where('status')
@@ -105,32 +79,10 @@ export const mutationQueue = {
// Mark as syncing so UI can show progress // Mark as syncing so UI can show progress
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' }) await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
// Resolve a temp-id reference now that earlier CREATEs in this flush
// may have completed (FIFO order guarantees the CREATE ran first).
let reqUrl = mutation.url
let reqEntityId = mutation.entityId
if (mutation.tempEntityId !== undefined) {
const realId = idMap.get(mutation.tempEntityId)
if (realId !== undefined) {
reqUrl = reqUrl.replace('{id}', String(realId))
reqEntityId = realId
}
}
// Placeholder still unresolved → the create it depended on is gone
// (failed or missing). Surface it as failed rather than firing a 404.
if (reqUrl.includes('{id}')) {
await offlineDb.mutationQueue.update(mutation.id, {
status: 'failed',
attempts: mutation.attempts + 1,
lastError: 'unresolved temp id (dependent create did not sync)',
})
continue
}
try { try {
const response = await apiClient.request({ const response = await apiClient.request({
method: mutation.method, method: mutation.method,
url: reqUrl, url: mutation.url,
data: mutation.body, data: mutation.body,
headers: { 'X-Idempotency-Key': mutation.id }, headers: { 'X-Idempotency-Key': mutation.id },
}) })
@@ -143,51 +95,31 @@ export const mutationQueue = {
const values = Object.values(response.data as Record<string, unknown>) const values = Object.values(response.data as Record<string, unknown>)
const entity = values[0] const entity = values[0]
if (entity && typeof entity === 'object' && 'id' in entity) { if (entity && typeof entity === 'object' && 'id' in entity) {
const realId = (entity as { id: number }).id // Remove temp optimistic entry if id changed (CREATE case)
// Remove temp optimistic entry if id changed (CREATE case) and if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) {
// remap any queued mutations that still target the negative id.
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
await table.delete(mutation.tempId) await table.delete(mutation.tempId)
idMap.set(mutation.tempId, realId)
// Durable rewrite so dependents survive a flush boundary / reload.
await offlineDb.mutationQueue
.where('tripId')
.equals(mutation.tripId)
.filter(m => m.tempEntityId === mutation.tempId)
.modify(m => {
m.url = m.url.replace('{id}', String(realId))
m.entityId = realId
m.tempEntityId = undefined
})
} }
await table.put(entity) await table.put(entity)
} }
} }
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) { } else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
// DELETE was already applied optimistically; ensure it's gone // DELETE was already applied optimistically; ensure it's gone
const table = getTable(mutation.resource) const table = getTable(mutation.resource)
if (table) await table.delete(reqEntityId) if (table) await table.delete(mutation.entityId)
} }
await offlineDb.mutationQueue.delete(mutation.id) await offlineDb.mutationQueue.delete(mutation.id)
} catch (err: unknown) { } catch (err: unknown) {
const httpStatus = (err as { response?: { status: number } })?.response?.status const httpStatus = (err as { response?: { status: number } })?.response?.status
const isTerminal = if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus) // Permanent client error — mark failed, continue with next
if (isTerminal) {
// Permanent client error — roll back the phantom optimistic CREATE so
// it can't masquerade as synced, then mark failed and continue.
if (mutation.method !== 'DELETE' && mutation.tempId !== undefined && mutation.resource) {
const table = getTable(mutation.resource)
if (table) await table.delete(mutation.tempId)
}
await offlineDb.mutationQueue.update(mutation.id, { await offlineDb.mutationQueue.update(mutation.id, {
status: 'failed', status: 'failed',
attempts: mutation.attempts + 1, attempts: mutation.attempts + 1,
lastError: String(err), lastError: String(err),
}) })
} else { } else {
// Network / transient error — reset to pending, abort flush (retry next trigger) // Network error — reset to pending, abort flush (retry on next trigger)
await offlineDb.mutationQueue.update(mutation.id, { await offlineDb.mutationQueue.update(mutation.id, {
status: 'pending', status: 'pending',
attempts: mutation.attempts + 1, attempts: mutation.attempts + 1,
@@ -228,19 +160,9 @@ export const mutationQueue = {
.count() .count()
}, },
/** Count permanently-failed mutations (surfaced separately so the user knows /** Reset internal flushing flag and timestamp counter — useful in tests. */
* changes were dropped they are NOT folded into pendingCount). */
async failedCount(): Promise<number> {
return offlineDb.mutationQueue
.where('status')
.equals('failed')
.count()
},
/** Reset internal flushing flag and timestamp counters — useful in tests. */
_resetFlushing(): void { _resetFlushing(): void {
_flushing = false _flushing = false
_lastTs = 0 _lastTs = 0
_lastTempId = 0
}, },
} }
-18
View File
@@ -1,18 +0,0 @@
/**
* Ask the browser for persistent storage so our offline data prefetched map
* tiles, cached file blobs, the IndexedDB caches is exempt from eviction under
* storage pressure. Without this the browser may purge tiles right when a
* traveler goes offline and needs them (audit H8 / M6).
*
* Best-effort and idempotent: returns whether persistence is (now) granted.
*/
export async function requestPersistentStorage(): Promise<boolean> {
try {
if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false
// Already persisted? Avoid re-prompting where the API distinguishes.
if (navigator.storage.persisted && (await navigator.storage.persisted())) return true
return await navigator.storage.persist()
} catch {
return false
}
}
+4 -27
View File
@@ -14,34 +14,17 @@
*/ */
import { mutationQueue } from './mutationQueue' import { mutationQueue } from './mutationQueue'
import { tripSyncManager } from './tripSyncManager' import { tripSyncManager } from './tripSyncManager'
import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket' import { setPreReconnectHook } from '../api/websocket'
import { useTripStore } from '../store/tripStore'
const PERIODIC_MS = 30_000 const PERIODIC_MS = 30_000
let _intervalId: ReturnType<typeof setInterval> | null = null let _intervalId: ReturnType<typeof setInterval> | null = null
let _registered = false let _registered = false
/** Pull the latest server state for every open trip into the Zustand store. */ /** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
function rehydrateActiveTrips() {
const store = useTripStore.getState()
for (const tripId of getActiveTrips()) {
store.hydrateActiveTrip(tripId).catch(console.error)
}
}
/**
* Network came back flush local writes first, then re-seed Dexie for all
* cacheable trips and re-hydrate the open trip's store so a collaborator's
* edits made while we were offline appear without navigating away.
*/
function onOnline() { function onOnline() {
mutationQueue.flush() mutationQueue.flush().catch(console.error)
.catch(console.error) tripSyncManager.syncAll().catch(console.error)
.finally(() => {
tripSyncManager.syncAll().catch(console.error)
rehydrateActiveTrips()
})
} }
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */ /** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
@@ -65,11 +48,6 @@ export function registerSyncTriggers(): void {
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate // WS reconnect: flush mutations only — no syncAll to avoid triggering rate
// limiters when the socket drops and reconnects while the device is online. // limiters when the socket drops and reconnects while the device is online.
setPreReconnectHook(() => mutationQueue.flush()) setPreReconnectHook(() => mutationQueue.flush())
// After the reconnect flush, pull canonical state for the open trip back into
// the store (the WS layer awaits the flush hook before invoking this).
setRefetchCallback(tripId => {
useTripStore.getState().hydrateActiveTrip(tripId).catch(console.error)
})
window.addEventListener('online', onOnline) window.addEventListener('online', onOnline)
document.addEventListener('visibilitychange', onVisibility) document.addEventListener('visibilitychange', onVisibility)
@@ -81,7 +59,6 @@ export function unregisterSyncTriggers(): void {
_registered = false _registered = false
setPreReconnectHook(null) setPreReconnectHook(null)
setRefetchCallback(null)
window.removeEventListener('online', onOnline) window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
if (_intervalId !== null) { if (_intervalId !== null) {
+12 -20
View File
@@ -17,18 +17,11 @@ import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */ /** Estimated average tile size in KB (road/transit tiles ~15 KB). */
const AVG_TILE_KB = 15 const AVG_TILE_KB = 15
/** /** Hard cap: ~50 MB worth of tiles. */
* Hard cap on prefetched tiles (~180 MB). export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413
*
* MUST stay in sync with the Workbox 'map-tiles' `maxEntries` in
* client/vite.config.js (kept equal). If this budget exceeds the SW cache size,
* the LRU evicts freshly-prefetched tiles on arrival and the offline map goes
* blank which is exactly the bug this value was raised (from ~3413) to fix.
*/
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
const DEFAULT_TILE_URL = const DEFAULT_TILE_URL =
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
@@ -184,16 +177,15 @@ export async function prefetchTilesForTrip(
const bbox = computeBbox(places) const bbox = computeBbox(places)
if (!bbox) return if (!bbox) return
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops // Size guard: if total tile count across all zooms exceeds cap, skip
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get const estimated = countTiles(bbox, 10, 16)
// their lower zooms cached instead of being skipped entirely. if (estimated > MAX_TILES) {
// console.warn(
// NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each `[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`,
// for quota accounting, so the real on-disk budget is far below 180 MB. We )
// keep no-cors deliberately: switching to cors would break self-hosted/custom return
// tile providers that don't send CORS headers. To stop the browser evicting }
// these tiles under the inflated quota, we request persistent storage at app
// init instead (sync/persistentStorage.ts).
const fetched = await prefetchTiles(bbox, template) const fetched = await prefetchTiles(bbox, template)
// Update syncMeta with bbox and tile count // Update syncMeta with bbox and tile count
+2 -7
View File
@@ -27,10 +27,8 @@ import {
upsertCategories, upsertCategories,
upsertSyncMeta, upsertSyncMeta,
clearTripData, clearTripData,
enforceBlobBudget,
} from '../db/offlineDb' } from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher' import { prefetchTilesForTrip } from './tilePrefetcher'
import { isAuthed } from './authGate'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types' import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
@@ -110,16 +108,13 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
const resp = await fetch(file.url!, { credentials: 'include' }) const resp = await fetch(file.url!, { credentials: 'include' })
if (!resp.ok) continue if (!resp.ok) continue
const blob = await resp.blob() const blob = await resp.blob()
await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() }) await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
cached++ cached++
} catch { } catch {
// Network failure — skip this file, will retry next sync // Network failure — skip this file, will retry next sync
} }
} }
// Keep the blob cache within its size/count budget after adding new files.
if (cached > 0) await enforceBlobBudget().catch(() => {})
// Update filesCachedCount in syncMeta // Update filesCachedCount in syncMeta
const tripId = files[0]?.trip_id const tripId = files[0]?.trip_id
if (tripId) { if (tripId) {
@@ -139,7 +134,7 @@ export const tripSyncManager = {
* No-ops when offline. * No-ops when offline.
*/ */
async syncAll(): Promise<void> { async syncAll(): Promise<void> {
if (_syncing || !navigator.onLine || !isAuthed()) return if (_syncing || !navigator.onLine) return
_syncing = true _syncing = true
try { try {
const { trips } = await tripsApi.list() as { trips: Trip[] } const { trips } = await tripsApi.list() as { trips: Trip[] }
-8
View File
@@ -113,8 +113,6 @@ export interface Settings {
show_place_description: boolean show_place_description: boolean
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_booking_labels?: boolean map_booking_labels?: boolean
map_poi_pill_enabled?: boolean
optimize_from_accommodation?: boolean
map_provider?: 'leaflet' | 'mapbox-gl' map_provider?: 'leaflet' | 'mapbox-gl'
mapbox_access_token?: string mapbox_access_token?: string
mapbox_style?: string mapbox_style?: string
@@ -164,12 +162,6 @@ export interface Waypoint {
lng: number 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 // User with optional OIDC fields
export interface UserWithOidc extends User { export interface UserWithOidc extends User {
oidc_issuer?: string | null oidc_issuer?: string | null
+4 -4
View File
@@ -126,18 +126,18 @@ describe('getMergedItems', () => {
expect(types).toEqual(['place', 'transport', 'place']) 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 = [ const dayAssignments = [
{ id: 1, order_index: 0, place: { place_time: '08:00' } }, { id: 1, order_index: 0, place: { place_time: '08:00' } },
{ id: 2, order_index: 1, place: { place_time: '13: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 — // Transport at 10:30 would normally go between the two places
// timed items are arranged chronologically even if an old manual position exists. // but per-day position 1.5 puts it after the second place
const dayTransports = [ const dayTransports = [
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } }, { 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 result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
const types = result.map(i => i.type) 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 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. */ /** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
export function getTransportForDay(opts: { export function getTransportForDay(opts: {
reservations: any[] reservations: any[]
dayId: number dayId: number
dayAssignmentIds: number[] dayAssignmentIds: number[]
days: Array<{ id: number; day_number?: number; date?: string | null }> days: Array<{ id: number; day_number?: number }>
}): any[] { }): any[] {
const { reservations, dayId, dayAssignmentIds, days } = opts const { reservations, dayId, dayAssignmentIds, days } = opts
@@ -123,34 +69,7 @@ export function getTransportForDay(opts: {
return thisDayOrder >= startOrder && thisDayOrder <= endOrder return thisDayOrder >= startOrder && thisDayOrder <= endOrder
} }
return startDayId === dayId 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. */ /** 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, minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
})).sort((a, b) => a.minutes - b.minutes) })).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) { 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 // 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 }) 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 => export const getDayOrder = (day: Day, days: Day[]): number =>
day.day_number ?? days.indexOf(day) 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 = ( export const isDayInAccommodationRange = (
day: Day, day: Day,
startDayId: number, startDayId: number,

Some files were not shown because too many files have changed in this diff Show More