mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ed00b67ad | |||
| 4d072b4cb8 | |||
| 028e3e0a84 | |||
| 39b5af790e | |||
| 1eb2cb8eb2 | |||
| bcd2c8c959 | |||
| 5a9c14fc8e | |||
| 5500405f2f | |||
| 0a794583d7 | |||
| 4188f67ab7 | |||
| 8077ffab34 | |||
| 3e9626fce9 | |||
| 3398da633b | |||
| 31f99f0e4e | |||
| 56655d53b4 | |||
| f91721c73e | |||
| 0a58e3270b | |||
| e224befde7 |
@@ -366,10 +366,10 @@ export const placesApi = {
|
||||
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)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
}
|
||||
@@ -487,6 +487,20 @@ export const addonsApi = {
|
||||
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 = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
@@ -559,8 +573,10 @@ export const mapsApi = {
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// 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 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
|
||||
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 = {
|
||||
|
||||
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
|
||||
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 {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
* OfflineBanner — connectivity + sync state indicator.
|
||||
*
|
||||
* States:
|
||||
* N failed → red pill "N changes failed to sync" (takes priority)
|
||||
* offline + N queued → amber pill "Offline · N queued"
|
||||
* offline + 0 queued → amber pill "Offline"
|
||||
* online + N pending → blue pill "Syncing N…"
|
||||
@@ -12,7 +13,7 @@
|
||||
* headers. On mobile it hovers just above the bottom tab bar.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 3_000
|
||||
@@ -20,6 +21,7 @@ const POLL_MS = 3_000
|
||||
export default function OfflineBanner(): React.ReactElement | null {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true)
|
||||
@@ -35,26 +37,36 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function poll() {
|
||||
const n = await mutationQueue.pendingCount()
|
||||
if (!cancelled) setPendingCount(n)
|
||||
const [n, failed] = await Promise.all([
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setPendingCount(n)
|
||||
setFailedCount(failed)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const hidden = isOnline && pendingCount === 0
|
||||
const hidden = isOnline && pendingCount === 0 && failedCount === 0
|
||||
if (hidden) return null
|
||||
|
||||
const offline = !isOnline
|
||||
const bg = offline ? '#92400e' : '#1e40af'
|
||||
// Failed mutations are the most important signal — they mean data was dropped.
|
||||
const failed = failedCount > 0
|
||||
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
|
||||
const text = '#fff'
|
||||
|
||||
const label = offline
|
||||
? pendingCount > 0
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount}…`
|
||||
const label = failed
|
||||
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
|
||||
: offline
|
||||
? pendingCount > 0
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount}…`
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -82,9 +94,11 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{offline
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
{failed
|
||||
? <AlertTriangle size={12} />
|
||||
: offline
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { MapViewGL } from './MapViewGL'
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
// 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
|
||||
export function MapViewAuto(props: any) {
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
|
||||
@@ -40,6 +40,12 @@ vi.mock('mapbox-gl', () => ({
|
||||
})),
|
||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||
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', () => ({}))
|
||||
|
||||
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
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 {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -53,6 +54,7 @@ interface Props {
|
||||
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 {
|
||||
@@ -167,6 +169,7 @@ export function MapViewGL({
|
||||
pois = [],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
@@ -186,10 +189,15 @@ export function MapViewGL({
|
||||
const onReservationClickRef = useRef(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 onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
@@ -212,6 +220,16 @@ export function MapViewGL({
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
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
|
||||
;(window as any).__trek_map = map
|
||||
|
||||
@@ -357,6 +375,8 @@ export function MapViewGL({
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||
onMapReadyRef.current?.(null)
|
||||
if (reservationOverlayRef.current) {
|
||||
reservationOverlayRef.current.destroy()
|
||||
reservationOverlayRef.current = null
|
||||
@@ -430,6 +450,10 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
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))
|
||||
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
@@ -450,6 +474,12 @@ export function MapViewGL({
|
||||
ev.stopPropagation()
|
||||
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 —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
@@ -471,11 +501,15 @@ export function MapViewGL({
|
||||
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.title = poi.name
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { RotateCw, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { POI_CATEGORIES } from './poiCategories'
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
@@ -15,8 +17,9 @@ interface Props {
|
||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||
// fill with the category colour (matching their markers); the label shows in a
|
||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
|
||||
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)',
|
||||
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
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',
|
||||
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
) : (
|
||||
<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 && active.size > 0 && (
|
||||
{(moved || anyError) && active.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchArea}
|
||||
@@ -76,10 +86,14 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
|
||||
{anyError
|
||||
? <AlertTriangle size={13} strokeWidth={2.4} />
|
||||
: <RotateCw size={13} strokeWidth={2.4} />}
|
||||
{t('poi.searchThisArea')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// 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>`
|
||||
}
|
||||
@@ -4,6 +4,12 @@ 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
|
||||
@@ -15,12 +21,18 @@ export function usePoiExplore() {
|
||||
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)
|
||||
@@ -28,19 +40,41 @@ export function usePoiExplore() {
|
||||
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)
|
||||
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 {
|
||||
} 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 {
|
||||
setLoading(key, false)
|
||||
// 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])
|
||||
}, [setLoading, setError])
|
||||
|
||||
const onViewportChange = useCallback((bbox: Bbox) => {
|
||||
bboxRef.current = bbox
|
||||
@@ -53,6 +87,11 @@ export function usePoiExplore() {
|
||||
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({})
|
||||
@@ -72,5 +111,5 @@ export function usePoiExplore() {
|
||||
|
||||
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
||||
|
||||
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
|
||||
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
|
||||
}
|
||||
|
||||
@@ -293,6 +293,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</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>` : ''}
|
||||
${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>` : ''}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const KAT_COLORS = [
|
||||
'#14b8a6', // teal
|
||||
]
|
||||
|
||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
|
||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
||||
|
||||
// A category's first item is seeded with this sentinel because the server
|
||||
// rejects empty names. Treat it as a placeholder in the UI.
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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,6 +2,7 @@ import {
|
||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
||||
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
|
||||
} 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 }
|
||||
@@ -27,6 +28,18 @@ export const NOTE_ICONS = [
|
||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
||||
{ 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]))
|
||||
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
||||
|
||||
@@ -1708,4 +1708,49 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
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)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,8 @@ interface DayPlanSidebarProps {
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
initialScrollTop?: number
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +127,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded = false,
|
||||
} = props
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -742,9 +745,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||
}
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!selectedDayId) return
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
const handleOptimize = async (dayId: number | null = selectedDayId) => {
|
||||
if (!dayId) return
|
||||
const da = getDayAssignments(dayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
@@ -764,7 +767,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const day = days.find(d => d.id === dayId)
|
||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||
? getAccommodationAnchors(day, days, accommodations)
|
||||
: {}
|
||||
@@ -781,10 +784,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||
}
|
||||
|
||||
await onReorder(selectedDayId, result.map(a => a.id))
|
||||
await onReorder(dayId, result.map(a => a.id))
|
||||
const usedHotel = !!(anchors.start || anchors.end)
|
||||
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
||||
const capturedDayId = selectedDayId
|
||||
const capturedDayId = dayId
|
||||
pushUndo?.(t('undo.optimize'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||
})
|
||||
@@ -901,6 +904,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1047,6 +1051,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -2096,7 +2101,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && 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={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button
|
||||
@@ -2112,7 +2117,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<RouteIcon size={12} strokeWidth={2} />
|
||||
{t('dayplan.route')}
|
||||
</button>
|
||||
<button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
|
||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -2141,7 +2146,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{routeInfo && (
|
||||
{isSelected && routeInfo && (
|
||||
<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 className="text-content-faint">·</span>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
||||
/>
|
||||
<textarea
|
||||
value={ui.time}
|
||||
maxLength={150}
|
||||
maxLength={250}
|
||||
rows={3}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
||||
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 }}
|
||||
/>
|
||||
<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 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 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={() => 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' }}>
|
||||
|
||||
@@ -225,16 +225,15 @@ export function DayPlanSidebarToolbar({
|
||||
<ArrowUpDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{reorderOpen && (
|
||||
<DayReorderPopup
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<DayReorderPopup
|
||||
isOpen={reorderOpen}
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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
|
||||
@@ -12,12 +14,12 @@ interface DayReorderPopupProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact panel for moving whole days around: drag a row by its grip or use the
|
||||
* up/down arrows, and add a day at the end. Day headers stay untouched — this is
|
||||
* the single surface for ordering. Reorders are applied optimistically by the
|
||||
* store, so the list reflects each move immediately.
|
||||
* 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({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
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)
|
||||
|
||||
@@ -41,97 +43,101 @@ export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose
|
||||
}
|
||||
|
||||
const cellBtn = {
|
||||
display: 'grid', placeItems: 'center', width: 26, height: 26,
|
||||
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 (
|
||||
<>
|
||||
{/* outside-click catcher */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
|
||||
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-faint)', borderRadius: 12,
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
|
||||
<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: 4, padding: '4px 9px',
|
||||
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} />
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</p>
|
||||
|
||||
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
|
||||
borderRadius: 8, marginTop: 2,
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
<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' }}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,31 @@ interface PlaceFormModalProps {
|
||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||
* 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) {
|
||||
const {
|
||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||
@@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
@@ -94,6 +120,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(DEFAULT_FORM)
|
||||
}
|
||||
setPendingFiles([])
|
||||
setDuplicateWarning(null)
|
||||
}, [place, prefillCoords, isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
@@ -309,6 +336,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast.error(t('places.nameRequired'))
|
||||
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)
|
||||
try {
|
||||
await onSave({
|
||||
@@ -381,6 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
handlePaste,
|
||||
hasTimeError,
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +480,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
handlePaste,
|
||||
hasTimeError,
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
} = S
|
||||
return (
|
||||
<Modal
|
||||
@@ -463,7 +503,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
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"
|
||||
>
|
||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
||||
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
export function ListImportModal(S: SidebarState) {
|
||||
const {
|
||||
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
||||
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||
} = S
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
|
||||
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' }}>
|
||||
<button
|
||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||
|
||||
@@ -179,6 +179,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{t('reservations.needsReview')}
|
||||
</span>
|
||||
) : 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 style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<span className="text-content" style={{
|
||||
@@ -472,6 +482,8 @@ interface ReservationsPanelProps {
|
||||
onAdd: () => void
|
||||
onImport?: () => void
|
||||
bookingImportAvailable?: boolean
|
||||
onAirTrailImport?: () => void
|
||||
airTrailAvailable?: boolean
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
@@ -479,7 +491,7 @@ interface ReservationsPanelProps {
|
||||
addManualKey?: string
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
@@ -602,6 +614,21 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
||||
</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={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
|
||||
@@ -77,9 +77,10 @@ interface WaypointForm {
|
||||
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: '' }
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -197,6 +198,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
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 {
|
||||
@@ -206,6 +208,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
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 ?? ''
|
||||
@@ -271,6 +274,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
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,
|
||||
@@ -279,6 +283,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
})
|
||||
}
|
||||
if (firstWp?.seat) metadata.seat = firstWp.seat
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
@@ -501,7 +506,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<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} />
|
||||
@@ -510,6 +515,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
export interface PlacesSidebarProps {
|
||||
@@ -49,6 +50,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
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 [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
@@ -94,6 +97,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [listImportUrl, setListImportUrl] = useState('')
|
||||
const [listImportLoading, setListImportLoading] = useState(false)
|
||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||
const [listImportEnrich, setListImportEnrich] = useState(false)
|
||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||
|
||||
@@ -108,9 +112,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
setListImportLoading(true)
|
||||
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||
try {
|
||||
const enrich = listImportEnrich && canEnrichImport
|
||||
const result = provider === 'google'
|
||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
||||
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
|
||||
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
|
||||
await loadTrip(tripId)
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
@@ -223,6 +228,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
scrollContainerRef, onScrollTopChange,
|
||||
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
||||
listImportLoading, listImportProvider, setListImportProvider,
|
||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh
|
||||
import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
|
||||
@@ -97,6 +98,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||
<McpTokenModals {...S} />
|
||||
<OAuthClientModals {...S} />
|
||||
@@ -109,6 +111,7 @@ function useIntegrations() {
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
@@ -289,7 +292,7 @@ function useIntegrations() {
|
||||
|
||||
|
||||
return {
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CachedTripRow {
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -28,11 +29,13 @@ export default function OfflineTab(): React.ReactElement {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending] = await Promise.all([
|
||||
const [metas, pending, failed] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
@@ -85,6 +88,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label="Cached trips" value={rows.length} />
|
||||
<Stat label="Pending changes" value={pendingCount} />
|
||||
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -165,13 +169,14 @@ export default function OfflineTab(): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-edge bg-surface-secondary" style={{
|
||||
padding: '8px 14px', borderRadius: 8,
|
||||
minWidth: 100,
|
||||
}}>
|
||||
<div className="text-content" style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
||||
className={danger ? undefined : 'text-content'}>{value}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -277,6 +277,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
const [desc, setDesc] = useState(item.description || '')
|
||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||
const [category, setCategory] = useState(item.category || '')
|
||||
const [addingCategory, setAddingCategoryInline] = useState(false)
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||
const [priority, setPriority] = useState(item.priority || 0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -378,21 +379,52 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('todo.detail.category')}</label>
|
||||
<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' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
{addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
||||
placeholder={t('todo.newCategory')}
|
||||
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' }}
|
||||
/>
|
||||
<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)' }}>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* Due date */}
|
||||
|
||||
+137
-3
@@ -27,6 +27,12 @@ export interface QueuedMutation {
|
||||
tempId?: number;
|
||||
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
||||
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 {
|
||||
@@ -41,13 +47,48 @@ export interface SyncMeta {
|
||||
export interface BlobCacheEntry {
|
||||
/** Relative URL, e.g. "/api/files/42/download" */
|
||||
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;
|
||||
/** 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;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
trips!: Table<Trip, number>;
|
||||
days!: Table<Day, number>;
|
||||
@@ -65,8 +106,8 @@ class TrekOfflineDb extends Dexie {
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
|
||||
constructor() {
|
||||
super('trek-offline');
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
|
||||
this.version(1).stores({
|
||||
trips: 'id',
|
||||
@@ -88,10 +129,67 @@ class TrekOfflineDb extends Dexie {
|
||||
tags: '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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const offlineDb = new TrekOfflineDb();
|
||||
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
|
||||
// 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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -166,6 +264,40 @@ export async function getCachedBlob(url: string): Promise<Blob | 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 ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
@@ -184,6 +316,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
offlineDb.tripMembers,
|
||||
offlineDb.mutationQueue,
|
||||
offlineDb.syncMeta,
|
||||
offlineDb.blobCache,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
@@ -197,6 +330,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.mutationQueue.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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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 }
|
||||
}
|
||||
@@ -35,6 +35,23 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
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 {
|
||||
background: rgba(10, 10, 20, 0.6) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
|
||||
@@ -15,8 +15,11 @@ import '@fontsource/geist-sans/500.css'
|
||||
import '@fontsource/geist-sans/600.css'
|
||||
import './index.css'
|
||||
import { startConnectivityProbe } from './sync/connectivity'
|
||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||
|
||||
startConnectivityProbe()
|
||||
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
||||
requestPersistentStorage()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('DashboardPage', () => {
|
||||
await user.click(archiveButtons[0]);
|
||||
|
||||
// Switch to the archive filter segment
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
@@ -293,7 +293,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
@@ -442,7 +442,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
@@ -644,7 +644,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Archive filter reveals the archived trip
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
||||
});
|
||||
@@ -687,7 +687,7 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import {
|
||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, SlidersHorizontal, Ticket, X,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
} from 'lucide-react'
|
||||
import '../styles/dashboard.css'
|
||||
|
||||
@@ -120,15 +120,12 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<div className="sec-tools">
|
||||
<div className="seg">
|
||||
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
|
||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||
</div>
|
||||
<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} />}
|
||||
</button>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
<SlidersHorizontal size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -491,6 +491,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||
placeholder="000000 or XXXX-XXXX"
|
||||
required
|
||||
autoFocus
|
||||
style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTripStore } from '../store/tripStore'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill } from '../components/Map/MapCompassPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
@@ -17,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import { TransportModal } from '../components/Planner/TransportModal'
|
||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
||||
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
@@ -187,6 +189,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
@@ -206,6 +209,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
} = 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) {
|
||||
@@ -308,11 +312,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
pois={poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
{poiPillEnabled && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
{(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>
|
||||
)}
|
||||
|
||||
@@ -608,7 +616,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} 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 />
|
||||
: <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>
|
||||
@@ -628,6 +636,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||
onAirTrailImport={() => setShowAirTrailImport(true)}
|
||||
airTrailAvailable={airTrailAvailable}
|
||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||
onDelete={handleDeleteReservation}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
@@ -697,6 +707,7 @@ 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} />
|
||||
{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} />
|
||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import Modal from '../../components/shared/Modal'
|
||||
import CustomSelect from '../../components/shared/CustomSelect'
|
||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
|
||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { useAdmin } from './useAdmin'
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||
handleCreateUser, handleSaveUser,
|
||||
} = admin
|
||||
const [showCreatePw, setShowCreatePw] = React.useState(false)
|
||||
const [showEditPw, setShowEditPw] = React.useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,13 +73,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
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"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCreatePw ? 'text' : 'password'}
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('common.password')}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
@@ -138,13 +151,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</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>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
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"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showEditPw ? 'text' : 'password'}
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
|
||||
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
||||
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
||||
const {
|
||||
serverTimezone, hour12, currentUser,
|
||||
hour12, currentUser,
|
||||
users, isLoading,
|
||||
setShowCreateUser,
|
||||
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||
</td>
|
||||
<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, timeZone: serverTimezone }) : '—'}
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
</div>
|
||||
<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.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,8 @@ export function useSettings() {
|
||||
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
|
||||
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('display')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -16,6 +16,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
|
||||
/**
|
||||
@@ -140,6 +141,18 @@ export function useTripPlanner() {
|
||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||
const [showBookingImport, setShowBookingImport] = 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 [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
@@ -208,11 +221,12 @@ export function useTripPlanner() {
|
||||
}
|
||||
}, [isLoading, places])
|
||||
|
||||
// Load trip + files (needed for place inspector file section)
|
||||
// Load the trip. loadTrip hydrates every trip-scoped slice (days, places,
|
||||
// packing, todo, budget, reservations, files) so offline hydration is uniform
|
||||
// and there's no cross-trip bleed; members/accommodations load alongside.
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
tripActions.loadFiles(tripId)
|
||||
loadAccommodations()
|
||||
if (!navigator.onLine) {
|
||||
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
||||
@@ -227,13 +241,6 @@ export function useTripPlanner() {
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
tripActions.loadReservations(tripId)
|
||||
tripActions.loadBudgetItems?.(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
|
||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
|
||||
@@ -666,6 +673,7 @@ export function useTripPlanner() {
|
||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { accommodationsApi } from '../api/client'
|
||||
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Accommodation } from '../types'
|
||||
|
||||
export const accommodationRepo = {
|
||||
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const accommodations = await offlineDb.accommodations
|
||||
.where('trip_id').equals(Number(tripId)).toArray()
|
||||
return { accommodations }
|
||||
}
|
||||
const result = await accommodationsApi.list(tripId)
|
||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await accommodationsApi.list(tripId)
|
||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
accommodations: await offlineDb.accommodations
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { budgetApi } from '../api/client'
|
||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { BudgetItem } from '../types'
|
||||
|
||||
export const budgetRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.budgetItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await budgetApi.list(tripId)
|
||||
upsertBudgetItems(result.items)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await budgetApi.list(tripId)
|
||||
upsertBudgetItems(result.items)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
items: await offlineDb.budgetItems
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+14
-10
@@ -1,18 +1,22 @@
|
||||
import { daysApi } from '../api/client'
|
||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Day } from '../types'
|
||||
|
||||
export const dayRepo = {
|
||||
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.days
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.sortBy('day_number' as keyof Day)
|
||||
return { days: cached as Day[] }
|
||||
}
|
||||
const result = await daysApi.list(tripId)
|
||||
upsertDays(result.days)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await daysApi.list(tripId)
|
||||
upsertDays(result.days)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
days: (await offlineDb.days
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.sortBy('day_number' as keyof Day)) as Day[],
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,18 +1,20 @@
|
||||
import { filesApi } from '../api/client'
|
||||
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { TripFile } from '../types'
|
||||
|
||||
export const fileRepo = {
|
||||
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.tripFiles
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { files: cached }
|
||||
}
|
||||
const result = await filesApi.list(tripId)
|
||||
upsertTripFiles(result.files)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await filesApi.list(tripId)
|
||||
upsertTripFiles(result.files)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
files: await offlineDb.tripFiles
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { packingApi } from '../api/client'
|
||||
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { PackingItem } from '../types'
|
||||
|
||||
export const packingRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.packingItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await packingApi.list(tripId)
|
||||
upsertPackingItems(result.items)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await packingApi.list(tripId)
|
||||
upsertPackingItems(result.items)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
items: await offlineDb.packingItems
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempId = nextTempId()
|
||||
const tempItem: PackingItem = {
|
||||
...(data as Partial<PackingItem>),
|
||||
id: tempId,
|
||||
@@ -51,13 +53,16 @@ export const packingRepo = {
|
||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
||||
await offlineDb.packingItems.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
|
||||
body: data,
|
||||
resource: 'packingItems',
|
||||
entityId: id,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
return { item: optimistic }
|
||||
}
|
||||
@@ -70,14 +75,16 @@ export const packingRepo = {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.packingItems.delete(id)
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
|
||||
body: undefined,
|
||||
resource: 'packingItems',
|
||||
entityId: id,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { placesApi } from '../api/client'
|
||||
import { offlineDb, upsertPlaces } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Place } from '../types'
|
||||
|
||||
export const placeRepo = {
|
||||
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.places
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { places: cached }
|
||||
}
|
||||
const result = await placesApi.list(tripId, params)
|
||||
upsertPlaces(result.places)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await placesApi.list(tripId, params)
|
||||
upsertPlaces(result.places)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
places: await offlineDb.places
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempId = nextTempId()
|
||||
const tempPlace: Place = {
|
||||
...(data as Partial<Place>),
|
||||
id: tempId,
|
||||
@@ -50,13 +52,16 @@ export const placeRepo = {
|
||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
||||
await offlineDb.places.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
const isTemp = Number(id) < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
entityId: Number(id),
|
||||
...(isTemp ? { tempEntityId: Number(id) } : {}),
|
||||
})
|
||||
return { place: optimistic }
|
||||
}
|
||||
@@ -69,14 +74,16 @@ export const placeRepo = {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.places.delete(Number(id))
|
||||
const mutId = generateUUID()
|
||||
const isTemp = Number(id) < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: Number(id),
|
||||
...(isTemp ? { tempEntityId: Number(id) } : {}),
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
@@ -90,14 +97,16 @@ export const placeRepo = {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
for (const id of ids) {
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: id,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
}
|
||||
return { deleted: ids, count: ids.length }
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { reservationsApi } from '../api/client'
|
||||
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
export const reservationRepo = {
|
||||
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.reservations
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { reservations: cached }
|
||||
}
|
||||
const result = await reservationsApi.list(tripId)
|
||||
upsertReservations(result.reservations)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await reservationsApi.list(tripId)
|
||||
upsertReservations(result.reservations)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
reservations: await offlineDb.reservations
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,18 +1,20 @@
|
||||
import { todoApi } from '../api/client'
|
||||
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { TodoItem } from '../types'
|
||||
|
||||
export const todoRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.todoItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await todoApi.list(tripId)
|
||||
upsertTodoItems(result.items)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await todoApi.list(tripId)
|
||||
upsertTodoItems(result.items)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
items: await offlineDb.todoItems
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+31
-22
@@ -1,33 +1,42 @@
|
||||
import { tripsApi } from '../api/client'
|
||||
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Trip } from '../types'
|
||||
|
||||
export const tripRepo = {
|
||||
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const all = await offlineDb.trips.toArray()
|
||||
return {
|
||||
trips: all.filter(t => !t.is_archived),
|
||||
archivedTrips: all.filter(t => t.is_archived),
|
||||
}
|
||||
}
|
||||
const [active, archived] = await Promise.all([
|
||||
tripsApi.list(),
|
||||
tripsApi.list({ archived: 1 }),
|
||||
])
|
||||
active.trips.forEach(t => upsertTrip(t))
|
||||
archived.trips.forEach(t => upsertTrip(t))
|
||||
return { trips: active.trips, archivedTrips: archived.trips }
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const [active, archived] = await Promise.all([
|
||||
tripsApi.list(),
|
||||
tripsApi.list({ archived: 1 }),
|
||||
])
|
||||
active.trips.forEach(t => upsertTrip(t))
|
||||
archived.trips.forEach(t => upsertTrip(t))
|
||||
return { trips: active.trips, archivedTrips: archived.trips }
|
||||
},
|
||||
async () => {
|
||||
const all = await offlineDb.trips.toArray()
|
||||
return {
|
||||
trips: all.filter(t => !t.is_archived),
|
||||
archivedTrips: all.filter(t => t.is_archived),
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
async get(tripId: number | string): Promise<{ trip: Trip }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.trips.get(Number(tripId))
|
||||
if (cached) return { trip: cached }
|
||||
throw new Error('No cached trip data available offline')
|
||||
}
|
||||
const result = await tripsApi.get(tripId)
|
||||
upsertTrip(result.trip)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await tripsApi.get(tripId)
|
||||
upsertTrip(result.trip)
|
||||
return result
|
||||
},
|
||||
async () => {
|
||||
const cached = await offlineDb.trips.get(Number(tripId))
|
||||
if (cached) return { trip: cached }
|
||||
throw new Error('No cached trip data available offline')
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { connect, disconnect } from '../api/websocket'
|
||||
import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||
import { clearAll } from '../db/offlineDb'
|
||||
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
|
||||
import { setAuthed } from '../sync/authGate'
|
||||
import { unregisterSyncTriggers } from '../sync/syncTriggers'
|
||||
import { useSystemNoticeStore } from './systemNoticeStore.js'
|
||||
|
||||
interface AuthResponse {
|
||||
@@ -40,7 +42,7 @@ interface AuthState {
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
logout: () => Promise<void>
|
||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
||||
updateMapsKey: (key: string | null) => Promise<void>
|
||||
@@ -65,6 +67,19 @@ interface AuthState {
|
||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||
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>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -99,6 +114,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -123,6 +139,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -147,6 +164,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
@@ -158,18 +176,27 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
// 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()
|
||||
useSystemNoticeStore.getState().reset()
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
// 4. Tell server to clear the httpOnly cookie (best-effort).
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// 5. Clear service worker caches containing sensitive data.
|
||||
if ('caches' in window) {
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
await Promise.all([
|
||||
caches.delete('api-data').catch(() => {}),
|
||||
caches.delete('user-uploads').catch(() => {}),
|
||||
])
|
||||
}
|
||||
// Purge all cached trip data from IndexedDB
|
||||
clearAll().catch(console.error)
|
||||
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB.
|
||||
await deleteCurrentUserDb().catch(console.error)
|
||||
// 7. Finish clearing auth state.
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -189,6 +216,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
@@ -282,6 +310,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
demoMode: true,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -193,25 +193,34 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
|
||||
// Assignments
|
||||
case 'assignment:created': {
|
||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
||||
const existing = (state.assignments[dayKey] || [])
|
||||
const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).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)
|
||||
if (hasTempVersion) {
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
|
||||
}
|
||||
}
|
||||
const incoming = payload.assignment as Assignment
|
||||
const dayKey = String(incoming.day_id)
|
||||
const existing = state.assignments[dayKey] || []
|
||||
const placeId = incoming.place?.id ?? incoming.place_id
|
||||
|
||||
// Already have this exact assignment id → duplicate broadcast or the
|
||||
// echo of an already-committed assignment. No-op.
|
||||
if (existing.some(a => a.id === incoming.id)) return {}
|
||||
|
||||
// Reconcile our own optimistic create: replace the temp (negative-id)
|
||||
// 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 {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, payload.assignment as Assignment],
|
||||
[dayKey]: [...existing, incoming],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { dayRepo } from '../repo/dayRepo'
|
||||
import { placeRepo } from '../repo/placeRepo'
|
||||
import { packingRepo } from '../repo/packingRepo'
|
||||
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 { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDaysSlice } from './slices/daysSlice'
|
||||
@@ -61,7 +64,9 @@ export interface TripStoreState
|
||||
|
||||
setSelectedDay: (dayId: number | null) => void
|
||||
handleRemoteEvent: (event: WebSocketEvent) => void
|
||||
resetTrip: () => void
|
||||
loadTrip: (tripId: number | string) => Promise<void>
|
||||
hydrateActiveTrip: (tripId: number | string) => Promise<void>
|
||||
refreshDays: (tripId: number | string) => Promise<void>
|
||||
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
|
||||
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
|
||||
@@ -89,15 +94,40 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
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) => {
|
||||
get().resetTrip()
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
|
||||
const [tripData, daysData, placesData, packingData, todoData, budgetData, reservationsData, filesData, tagsData, categoriesData] = await Promise.all([
|
||||
tripRepo.get(tripId),
|
||||
dayRepo.list(tripId),
|
||||
placeRepo.list(tripId),
|
||||
packingRepo.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
|
||||
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
|
||||
: offlineDb.tags.toArray().then(tags => ({ tags })),
|
||||
@@ -121,6 +151,9 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
dayNotes: dayNotesMap,
|
||||
packingItems: packingData.items,
|
||||
todoItems: todoData.items,
|
||||
budgetItems: budgetData.items,
|
||||
reservations: reservationsData.reservations,
|
||||
files: filesData.files,
|
||||
tags: tagsData.tags,
|
||||
categories: categoriesData.categories,
|
||||
isLoading: false,
|
||||
@@ -132,6 +165,22 @@ 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) => {
|
||||
try {
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
|
||||
@@ -378,8 +378,12 @@
|
||||
.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-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: space-between; padding: 20px 32px; gap: 48px; }
|
||||
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
|
||||
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
|
||||
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 {
|
||||
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -526,6 +530,9 @@
|
||||
|
||||
/* 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); }
|
||||
/* 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; }
|
||||
/* the page already opens with the notification/profile strip, trim its top gap */
|
||||
.trek-dash .page { padding-top: 4px; }
|
||||
@@ -580,25 +587,33 @@
|
||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||
.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). */
|
||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
|
||||
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
|
||||
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
|
||||
/* 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: 17px; overflow: hidden; text-overflow: ellipsis;
|
||||
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: flex-start; padding: 12px 16px; }
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
|
||||
.trek-dash .trips.list-view .trip-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) */
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0 0 40px; padding: 0; }
|
||||
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { apiClient } from '../api/client'
|
||||
import { isAuthed } from './authGate'
|
||||
import type { QueuedMutation } from '../db/offlineDb'
|
||||
import type { Table } from 'dexie'
|
||||
|
||||
@@ -39,6 +40,27 @@ let _flushing = false
|
||||
// Monotonically increasing timestamp so same-millisecond enqueues
|
||||
// still get a deterministic FIFO order when sorted by createdAt.
|
||||
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 = {
|
||||
/**
|
||||
@@ -67,8 +89,12 @@ export const mutationQueue = {
|
||||
* 4xx responses are marked failed and skipped.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (_flushing || !navigator.onLine) return
|
||||
if (_flushing || !navigator.onLine || !isAuthed()) return
|
||||
_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 {
|
||||
const pending = await offlineDb.mutationQueue
|
||||
.where('status')
|
||||
@@ -79,10 +105,32 @@ export const mutationQueue = {
|
||||
// Mark as syncing so UI can show progress
|
||||
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 {
|
||||
const response = await apiClient.request({
|
||||
method: mutation.method,
|
||||
url: mutation.url,
|
||||
url: reqUrl,
|
||||
data: mutation.body,
|
||||
headers: { 'X-Idempotency-Key': mutation.id },
|
||||
})
|
||||
@@ -95,31 +143,51 @@ export const mutationQueue = {
|
||||
const values = Object.values(response.data as Record<string, unknown>)
|
||||
const entity = values[0]
|
||||
if (entity && typeof entity === 'object' && 'id' in entity) {
|
||||
// Remove temp optimistic entry if id changed (CREATE case)
|
||||
if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) {
|
||||
const realId = (entity as { id: number }).id
|
||||
// Remove temp optimistic entry if id changed (CREATE case) and
|
||||
// remap any queued mutations that still target the negative id.
|
||||
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) {
|
||||
// DELETE was already applied optimistically; ensure it's gone
|
||||
const table = getTable(mutation.resource)
|
||||
if (table) await table.delete(mutation.entityId)
|
||||
if (table) await table.delete(reqEntityId)
|
||||
}
|
||||
|
||||
await offlineDb.mutationQueue.delete(mutation.id)
|
||||
} catch (err: unknown) {
|
||||
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
||||
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
|
||||
// Permanent client error — mark failed, continue with next
|
||||
const isTerminal =
|
||||
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus)
|
||||
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, {
|
||||
status: 'failed',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: String(err),
|
||||
})
|
||||
} else {
|
||||
// Network error — reset to pending, abort flush (retry on next trigger)
|
||||
// Network / transient error — reset to pending, abort flush (retry next trigger)
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'pending',
|
||||
attempts: mutation.attempts + 1,
|
||||
@@ -160,9 +228,19 @@ export const mutationQueue = {
|
||||
.count()
|
||||
},
|
||||
|
||||
/** Reset internal flushing flag and timestamp counter — useful in tests. */
|
||||
/** Count permanently-failed mutations (surfaced separately so the user knows
|
||||
* 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 {
|
||||
_flushing = false
|
||||
_lastTs = 0
|
||||
_lastTempId = 0
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -14,17 +14,34 @@
|
||||
*/
|
||||
import { mutationQueue } from './mutationQueue'
|
||||
import { tripSyncManager } from './tripSyncManager'
|
||||
import { setPreReconnectHook } from '../api/websocket'
|
||||
import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
|
||||
const PERIODIC_MS = 30_000
|
||||
|
||||
let _intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let _registered = false
|
||||
|
||||
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
|
||||
/** Pull the latest server state for every open trip into the Zustand store. */
|
||||
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() {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
mutationQueue.flush()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
rehydrateActiveTrips()
|
||||
})
|
||||
}
|
||||
|
||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
||||
@@ -48,6 +65,11 @@ export function registerSyncTriggers(): void {
|
||||
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
|
||||
// limiters when the socket drops and reconnects while the device is online.
|
||||
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)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
@@ -59,6 +81,7 @@ export function unregisterSyncTriggers(): void {
|
||||
_registered = false
|
||||
|
||||
setPreReconnectHook(null)
|
||||
setRefetchCallback(null)
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
if (_intervalId !== null) {
|
||||
|
||||
@@ -17,11 +17,18 @@ import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Estimated average tile size in KB (road/transit tiles ~15 KB). */
|
||||
/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */
|
||||
const AVG_TILE_KB = 15
|
||||
|
||||
/** Hard cap: ~50 MB worth of tiles. */
|
||||
export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413
|
||||
/**
|
||||
* Hard cap on prefetched tiles (~180 MB).
|
||||
*
|
||||
* 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 =
|
||||
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
@@ -177,15 +184,16 @@ export async function prefetchTilesForTrip(
|
||||
const bbox = computeBbox(places)
|
||||
if (!bbox) return
|
||||
|
||||
// Size guard: if total tile count across all zooms exceeds cap, skip
|
||||
const estimated = countTiles(bbox, 10, 16)
|
||||
if (estimated > MAX_TILES) {
|
||||
console.warn(
|
||||
`[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
|
||||
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get
|
||||
// their lower zooms cached instead of being skipped entirely.
|
||||
//
|
||||
// NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
upsertCategories,
|
||||
upsertSyncMeta,
|
||||
clearTripData,
|
||||
enforceBlobBudget,
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { isAuthed } from './authGate'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
||||
|
||||
@@ -108,13 +110,16 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
const resp = await fetch(file.url!, { credentials: 'include' })
|
||||
if (!resp.ok) continue
|
||||
const blob = await resp.blob()
|
||||
await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
|
||||
await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() })
|
||||
cached++
|
||||
} catch {
|
||||
// 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
|
||||
const tripId = files[0]?.trip_id
|
||||
if (tripId) {
|
||||
@@ -134,7 +139,7 @@ export const tripSyncManager = {
|
||||
* No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
reopenForUser,
|
||||
reopenAnonymous,
|
||||
deleteCurrentUserDb,
|
||||
enforceBlobBudget,
|
||||
type QueuedMutation,
|
||||
type SyncMeta,
|
||||
type BlobCacheEntry,
|
||||
@@ -81,6 +85,15 @@ const makePlace = (id: number, tripId = 1): Place => ({
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const makeBlob = (url: string, tripId = 1, bytes = 10, cachedAt = 1): BlobCacheEntry => ({
|
||||
url,
|
||||
tripId,
|
||||
blob: new Blob(['x'.repeat(bytes)], { type: 'application/pdf' }),
|
||||
bytes,
|
||||
mime: 'application/pdf',
|
||||
cachedAt,
|
||||
});
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -220,7 +233,9 @@ describe('offlineDb — blobCache', () => {
|
||||
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
|
||||
const entry: BlobCacheEntry = {
|
||||
url: '/api/files/99/download',
|
||||
tripId: 1,
|
||||
blob,
|
||||
bytes: blob.size,
|
||||
mime: 'application/pdf',
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
@@ -231,6 +246,49 @@ describe('offlineDb — blobCache', () => {
|
||||
expect(stored!.mime).toBe('application/pdf');
|
||||
expect(stored!.blob).toBeDefined();
|
||||
});
|
||||
|
||||
it('queries blobs by tripId index', async () => {
|
||||
await offlineDb.blobCache.bulkPut([
|
||||
makeBlob('/api/files/1/download', 1),
|
||||
makeBlob('/api/files/2/download', 1),
|
||||
makeBlob('/api/files/3/download', 2),
|
||||
]);
|
||||
const trip1 = await offlineDb.blobCache.where('tripId').equals(1).toArray();
|
||||
expect(trip1).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — enforceBlobBudget', () => {
|
||||
it('evicts oldest-by-cachedAt entries past the count budget', async () => {
|
||||
// 5 entries with strictly increasing cachedAt; cap to 3.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 10, i + 1));
|
||||
}
|
||||
await enforceBlobBudget(3, Infinity);
|
||||
|
||||
expect(await offlineDb.blobCache.count()).toBe(3);
|
||||
// Oldest two (cachedAt 1 and 2) are gone; newest survive.
|
||||
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
|
||||
expect(await offlineDb.blobCache.get('/api/files/1/download')).toBeUndefined();
|
||||
expect(await offlineDb.blobCache.get('/api/files/4/download')).toBeDefined();
|
||||
});
|
||||
|
||||
it('evicts oldest entries past the byte budget', async () => {
|
||||
// 3 entries of 100 bytes each; cap to 250 bytes → newest two (200) survive.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 100, i + 1));
|
||||
}
|
||||
await enforceBlobBudget(Infinity, 250);
|
||||
|
||||
expect(await offlineDb.blobCache.count()).toBe(2);
|
||||
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a no-op when already within budget', async () => {
|
||||
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
|
||||
await enforceBlobBudget(10, Infinity);
|
||||
expect(await offlineDb.blobCache.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — clearTripData', () => {
|
||||
@@ -241,9 +299,12 @@ describe('offlineDb — clearTripData', () => {
|
||||
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
|
||||
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
|
||||
|
||||
// Also add data for a different trip — should NOT be removed
|
||||
await upsertTrip(makeTrip(2));
|
||||
await upsertDays([makeDay(99, 2)]);
|
||||
await offlineDb.blobCache.put(makeBlob('/api/files/2/download', 2));
|
||||
|
||||
await clearTripData(1);
|
||||
|
||||
@@ -251,10 +312,12 @@ describe('offlineDb — clearTripData', () => {
|
||||
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.blobCache.where('tripId').equals(1).count()).toBe(0);
|
||||
|
||||
// Trip 2 intact
|
||||
expect(await offlineDb.trips.get(2)).toBeDefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
|
||||
expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,3 +334,37 @@ describe('offlineDb — clearAll', () => {
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — per-user scoping (B4)', () => {
|
||||
afterEach(async () => {
|
||||
// Leave the suite on the anonymous DB so other tests are unaffected.
|
||||
await reopenAnonymous();
|
||||
});
|
||||
|
||||
it('isolates one user\'s cached data from another', async () => {
|
||||
await reopenForUser(1);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
expect(await offlineDb.places.count()).toBe(1);
|
||||
|
||||
// Switching users must not expose user 1's rows.
|
||||
await reopenForUser(2);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
|
||||
// Switching back restores user 1's data (different physical DB).
|
||||
await reopenForUser(1);
|
||||
expect(await offlineDb.places.get(10)).toBeDefined();
|
||||
});
|
||||
|
||||
it('deleteCurrentUserDb wipes the user DB and returns to anonymous', async () => {
|
||||
await reopenForUser(5);
|
||||
await upsertPlaces([makePlace(20, 1)]);
|
||||
|
||||
await deleteCurrentUserDb();
|
||||
// Now on the anonymous DB — no user data.
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
|
||||
// Re-opening user 5 starts empty (DB was deleted, not just detached).
|
||||
await reopenForUser(5);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
import type { Assignment } from '../../../src/types';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
@@ -50,6 +51,58 @@ describe('remoteEventHandler > assignments', () => {
|
||||
expect(assignments['10'][0].id).toBe(500);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-003b: a second assignment of an already-present place is NOT suppressed (H11)', () => {
|
||||
const place = buildPlace({ id: 55 });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
// A committed (positive-id) assignment of place 55 already on the day.
|
||||
assignments: { '10': [buildAssignment({ id: 100, day_id: 10, place, place_id: place.id })] },
|
||||
});
|
||||
// A legitimately new, distinct assignment of the same place arrives.
|
||||
const second = buildAssignment({ id: 300, day_id: 10, place, place_id: place.id });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: second });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10']).toHaveLength(2);
|
||||
expect(assignments['10'].map(a => a.id).sort((x, y) => x - y)).toEqual([100, 300]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-003c: temp reconciliation replaces only the matching place, not a sibling temp (H11)', () => {
|
||||
const place55 = buildPlace({ id: 55 });
|
||||
const place66 = buildPlace({ id: 66 });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
assignments: {
|
||||
'10': [
|
||||
buildAssignment({ id: -1, day_id: 10, place: place55, place_id: 55 }),
|
||||
buildAssignment({ id: -2, day_id: 10, place: place66, place_id: 66 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
const real = buildAssignment({ id: 500, day_id: 10, place: place55, place_id: 55 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: real });
|
||||
const { assignments } = useTripStore.getState();
|
||||
const ids = assignments['10'].map(a => a.id);
|
||||
expect(assignments['10']).toHaveLength(2);
|
||||
expect(ids).toContain(500); // temp 55 reconciled to real
|
||||
expect(ids).toContain(-2); // sibling temp 66 untouched
|
||||
expect(ids).not.toContain(-1);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-003d: place-less assignments do not collapse onto each other (H11)', () => {
|
||||
// Defensive: a malformed event lacking place data must not let the
|
||||
// `place?.id === placeId` reconciliation match undefined === undefined.
|
||||
const placeless = (id: number): Assignment =>
|
||||
({ ...buildAssignment({ id, day_id: 10 }), place: undefined, place_id: undefined } as unknown as Assignment);
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
assignments: { '10': [placeless(-1)] },
|
||||
});
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: placeless(700) });
|
||||
const { assignments } = useTripStore.getState();
|
||||
// No placeId → no reconcile; both survive as distinct rows (no collapse).
|
||||
expect(assignments['10']).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-004: assignment:updated merges updated data into correct day', () => {
|
||||
seedData();
|
||||
const updated = buildAssignment({ id: 100, day_id: 10, notes: 'Updated notes' });
|
||||
|
||||
@@ -64,6 +64,20 @@ describe('placeRepo.list', () => {
|
||||
const result = await placeRepo.list(99);
|
||||
expect(result.places).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('online but request fails — falls back to Dexie cache (captive portal)', async () => {
|
||||
// navigator.onLine lies "true" on a captive portal; the request throws.
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips/1/places', () => HttpResponse.error()),
|
||||
);
|
||||
|
||||
const result = await placeRepo.list(1);
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].id).toBe(place.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.create', () => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* onlineThenCache — the read-through fallback shared by every repo (H2).
|
||||
*
|
||||
* Branches:
|
||||
* - navigator offline → cache only (skip the request)
|
||||
* - online but the request fails at the network level → fall back to cache
|
||||
* - online but the server returns an HTTP error → rethrow (don't mask)
|
||||
* - online and the request succeeds → return it, skip cache
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { onlineThenCache } from '../../../src/repo/withOfflineFallback';
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('onlineThenCache', () => {
|
||||
it('returns the online result when online', async () => {
|
||||
const online = vi.fn().mockResolvedValue('online');
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
expect(await onlineThenCache(online, cache)).toBe('online');
|
||||
expect(online).toHaveBeenCalledOnce();
|
||||
expect(cache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the cache without calling online when navigator is offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const online = vi.fn().mockResolvedValue('online');
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
expect(await onlineThenCache(online, cache)).toBe('cache');
|
||||
expect(online).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the cache on a network-level failure (no HTTP response)', async () => {
|
||||
// Axios network error: the request never reached the server (captive portal).
|
||||
const netErr = Object.assign(new Error('Network Error'), { isAxiosError: true, response: undefined });
|
||||
const online = vi.fn().mockRejectedValue(netErr);
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
expect(await onlineThenCache(online, cache)).toBe('cache');
|
||||
expect(online).toHaveBeenCalledOnce();
|
||||
expect(cache).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rethrows a genuine HTTP error (server responded) instead of masking it', async () => {
|
||||
// 404/403/500 mean the server replied — callers must see it, not a stale cache.
|
||||
const httpErr = Object.assign(new Error('Not Found'), { isAxiosError: true, response: { status: 404 } });
|
||||
const online = vi.fn().mockRejectedValue(httpErr);
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
await expect(onlineThenCache(online, cache)).rejects.toThrow('Not Found');
|
||||
expect(cache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows a non-Axios error rather than swallowing it', async () => {
|
||||
const online = vi.fn().mockRejectedValue(new Error('bug'));
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
await expect(onlineThenCache(online, cache)).rejects.toThrow('bug');
|
||||
expect(cache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a cache error (e.g. nothing cached) when online also failed', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const online = vi.fn().mockResolvedValue('online');
|
||||
const cache = vi.fn().mockRejectedValue(new Error('No cached data'));
|
||||
|
||||
await expect(onlineThenCache(online, cache)).rejects.toThrow('No cached data');
|
||||
});
|
||||
});
|
||||
@@ -105,10 +105,10 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
describe('FE-AUTH-006: logout', () => {
|
||||
it('calls disconnect() and clears user state', () => {
|
||||
it('calls disconnect() and clears user state', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
await useAuthStore.getState().logout();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
@@ -441,10 +441,10 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-PERSIST-001: logout resets persisted snapshot', () => {
|
||||
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', () => {
|
||||
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
||||
expect(snapshot?.state?.isAuthenticated).toBe(false);
|
||||
|
||||
@@ -8,18 +8,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../../../src/sync/mutationQueue';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { placeRepo } from '../../../src/repo/placeRepo';
|
||||
import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
mutationQueue._resetFlushing();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setAuthed(false);
|
||||
});
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -214,6 +218,25 @@ describe('mutationQueue.flush — offline guard', () => {
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('does nothing when logged out (auth gate closed)', async () => {
|
||||
setAuthed(false);
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
let called = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
expect(called).toBe(false);
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pending / pendingCount ────────────────────────────────────────────────────
|
||||
@@ -265,3 +288,177 @@ describe('mutationQueue.pendingCount', () => {
|
||||
expect(await mutationQueue.pendingCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutationQueue.failedCount', () => {
|
||||
it('counts only failed mutations (not pending/syncing)', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
||||
await offlineDb.mutationQueue.update(id2, { status: 'failed' });
|
||||
|
||||
expect(await mutationQueue.failedCount()).toBe(1);
|
||||
expect(await mutationQueue.pendingCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B2: collision-free temp ids ────────────────────────────────────────────────
|
||||
|
||||
describe('nextTempId (B2)', () => {
|
||||
it('returns distinct negative ids even within the same millisecond', () => {
|
||||
mutationQueue._resetFlushing();
|
||||
const a = nextTempId();
|
||||
const b = nextTempId();
|
||||
const c = nextTempId();
|
||||
expect(a).toBeLessThan(0);
|
||||
expect(new Set([a, b, c]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('two tight offline creates produce two distinct Dexie rows', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
await placeRepo.create(1, { name: 'First' });
|
||||
await placeRepo.create(1, { name: 'Second' });
|
||||
|
||||
const rows = await offlineDb.places.where('trip_id').equals(1).toArray();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map(r => r.name).sort()).toEqual(['First', 'Second']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B1: temp-id → real-id remapping ─────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — temp-id remapping (B1)', () => {
|
||||
it('rewrites a dependent PUT/DELETE to the real id within one flush', async () => {
|
||||
const tempId = -1;
|
||||
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||
|
||||
const createId = generateUUID();
|
||||
const putId = generateUUID();
|
||||
const deleteId = generateUUID();
|
||||
|
||||
await mutationQueue.enqueue({
|
||||
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
||||
body: { name: 'Temp' }, resource: 'places', tempId,
|
||||
});
|
||||
await mutationQueue.enqueue({
|
||||
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||
});
|
||||
await mutationQueue.enqueue({
|
||||
id: deleteId, tripId: 1, method: 'DELETE', url: '/trips/1/places/{id}',
|
||||
body: undefined, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||
});
|
||||
|
||||
const putUrls: string[] = [];
|
||||
const deleteUrls: string[] = [];
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })),
|
||||
http.put('/api/trips/1/places/:id', ({ params }) => { putUrls.push(String(params.id)); return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Edited' }) }); }),
|
||||
http.delete('/api/trips/1/places/:id', ({ params }) => { deleteUrls.push(String(params.id)); return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(putUrls).toEqual(['42']);
|
||||
expect(deleteUrls).toEqual(['42']);
|
||||
expect(await mutationQueue.pendingCount()).toBe(0);
|
||||
expect(await mutationQueue.failedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('durably rewrites a still-queued dependent after the CREATE flushes alone', async () => {
|
||||
const tempId = -7;
|
||||
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||
|
||||
const createId = generateUUID();
|
||||
const putId = generateUUID();
|
||||
await mutationQueue.enqueue({
|
||||
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
||||
body: { name: 'Temp' }, resource: 'places', tempId,
|
||||
});
|
||||
await mutationQueue.enqueue({
|
||||
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||
});
|
||||
|
||||
// Only the CREATE succeeds this round; the PUT errors out (network) and stays queued.
|
||||
let putAttempts = 0;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 88 }) })),
|
||||
http.put('/api/trips/1/places/:id', () => { putAttempts++; return HttpResponse.error(); }),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const queuedPut = await offlineDb.mutationQueue.get(putId);
|
||||
expect(queuedPut).toBeDefined();
|
||||
expect(queuedPut!.url).toBe('/trips/1/places/88');
|
||||
expect(queuedPut!.entityId).toBe(88);
|
||||
expect(queuedPut!.tempEntityId).toBeUndefined();
|
||||
expect(putAttempts).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('marks an orphaned dependent (placeholder never resolved) as failed', async () => {
|
||||
const putId = generateUUID();
|
||||
await mutationQueue.enqueue({
|
||||
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||
body: { name: 'Edited' }, resource: 'places', entityId: -999, tempEntityId: -999,
|
||||
});
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(putId);
|
||||
expect(m!.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── B3: terminal rollback + retryable classification ────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — failure handling (B3)', () => {
|
||||
it('rolls back the phantom optimistic row on a terminal 400 CREATE', async () => {
|
||||
const tempId = -3;
|
||||
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id, tempId }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'Bad' }, { status: 400 })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('treats 429 as retryable: resets to pending and stops the flush', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'slow down' }, { status: 429 })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
expect(m!.attempts).toBe(1);
|
||||
expect(await mutationQueue.failedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('treats 401 as retryable rather than dropping the change', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'AUTH_REQUIRED' }, { status: 401 })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* requestPersistentStorage (H8 / M6) — best-effort persistent storage request
|
||||
* so prefetched tiles / file blobs / IndexedDB aren't evicted under pressure.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { requestPersistentStorage } from '../../../src/sync/persistentStorage';
|
||||
|
||||
const original = (navigator as Navigator & { storage?: StorageManager }).storage;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'storage', { value: original, configurable: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function stubStorage(storage: unknown) {
|
||||
Object.defineProperty(navigator, 'storage', { value: storage, configurable: true });
|
||||
}
|
||||
|
||||
describe('requestPersistentStorage', () => {
|
||||
it('requests persistence when not already granted', async () => {
|
||||
const persist = vi.fn().mockResolvedValue(true);
|
||||
const persisted = vi.fn().mockResolvedValue(false);
|
||||
stubStorage({ persist, persisted });
|
||||
|
||||
expect(await requestPersistentStorage()).toBe(true);
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips the prompt when already persisted', async () => {
|
||||
const persist = vi.fn().mockResolvedValue(true);
|
||||
const persisted = vi.fn().mockResolvedValue(true);
|
||||
stubStorage({ persist, persisted });
|
||||
|
||||
expect(await requestPersistentStorage()).toBe(true);
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false (no throw) when the API is unavailable', async () => {
|
||||
stubStorage(undefined);
|
||||
expect(await requestPersistentStorage()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false (no throw) when persist rejects', async () => {
|
||||
stubStorage({ persist: vi.fn().mockRejectedValue(new Error('denied')) });
|
||||
expect(await requestPersistentStorage()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* syncTriggers — reconnect/online wiring (H1).
|
||||
*
|
||||
* Verifies the previously-dead refetch path is wired: on WS reconnect and on the
|
||||
* `online` event the active trip's store is re-hydrated (after the queue flush).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const flush = vi.fn(() => Promise.resolve());
|
||||
const syncAll = vi.fn(() => Promise.resolve());
|
||||
const hydrate = vi.fn(() => Promise.resolve());
|
||||
|
||||
let refetchCb: ((tripId: string) => void) | null = null;
|
||||
let preReconnect: (() => Promise<void>) | null = null;
|
||||
|
||||
vi.mock('../../../src/sync/mutationQueue', () => ({
|
||||
mutationQueue: { flush: () => flush() },
|
||||
}));
|
||||
vi.mock('../../../src/sync/tripSyncManager', () => ({
|
||||
tripSyncManager: { syncAll: () => syncAll() },
|
||||
}));
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
setPreReconnectHook: (fn: (() => Promise<void>) | null) => { preReconnect = fn; },
|
||||
setRefetchCallback: (fn: ((tripId: string) => void) | null) => { refetchCb = fn; },
|
||||
getActiveTrips: () => ['7'],
|
||||
}));
|
||||
vi.mock('../../../src/store/tripStore', () => ({
|
||||
useTripStore: { getState: () => ({ hydrateActiveTrip: hydrate }) },
|
||||
}));
|
||||
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from '../../../src/sync/syncTriggers';
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
flush.mockClear(); syncAll.mockClear(); hydrate.mockClear();
|
||||
refetchCb = null; preReconnect = null;
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSyncTriggers();
|
||||
});
|
||||
|
||||
describe('syncTriggers', () => {
|
||||
it('registers a refetch callback that hydrates the active trip', () => {
|
||||
registerSyncTriggers();
|
||||
expect(refetchCb).toBeTypeOf('function');
|
||||
refetchCb!('7');
|
||||
expect(hydrate).toHaveBeenCalledWith('7');
|
||||
});
|
||||
|
||||
it('also registers the pre-reconnect flush hook', () => {
|
||||
registerSyncTriggers();
|
||||
expect(preReconnect).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('clears both reconnect hooks on unregister', () => {
|
||||
registerSyncTriggers();
|
||||
unregisterSyncTriggers();
|
||||
expect(refetchCb).toBeNull();
|
||||
expect(preReconnect).toBeNull();
|
||||
});
|
||||
|
||||
it('online event flushes, then re-seeds Dexie and re-hydrates active trips', async () => {
|
||||
registerSyncTriggers();
|
||||
window.dispatchEvent(new Event('online'));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(flush).toHaveBeenCalled();
|
||||
expect(syncAll).toHaveBeenCalled();
|
||||
expect(hydrate).toHaveBeenCalledWith('7');
|
||||
});
|
||||
});
|
||||
@@ -207,17 +207,42 @@ describe('prefetchTilesForTrip', () => {
|
||||
expect(meta!.tilesBbox).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('skips prefetch when estimated tiles exceed MAX_TILES', async () => {
|
||||
it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
// Places far apart → huge bbox → estimate > MAX_TILES
|
||||
// ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past
|
||||
// it. The old guard skipped the whole trip; now we keep what fits.
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: -60, lng: -170 }),
|
||||
buildPlace({ trip_id: 1, lat: 60, lng: 170 }),
|
||||
buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }),
|
||||
buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
// No fetches should have been made
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
// Previously this skipped entirely; now it prefetches a clamped subset.
|
||||
const calls = vi.mocked(fetch).mock.calls.length;
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
expect(calls).toBeLessThanOrEqual(MAX_TILES);
|
||||
});
|
||||
|
||||
it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }),
|
||||
buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls.length;
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
expect(calls).toBeLessThanOrEqual(MAX_TILES);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cap coherence ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MAX_TILES budget', () => {
|
||||
it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => {
|
||||
expect(MAX_TILES).toBe(12288);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||
import {
|
||||
buildTrip,
|
||||
@@ -45,6 +46,7 @@ function makeBundle(tripId: number) {
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
tripSyncManager._resetSyncing();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
@@ -56,6 +58,19 @@ beforeEach(async () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
setAuthed(false);
|
||||
});
|
||||
|
||||
describe('tripSyncManager.syncAll — auth gate (B4)', () => {
|
||||
it('no-ops when logged out (gate closed)', async () => {
|
||||
setAuthed(false);
|
||||
let called = false;
|
||||
server.use(
|
||||
http.get('/api/trips', () => { called = true; return HttpResponse.json({ trips: [] }); }),
|
||||
);
|
||||
await tripSyncManager.syncAll();
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../src/store/tripStore';
|
||||
import { resetAllStores } from '../helpers/store';
|
||||
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
|
||||
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote, buildBudgetItem, buildReservation, buildTripFile } from '../helpers/factories';
|
||||
import { server } from '../helpers/msw/server';
|
||||
|
||||
vi.mock('../../src/api/websocket', () => ({
|
||||
@@ -21,6 +21,28 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
/** Full set of MSW handlers for one trip's loadTrip fan-out. */
|
||||
function tripHandlers(
|
||||
id: number,
|
||||
data: {
|
||||
budget?: unknown[]; reservations?: unknown[]; files?: unknown[];
|
||||
tags?: unknown[]; categories?: unknown[];
|
||||
},
|
||||
) {
|
||||
return [
|
||||
http.get(`/api/trips/${id}`, () => HttpResponse.json({ trip: buildTrip({ id }) })),
|
||||
http.get(`/api/trips/${id}/days`, () => HttpResponse.json({ days: [] })),
|
||||
http.get(`/api/trips/${id}/places`, () => HttpResponse.json({ places: [] })),
|
||||
http.get(`/api/trips/${id}/packing`, () => HttpResponse.json({ items: [] })),
|
||||
http.get(`/api/trips/${id}/todo`, () => HttpResponse.json({ items: [] })),
|
||||
http.get(`/api/trips/${id}/budget`, () => HttpResponse.json({ items: data.budget ?? [] })),
|
||||
http.get(`/api/trips/${id}/reservations`, () => HttpResponse.json({ reservations: data.reservations ?? [] })),
|
||||
http.get(`/api/trips/${id}/files`, () => HttpResponse.json({ files: data.files ?? [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: data.tags ?? [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: data.categories ?? [] })),
|
||||
];
|
||||
}
|
||||
|
||||
describe('tripStore', () => {
|
||||
describe('loadTrip', () => {
|
||||
it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => {
|
||||
@@ -178,6 +200,97 @@ describe('tripStore', () => {
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TRIP-H5: loadTrip uniformly hydrates budget, reservations and files', async () => {
|
||||
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
||||
const reservation = buildReservation({ trip_id: 1 });
|
||||
const file = buildTripFile({ trip_id: 1 });
|
||||
server.use(...tripHandlers(1, { budget: [budgetItem], reservations: [reservation], files: [file] }));
|
||||
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.budgetItems).toEqual([budgetItem]);
|
||||
expect(state.reservations).toEqual([reservation]);
|
||||
expect(state.files).toEqual([file]);
|
||||
});
|
||||
|
||||
it('FE-TRIP-H4: switching trips does not leak budget/reservations/files from the previous trip', async () => {
|
||||
// Trip 1 has budget/reservations/files; trip 2 has none.
|
||||
server.use(...tripHandlers(1, {
|
||||
budget: [buildBudgetItem({ trip_id: 1 })],
|
||||
reservations: [buildReservation({ trip_id: 1 })],
|
||||
files: [buildTripFile({ trip_id: 1 })],
|
||||
}));
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
|
||||
server.use(...tripHandlers(2, {}));
|
||||
await useTripStore.getState().loadTrip(2);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.trip!.id).toBe(2);
|
||||
expect(state.budgetItems).toEqual([]);
|
||||
expect(state.reservations).toEqual([]);
|
||||
expect(state.files).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TRIP-H4b: resetTrip clears every trip-scoped slice but keeps tags/categories', async () => {
|
||||
server.use(...tripHandlers(1, {
|
||||
budget: [buildBudgetItem({ trip_id: 1 })],
|
||||
reservations: [buildReservation({ trip_id: 1 })],
|
||||
files: [buildTripFile({ trip_id: 1 })],
|
||||
tags: [buildTag()],
|
||||
}));
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
|
||||
useTripStore.getState().resetTrip();
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.trip).toBeNull();
|
||||
expect(state.places).toEqual([]);
|
||||
expect(state.budgetItems).toEqual([]);
|
||||
expect(state.reservations).toEqual([]);
|
||||
expect(state.files).toEqual([]);
|
||||
expect(state.selectedDayId).toBeNull();
|
||||
// Global lookups survive a trip reset.
|
||||
expect(state.tags).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydrateActiveTrip', () => {
|
||||
const loadHandlers = (places: unknown[] = [], budget: unknown[] = []) => [
|
||||
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places })),
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: budget })),
|
||||
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
||||
];
|
||||
|
||||
it('FE-TRIP-H1: silently refreshes resources without resetting or splashing', async () => {
|
||||
server.use(...loadHandlers());
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
expect(useTripStore.getState().trip!.id).toBe(1);
|
||||
|
||||
// New collaborative state arrives (as if edited by someone while we were offline).
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
||||
server.use(...loadHandlers([place], [budgetItem]));
|
||||
|
||||
await useTripStore.getState().hydrateActiveTrip(1);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.places).toEqual([place]);
|
||||
expect(state.budgetItems).toEqual([budgetItem]);
|
||||
expect(state.trip!.id).toBe(1); // trip not reset
|
||||
expect(state.isLoading).toBe(false); // no splash toggled
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshDays', () => {
|
||||
|
||||
+28
-9
@@ -15,21 +15,25 @@ export default defineConfig({
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Carto map tiles (default provider)
|
||||
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
|
||||
// (both are 12288) so prefetched tiles aren't evicted on arrival.
|
||||
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'map-tiles',
|
||||
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// OpenStreetMap tiles (fallback / alternative)
|
||||
// Shares the 'map-tiles' cache; keep maxEntries equal to the Carto
|
||||
// rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288).
|
||||
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'map-tiles',
|
||||
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
@@ -44,17 +48,32 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
{
|
||||
// API calls — prefer network, fall back to cache
|
||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||
handler: 'NetworkFirst',
|
||||
// Mapbox GL style, glyphs, sprites and vector tiles. Best-effort
|
||||
// offline only: opportunistically caches what the user has already
|
||||
// viewed online. Full pre-download offline maps require the Leaflet
|
||||
// renderer (raster prefetch in tilePrefetcher.ts) — the GL vector
|
||||
// pipeline is not prefetched. StaleWhileRevalidate keeps the basemap
|
||||
// fresh online while still serving from cache when offline. Mapbox
|
||||
// sends CORS, so responses are non-opaque (real 200s, no quota pad).
|
||||
urlPattern: /^https:\/\/(api\.mapbox\.com|[a-d]\.tiles\.mapbox\.com)\/.*/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-data',
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
||||
networkTimeoutSeconds: 5,
|
||||
cacheName: 'mapbox-tiles',
|
||||
expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// API calls — network only. We deliberately do NOT cache API
|
||||
// responses in the Service Worker: Workbox keys entries by URL and
|
||||
// cannot vary on the httpOnly session cookie, so a shared device
|
||||
// could serve one user's cached data to the next (cross-user leak).
|
||||
// Offline reads are served from the per-user IndexedDB cache via the
|
||||
// repo layer instead. The urlPattern is kept so these requests still
|
||||
// bypass the SPA navigation fallback.
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
{
|
||||
// Uploaded files (photos, covers — public assets only)
|
||||
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ADDON_IDS = {
|
||||
ATLAS: 'atlas',
|
||||
COLLAB: 'collab',
|
||||
JOURNEY: 'journey',
|
||||
AIRTRAIL: 'airtrail',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
@@ -2460,6 +2460,35 @@ function runMigrations(db: Database.Database): void {
|
||||
if (after && after.region_code === row.region_code) del.run(row.id);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// AirTrail integration addon — disabled by default (opt-in). Per-user connection
|
||||
// lives in Settings → Integrations; this row is only the admin-level global toggle.
|
||||
try {
|
||||
db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run('airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14);
|
||||
} catch (err: any) {
|
||||
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// AirTrail per-user connection (mirrors the Immich integration columns).
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_allow_insecure_tls INTEGER DEFAULT 0"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
() => {
|
||||
// AirTrail flight linkage on reservations (#214) — lets a TREK transport
|
||||
// remember its AirTrail origin so the two-way sync can match + update it.
|
||||
// sync_enabled flips to 0 when the AirTrail flight is deleted (row kept).
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_source TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_id TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_owner_user_id INTEGER"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_synced_at TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN sync_enabled INTEGER DEFAULT 1"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_hash TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
// NULLs compare distinct in SQLite, so non-linked reservations don't collide.
|
||||
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)");
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -98,6 +98,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
|
||||
];
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
|
||||
@@ -79,6 +79,8 @@ const onListen = () => {
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
scheduler.startTrekPhotoCacheCleanup();
|
||||
scheduler.startPlacePhotoCacheCleanup();
|
||||
scheduler.startAirTrailSync();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
|
||||
@@ -78,10 +78,12 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
|
||||
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
||||
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
|
||||
@@ -95,7 +97,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image }, 'user');
|
||||
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { CollabModule } from './collab/collab.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { MemoriesModule } from './memories/memories.module';
|
||||
import { AirtrailModule } from './integrations/airtrail.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
@@ -41,10 +42,11 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
|
||||
/**
|
||||
* Root NestJS module for the incremental migration. Domain modules
|
||||
* (weather, notifications, ...) get registered here as they are migrated.
|
||||
* (weather, notifications, integrations, ...) get registered here as they are
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
* Gates the AirTrail integration routes on the global `airtrail` addon. When the
|
||||
* admin has it disabled the whole group answers 404. Declared before the
|
||||
* JwtAuthGuard so the addon check wins over the 401 (same ordering as the
|
||||
* Journey addon gate).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AirtrailAddonGuard implements CanActivate {
|
||||
canActivate(): boolean {
|
||||
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) {
|
||||
throw new HttpException({ error: 'AirTrail addon is not enabled' }, 404);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Headers, HttpException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AirtrailAddonGuard } from './airtrail-addon.guard';
|
||||
import { airtrailImportSchema, type AirtrailImport, type AirtrailImportResult } from '@trek/shared';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import { importAirtrailFlights } from '../../services/airtrail/airtrailImport';
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/reservations/import/airtrail — turn selected AirTrail
|
||||
* flights into reservations. Trip-scoped (reservation_edit) and addon-gated. The
|
||||
* flights are re-fetched server-side with the caller's own key.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/reservations/import')
|
||||
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
|
||||
export class AirtrailImportController {
|
||||
private requireEdit(tripId: string, user: User): void {
|
||||
const trip = verifyTripAccess(tripId, user.id);
|
||||
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
if (!checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('airtrail')
|
||||
async importAirtrail(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body(new ZodValidationPipe(airtrailImportSchema)) body: AirtrailImport,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): Promise<AirtrailImportResult> {
|
||||
this.requireEdit(tripId, user);
|
||||
try {
|
||||
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId);
|
||||
} catch (err: any) {
|
||||
throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { User } from '../../types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AirtrailAddonGuard } from './airtrail-addon.guard';
|
||||
import { getClientIp } from '../../services/auditLog';
|
||||
import { airtrailSettingsSchema, type AirtrailSettings } from '@trek/shared';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
getConnectionStatus,
|
||||
getFlightsForPicker,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
} from '../../services/airtrail/airtrailService';
|
||||
import { runAirtrailSyncForUser } from '../../services/airtrail/airtrailSync';
|
||||
|
||||
/**
|
||||
* /api/integrations/airtrail — per-user AirTrail connection (#214).
|
||||
*
|
||||
* `status` and `test` answer 200 even on failure (the service shapes
|
||||
* `{ connected: false, error }`); `settings` PUT validates with a 400. The API
|
||||
* key is never echoed — `getSettings` returns it masked. The route group is
|
||||
* gated on the `airtrail` addon (404 when disabled).
|
||||
*/
|
||||
@Controller('api/integrations/airtrail')
|
||||
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
|
||||
export class AirtrailController {
|
||||
@Get('settings')
|
||||
getSettings(@CurrentUser() user: User) {
|
||||
return getConnectionSettings(user.id);
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
async putSettings(
|
||||
@CurrentUser() user: User,
|
||||
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const result = await saveSettings(
|
||||
user.id,
|
||||
body.url,
|
||||
body.apiKey,
|
||||
!!body.allowInsecureTls,
|
||||
getClientIp(req),
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new HttpException({ error: result.error }, 400);
|
||||
}
|
||||
return result.warning ? { success: true, warning: result.warning } : { success: true };
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
getStatus(@CurrentUser() user: User) {
|
||||
return getConnectionStatus(user.id);
|
||||
}
|
||||
|
||||
@Get('flights')
|
||||
async flights(@CurrentUser() user: User) {
|
||||
try {
|
||||
return { flights: await getFlightsForPicker(user.id) };
|
||||
} catch (err: any) {
|
||||
throw new HttpException({ error: err?.message || 'Could not load AirTrail flights' }, err?.status === 400 ? 400 : 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull this user's AirTrail edits into their linked reservations on demand. */
|
||||
@Post('sync')
|
||||
@HttpCode(200)
|
||||
sync(@CurrentUser() user: User) {
|
||||
return runAirtrailSyncForUser(user.id);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(200)
|
||||
test(
|
||||
@CurrentUser() user: User,
|
||||
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
|
||||
) {
|
||||
return testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AirtrailController } from './airtrail.controller';
|
||||
import { AirtrailImportController } from './airtrail-import.controller';
|
||||
|
||||
/**
|
||||
* AirTrail integration domain. The connection lives under
|
||||
* /api/integrations/airtrail; the flight import is trip-scoped under
|
||||
* /api/trips/:tripId/reservations/import/airtrail. Business logic lives in
|
||||
* services/airtrail/* (plain functions over better-sqlite3).
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AirtrailController, AirtrailImportController],
|
||||
})
|
||||
export class AirtrailModule {}
|
||||
@@ -55,12 +55,17 @@ export class MapsController {
|
||||
@CurrentUser() user: User,
|
||||
@Body('query') query: unknown,
|
||||
@Query('lang') lang?: string,
|
||||
@Body('locationBias') locationBias?: { lat: number; lng: number; radius?: number },
|
||||
): Promise<MapsSearchResult> {
|
||||
if (!query) {
|
||||
throw new HttpException({ error: 'Search query is required' }, 400);
|
||||
}
|
||||
// Optional bias toward a coordinate (lat/lng[/radius]); improves foreign-region queries.
|
||||
if (locationBias && !(Number.isFinite(locationBias.lat) && Number.isFinite(locationBias.lng))) {
|
||||
throw new HttpException({ error: 'Invalid locationBias: lat and lng must be finite numbers' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.search(user.id, query as string, lang);
|
||||
return await this.maps.search(user.id, query as string, lang, locationBias);
|
||||
} catch (err: unknown) {
|
||||
console.error('Maps search error:', err);
|
||||
throw toHttpException(err, 'Search error', 500);
|
||||
|
||||
@@ -56,8 +56,8 @@ export class MapsService {
|
||||
return this.isSettingDisabled('places_photos_enabled');
|
||||
}
|
||||
|
||||
search(userId: number, query: string, lang?: string): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang) as Promise<MapsSearchResult>;
|
||||
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
|
||||
}
|
||||
|
||||
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise<MapsAutocompleteResult> {
|
||||
|
||||
@@ -163,27 +163,30 @@ export class PlacesController {
|
||||
}
|
||||
|
||||
@Post('import/google-list')
|
||||
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('google', user, tripId, url, socketId);
|
||||
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('google', user, tripId, url, enrich, socketId);
|
||||
}
|
||||
|
||||
@Post('import/naver-list')
|
||||
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('naver', user, tripId, url, socketId);
|
||||
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('naver', user, tripId, url, enrich, socketId);
|
||||
}
|
||||
|
||||
/** Shared google/naver list import — identical flow, different provider + error string. */
|
||||
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, socketId?: string) {
|
||||
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, enrich: unknown, socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
// Opt-in: re-resolve each imported place via the Places API to fill in
|
||||
// photo / address / website / phone and persist a google_place_id (#886).
|
||||
const opts = { enrich: parseBool(enrich, false), userId: user.id };
|
||||
const label = provider === 'google' ? 'Google' : 'Naver';
|
||||
try {
|
||||
const result = provider === 'google'
|
||||
? await this.places.importGoogleList(tripId, url)
|
||||
: await this.places.importNaverList(tripId, url);
|
||||
? await this.places.importGoogleList(tripId, url, opts)
|
||||
: await this.places.importNaverList(tripId, url, opts);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ export class PlacesService {
|
||||
return svc.importMapFile(tripId, buffer, filename, opts);
|
||||
}
|
||||
|
||||
importGoogleList(tripId: string, url: string) {
|
||||
return svc.importGoogleList(tripId, url);
|
||||
importGoogleList(tripId: string, url: string, opts?: Parameters<typeof svc.importGoogleList>[2]) {
|
||||
return svc.importGoogleList(tripId, url, opts);
|
||||
}
|
||||
|
||||
importNaverList(tripId: string, url: string) {
|
||||
return svc.importNaverList(tripId, url);
|
||||
importNaverList(tripId: string, url: string, opts?: Parameters<typeof svc.importNaverList>[2]) {
|
||||
return svc.importNaverList(tripId, url, opts);
|
||||
}
|
||||
|
||||
searchImage(tripId: string, id: string, userId: number) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { User } from '../../types';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { pushReservationToAirtrail } from '../../services/airtrail/airtrailSync';
|
||||
|
||||
type ReservationBody = Record<string, unknown> & {
|
||||
title?: string;
|
||||
@@ -115,6 +116,11 @@ export class ReservationsController {
|
||||
const cur = current as { title: string; type?: string };
|
||||
this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId);
|
||||
// Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget,
|
||||
// under the importer's credentials — see airtrailSync). #214
|
||||
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
|
||||
void pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
|
||||
}
|
||||
this.reservations.notifyBookingChange(tripId, user, body.title || cur.title, body.type || cur.type || '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
+83
-7
@@ -291,20 +291,45 @@ function startVersionCheck(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours
|
||||
// Idempotency key cleanup: nightly at 3 AM — delete keys past their TTL.
|
||||
// The TTL must exceed any realistic offline window: the TREK client replays
|
||||
// queued mutations with their X-Idempotency-Key when it reconnects, so a key
|
||||
// GC'd before the device comes back online would let the replay create a
|
||||
// duplicate. 24h was far too short for a multi-day offline trip; default 30d,
|
||||
// overridable via IDEMPOTENCY_TTL_SECONDS.
|
||||
const DEFAULT_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
let idempotencyCleanupTask: ScheduledTask | null = null;
|
||||
|
||||
function idempotencyTtlSeconds(): number {
|
||||
const n = Number(process.env.IDEMPOTENCY_TTL_SECONDS);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_IDEMPOTENCY_TTL_SECONDS;
|
||||
}
|
||||
|
||||
interface PurgeDb {
|
||||
prepare(sql: string): { run(...args: unknown[]): { changes: number } };
|
||||
}
|
||||
|
||||
/** Delete idempotency keys older than the configured TTL. Returns rows removed.
|
||||
* The db is injectable for testing; the cron job uses the default. */
|
||||
function purgeExpiredIdempotencyKeys(
|
||||
now: number = Date.now(),
|
||||
ttlSeconds: number = idempotencyTtlSeconds(),
|
||||
database: PurgeDb = require('./db/database').db,
|
||||
): number {
|
||||
const cutoff = Math.floor(now / 1000) - ttlSeconds;
|
||||
const result = database.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
function startIdempotencyCleanup(): void {
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
idempotencyCleanupTask = cron.schedule('0 3 * * *', () => {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 86400;
|
||||
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
||||
const removed = purgeExpiredIdempotencyKeys();
|
||||
if (removed > 0) {
|
||||
logInfo(`Idempotency cleanup: removed ${removed} expired key(s)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
@@ -334,6 +359,55 @@ function startTrekPhotoCacheCleanup(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Place-photo (Google/Wikimedia) cache cleanup: nightly — reclaim cached files and
|
||||
// meta rows no place references anymore (deleted places/trips, overwritten image_url).
|
||||
let placePhotoCacheTask: ScheduledTask | null = null;
|
||||
|
||||
function startPlacePhotoCacheCleanup(): void {
|
||||
if (placePhotoCacheTask) { placePhotoCacheTask.stop(); placePhotoCacheTask = null; }
|
||||
|
||||
const sweep = () => {
|
||||
try {
|
||||
const { sweepOrphans } = require('./services/placePhotoCache');
|
||||
const removed = sweepOrphans();
|
||||
if (removed > 0) logInfo(`Place-photo cache cleanup: removed ${removed} orphaned file(s)/row(s)`);
|
||||
} catch (err: unknown) {
|
||||
logError(`Place-photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Run once on startup to reclaim orphans left over from before this sweeper existed.
|
||||
sweep();
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
placePhotoCacheTask = cron.schedule('30 3 * * *', sweep, { timezone: tz });
|
||||
}
|
||||
|
||||
// AirTrail sync: poll connected instances on an interval and reconcile linked
|
||||
// flights both ways (#214). The per-tick enable gate (addon + setting) lives in
|
||||
// runAirtrailSync, so toggling the addon takes effect without a restart.
|
||||
let airtrailSyncTask: ScheduledTask | null = null;
|
||||
|
||||
function startAirTrailSync(): void {
|
||||
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
|
||||
|
||||
const { db } = require('./db/database');
|
||||
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const raw = parseInt(getSetting('airtrail_poll_interval_minutes') || '5', 10);
|
||||
const minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5;
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
logInfo(`AirTrail sync: scheduled every ${minutes}m`);
|
||||
|
||||
airtrailSyncTask = cron.schedule(`*/${minutes} * * * *`, async () => {
|
||||
try {
|
||||
const { runAirtrailSync } = require('./services/airtrail/airtrailSync');
|
||||
await runAirtrailSync();
|
||||
} catch (err: unknown) {
|
||||
logError(`AirTrail sync tick failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
@@ -341,6 +415,8 @@ function stop(): void {
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
if (placePhotoCacheTask) { placePhotoCacheTask.stop(); placePhotoCacheTask = null; }
|
||||
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, purgeExpiredIdempotencyKeys, startTrekPhotoCacheCleanup, startPlacePhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { safeFetch } from '../../utils/ssrfGuard';
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the AirTrail REST API (github.com/johanohly/AirTrail).
|
||||
* This is the ONLY place that talks to a user's AirTrail instance.
|
||||
*
|
||||
* Verified against AirTrail source:
|
||||
* - Auth: `Authorization: Bearer <key>`; a key maps to exactly one user.
|
||||
* - GET /api/flight/list — defaults to scope=mine. We NEVER send a scope
|
||||
* param so the key only ever returns its owner's own flights (isolation
|
||||
* holds even if an admin key is pasted).
|
||||
* - GET /api/flight/get/{id}
|
||||
* - POST /api/flight/save — `id` present => update, else create. seats[] is
|
||||
* required (>=1). A seat with userId '<USER_ID>' is attributed to the key
|
||||
* owner server-side, so we never need the caller's AirTrail user id.
|
||||
* - There is no webhook and no updated_at on a flight, so change detection is
|
||||
* snapshot-hash based (see airtrailSync).
|
||||
*/
|
||||
|
||||
const TIMEOUT_MS = 12000;
|
||||
|
||||
export interface AirtrailCreds {
|
||||
/** Instance origin without a trailing /api. */
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
allowInsecureTls: boolean;
|
||||
}
|
||||
|
||||
export class AirtrailAuthError extends Error {
|
||||
constructor(message = 'AirTrail rejected the API key') {
|
||||
super(message);
|
||||
this.name = 'AirtrailAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AirtrailRequestError extends Error {
|
||||
status?: number;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = 'AirtrailRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AirtrailAirport {
|
||||
id: number;
|
||||
icao: string | null;
|
||||
iata: string | null;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
tz: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
export interface AirtrailSeat {
|
||||
userId: string | null;
|
||||
guestName: string | null;
|
||||
seat: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClass: string | null;
|
||||
}
|
||||
|
||||
/** Airline/aircraft come back as joined objects (not bare codes) on a flight. */
|
||||
export interface AirtrailNamedCode {
|
||||
id?: number;
|
||||
icao?: string | null;
|
||||
iata?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** A flight as returned by list/get (the fields TREK consumes). */
|
||||
export interface AirtrailFlightRaw {
|
||||
id: number;
|
||||
from: AirtrailAirport | null;
|
||||
to: AirtrailAirport | null;
|
||||
date: string | null;
|
||||
datePrecision: string | null;
|
||||
departure: string | null;
|
||||
arrival: string | null;
|
||||
airline: AirtrailNamedCode | null;
|
||||
flightNumber: string | null;
|
||||
aircraft: AirtrailNamedCode | null;
|
||||
aircraftReg: string | null;
|
||||
flightReason: string | null;
|
||||
note: string | null;
|
||||
seats: AirtrailSeat[];
|
||||
}
|
||||
|
||||
/** Write shape accepted by POST /flight/save (airports/airline/aircraft as codes). */
|
||||
export interface AirtrailSavePayload {
|
||||
id?: number;
|
||||
from: string;
|
||||
to: string;
|
||||
departure: string;
|
||||
departureTime?: string | null;
|
||||
arrival?: string | null;
|
||||
arrivalTime?: string | null;
|
||||
datePrecision?: string;
|
||||
airline?: string | null;
|
||||
flightNumber?: string | null;
|
||||
aircraft?: string | null;
|
||||
aircraftReg?: string | null;
|
||||
flightReason?: string | null;
|
||||
note?: string | null;
|
||||
seats: Array<{
|
||||
userId: string | null;
|
||||
guestName: string | null;
|
||||
seat: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClass: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function apiBase(baseUrl: string): string {
|
||||
// Tolerate a pasted trailing slash or '/api' suffix so we never build '/api/api'.
|
||||
const origin = baseUrl.trim().replace(/\/+$/, '').replace(/\/api$/i, '');
|
||||
return origin + '/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a response as JSON, but turn the cryptic "Unexpected token '<'" that a
|
||||
* misconfigured URL produces (AirTrail serving its SPA / an auth-proxy login
|
||||
* page) into an actionable message.
|
||||
*/
|
||||
async function parseJson<T>(resp: Response): Promise<T> {
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new AirtrailRequestError(
|
||||
'AirTrail returned a non-JSON response. Check the URL is your AirTrail base URL (e.g. https://airtrail.example.com, without /api) and that the instance is reachable without a separate login.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(creds: AirtrailCreds, path: string, init: RequestInit): Promise<Response> {
|
||||
const url = apiBase(creds.baseUrl) + path;
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await safeFetch(
|
||||
url,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${creds.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
...(init.headers || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS) as any,
|
||||
},
|
||||
{ rejectUnauthorized: !creds.allowInsecureTls },
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
throw new AirtrailRequestError(err instanceof Error ? err.message : 'Could not reach AirTrail');
|
||||
}
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
throw new AirtrailAuthError();
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function listFlights(creds: AirtrailCreds): Promise<AirtrailFlightRaw[]> {
|
||||
const resp = await request(creds, '/flight/list', { method: 'GET' });
|
||||
if (!resp.ok) throw new AirtrailRequestError(`AirTrail list failed (HTTP ${resp.status})`, resp.status);
|
||||
const data = await parseJson<{ flights?: AirtrailFlightRaw[] }>(resp);
|
||||
return data.flights ?? [];
|
||||
}
|
||||
|
||||
export async function getFlight(creds: AirtrailCreds, id: number): Promise<AirtrailFlightRaw | null> {
|
||||
const resp = await request(creds, `/flight/get/${id}`, { method: 'GET' });
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) throw new AirtrailRequestError(`AirTrail get failed (HTTP ${resp.status})`, resp.status);
|
||||
const data = await parseJson<{ flight?: AirtrailFlightRaw }>(resp);
|
||||
return data.flight ?? null;
|
||||
}
|
||||
|
||||
export async function saveFlight(creds: AirtrailCreds, payload: AirtrailSavePayload): Promise<{ id?: number }> {
|
||||
const resp = await request(creds, '/flight/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let msg = `AirTrail save failed (HTTP ${resp.status})`;
|
||||
try {
|
||||
const body = (await resp.json()) as { message?: string; errors?: unknown };
|
||||
if (body?.message) msg = body.message;
|
||||
else if (body?.errors) msg = JSON.stringify(body.errors);
|
||||
} catch {
|
||||
/* keep the generic message */
|
||||
}
|
||||
throw new AirtrailRequestError(msg, resp.status);
|
||||
}
|
||||
const data = await parseJson<{ id?: number }>(resp);
|
||||
return { id: data.id };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AirtrailImportResult } from '@trek/shared';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { createReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import { AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
|
||||
interface ExistingFlightRow {
|
||||
id: number;
|
||||
reservation_time: string | null;
|
||||
metadata: string | null;
|
||||
from_code: string | null;
|
||||
to_code: string | null;
|
||||
}
|
||||
|
||||
function depDate(t: string | null): string | null {
|
||||
return t && /^\d{4}-\d{2}-\d{2}/.test(t) ? t.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** A loose "same physical flight" key: flight number + date, else route + date. */
|
||||
function softSignature(
|
||||
date: string | null,
|
||||
flightNumber: string | null,
|
||||
fromCode: string | null,
|
||||
toCode: string | null,
|
||||
): string | null {
|
||||
if (!date) return null;
|
||||
if (flightNumber) return `fn:${flightNumber.toUpperCase()}@${date}`;
|
||||
if (fromCode && toCode) return `rt:${fromCode.toUpperCase()}-${toCode.toUpperCase()}@${date}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the given AirTrail flights into a trip as reservations (type:'flight'),
|
||||
* recording the AirTrail linkage for two-way sync and broadcasting each one live.
|
||||
*
|
||||
* Dedup: a flight already linked to this trip is skipped ('already-imported'); a
|
||||
* flight that looks like one already in the trip — e.g. the same flight another
|
||||
* member already imported from their own AirTrail — is skipped ('already-in-trip').
|
||||
* The server re-fetches the flights by id with the caller's own key, so the client
|
||||
* cannot inject arbitrary flight data.
|
||||
*/
|
||||
export async function importAirtrailFlights(
|
||||
tripId: string | number,
|
||||
userId: number,
|
||||
flightIds: string[],
|
||||
socketId: string | undefined,
|
||||
): Promise<AirtrailImportResult> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
|
||||
const wanted = new Set(flightIds.map(String));
|
||||
const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id)));
|
||||
|
||||
const result: AirtrailImportResult = { imported: [], skipped: [] };
|
||||
|
||||
const linkedIds = new Set(
|
||||
(db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as {
|
||||
external_id: string | null;
|
||||
}[])
|
||||
.map(r => r.external_id)
|
||||
.filter((v): v is string => !!v),
|
||||
);
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT r.id, r.reservation_time, r.metadata,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code
|
||||
FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`,
|
||||
)
|
||||
.all(tripId) as ExistingFlightRow[];
|
||||
|
||||
const existingSigs = new Set<string>();
|
||||
for (const row of existing) {
|
||||
let fn: string | null = null;
|
||||
try {
|
||||
fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null;
|
||||
} catch {
|
||||
/* malformed metadata — ignore */
|
||||
}
|
||||
const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code);
|
||||
if (sig) existingSigs.add(sig);
|
||||
}
|
||||
|
||||
for (const flight of selected) {
|
||||
const fid = String(flight.id);
|
||||
if (linkedIds.has(fid)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-imported' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = mapFlightToReservation(flight);
|
||||
const sig = softSignature(
|
||||
depDate(mapped.reservation_time),
|
||||
(mapped.metadata.flight_number as string) ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
|
||||
);
|
||||
if (sig && existingSigs.has(sig)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { reservation } = createReservation(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
sync_enabled = 1, external_hash = ?, external_synced_at = ? WHERE id = ?`,
|
||||
).run(fid, userId, canonicalHash(flight), now, reservation.id);
|
||||
|
||||
// Carry the linkage on the broadcast payload so members see the badge live.
|
||||
reservation.external_source = 'airtrail';
|
||||
reservation.external_id = fid;
|
||||
reservation.external_owner_user_id = userId;
|
||||
reservation.sync_enabled = 1;
|
||||
reservation.external_synced_at = now;
|
||||
|
||||
broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
if (sig) existingSigs.add(sig);
|
||||
linkedIds.add(fid);
|
||||
result.imported.push(fid);
|
||||
} catch (err) {
|
||||
console.error('[airtrail-import] failed to import flight', fid, err instanceof Error ? err.message : err);
|
||||
result.skipped.push({ flightId: fid, reason: 'invalid', detail: err instanceof Error ? err.message : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrailClient';
|
||||
import type { AirtrailFlight } from '@trek/shared';
|
||||
|
||||
/** Preferred display/lookup code for an airport. */
|
||||
function airportCode(a: AirtrailAirport | null): string | null {
|
||||
return a?.iata || a?.icao || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
|
||||
* them to a single code (ICAO preferred, matching AirTrail's save shape).
|
||||
*/
|
||||
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
|
||||
return e?.icao || e?.iata || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar date + clock time for an instant at a given IANA zone.
|
||||
* AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local
|
||||
* `date`; the airport-local wall time is what TREK shows and files days by.
|
||||
*/
|
||||
function localParts(iso: string | null, tz: string | null): { date: string | null; time: string | null } {
|
||||
if (!iso) return { date: null, time: null };
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return { date: null, time: null };
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz || 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(d);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
|
||||
const date = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
let hh = get('hour');
|
||||
if (hh === '24') hh = '00'; // some ICU builds emit 24:00 for midnight
|
||||
const time = `${hh}:${get('minute')}`;
|
||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time };
|
||||
} catch {
|
||||
return { date: null, time: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw AirTrail flight → the normalized shape the import picker consumes. */
|
||||
export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
|
||||
return {
|
||||
id: String(raw.id),
|
||||
fromCode: airportCode(raw.from),
|
||||
fromName: raw.from?.name ?? null,
|
||||
toCode: airportCode(raw.to),
|
||||
toName: raw.to?.name ?? null,
|
||||
date: raw.date ?? null,
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MappedEndpoint {
|
||||
role: 'from' | 'to' | 'stop';
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
}
|
||||
|
||||
export interface MappedReservation {
|
||||
title: string;
|
||||
type: 'flight';
|
||||
status: 'confirmed';
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
notes: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
endpoints: MappedEndpoint[];
|
||||
needs_review: number;
|
||||
}
|
||||
|
||||
function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: number; lng: number } {
|
||||
return !!a && typeof a.lat === 'number' && typeof a.lon === 'number';
|
||||
}
|
||||
|
||||
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
|
||||
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
|
||||
const dep = localParts(raw.departure, raw.from?.tz ?? null);
|
||||
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
|
||||
|
||||
const fromCode = airportCode(raw.from);
|
||||
const toCode = airportCode(raw.to);
|
||||
const datePrefix = raw.date || dep.date;
|
||||
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
|
||||
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
|
||||
|
||||
const endpoints: MappedEndpoint[] = [];
|
||||
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
|
||||
|
||||
if (hasCoords(raw.from)) {
|
||||
endpoints.push({
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: raw.from.name || fromCode || 'Departure',
|
||||
code: fromCode,
|
||||
lat: raw.from.lat,
|
||||
lng: raw.from.lon,
|
||||
timezone: raw.from.tz,
|
||||
local_time: dep.time,
|
||||
local_date: datePrefix,
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
if (hasCoords(raw.to)) {
|
||||
endpoints.push({
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: raw.to.name || toCode || 'Arrival',
|
||||
code: toCode,
|
||||
lat: raw.to.lat,
|
||||
lng: raw.to.lon,
|
||||
timezone: raw.to.tz,
|
||||
local_time: arr.time,
|
||||
local_date: arr.date,
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0];
|
||||
const airlineCode = entityCode(raw.airline);
|
||||
const aircraftCode = entityCode(raw.aircraft);
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (airlineCode) metadata.airline = airlineCode;
|
||||
if (raw.flightNumber) metadata.flight_number = raw.flightNumber;
|
||||
if (aircraftCode) metadata.aircraft = aircraftCode;
|
||||
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
|
||||
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
|
||||
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
|
||||
|
||||
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
|
||||
// makes the clearest title; fall back to the route.
|
||||
const title = raw.flightNumber?.trim() || `${fromCode || '?'} → ${toCode || '?'}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
notes: raw.note ?? null,
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review: needsReview,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable snapshot hash of an AirTrail flight, used by the sync engine to detect
|
||||
* remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own
|
||||
* writes from re-triggering a pull. Only fields that can meaningfully change are
|
||||
* included, in a fixed key order.
|
||||
*/
|
||||
export function canonicalHash(raw: AirtrailFlightRaw): string {
|
||||
const snapshot = {
|
||||
from: airportCode(raw.from),
|
||||
to: airportCode(raw.to),
|
||||
date: raw.date ?? null,
|
||||
datePrecision: raw.datePrecision ?? 'day',
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
aircraftReg: raw.aircraftReg ?? null,
|
||||
flightReason: raw.flightReason ?? null,
|
||||
note: raw.note ?? null,
|
||||
seats: (raw.seats ?? [])
|
||||
.map(s => ({
|
||||
userId: s.userId ?? null,
|
||||
guestName: s.guestName ?? null,
|
||||
seat: s.seat ?? null,
|
||||
seatNumber: s.seatNumber ?? null,
|
||||
seatClass: s.seatClass ?? null,
|
||||
}))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
};
|
||||
return crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { AirtrailFlight } from '@trek/shared';
|
||||
import { db } from '../../db/database';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { writeAudit } from '../auditLog';
|
||||
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import { normalizeFlight } from './airtrailMapper';
|
||||
|
||||
const KEY_MASK = '••••••••';
|
||||
|
||||
interface UserConnRow {
|
||||
airtrail_url?: string | null;
|
||||
airtrail_api_key?: string | null;
|
||||
airtrail_allow_insecure_tls?: number | null;
|
||||
}
|
||||
|
||||
function readRow(userId: number): UserConnRow | undefined {
|
||||
return db
|
||||
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
|
||||
.get(userId) as UserConnRow | undefined;
|
||||
}
|
||||
|
||||
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
||||
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
||||
const row = readRow(userId);
|
||||
if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
|
||||
const apiKey = decrypt_api_key(row.airtrail_api_key);
|
||||
if (!apiKey) return null;
|
||||
return {
|
||||
baseUrl: row.airtrail_url,
|
||||
apiKey,
|
||||
allowInsecureTls: !!row.airtrail_allow_insecure_tls,
|
||||
};
|
||||
}
|
||||
|
||||
/** Settings as shown in the UI — the key is never echoed, only masked. */
|
||||
export function getConnectionSettings(userId: number) {
|
||||
const row = readRow(userId);
|
||||
return {
|
||||
url: row?.airtrail_url || '',
|
||||
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
||||
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
|
||||
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: number,
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
clientIp: string | null,
|
||||
): Promise<{ success: boolean; warning?: string; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
let warning: string | undefined;
|
||||
|
||||
if (trimmedUrl) {
|
||||
const ssrf = await checkSsrf(trimmedUrl);
|
||||
// Reject only genuinely unusable URLs (malformed, unresolvable, non-http,
|
||||
// loopback). Private/LAN instances are the common self-hosted case, so we
|
||||
// persist them with a warning rather than blocking — the outbound calls
|
||||
// still need ALLOW_INTERNAL_NETWORK=true to actually reach them.
|
||||
if (!ssrf.allowed && !ssrf.isPrivate) {
|
||||
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
|
||||
}
|
||||
if (ssrf.isPrivate) {
|
||||
writeAudit({
|
||||
userId,
|
||||
action: 'airtrail.private_ip_configured',
|
||||
ip: clientIp,
|
||||
details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
|
||||
});
|
||||
warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only overwrite the stored key when a genuinely new value is supplied;
|
||||
// a blank field or the mask means "keep the existing key".
|
||||
const provided = (apiKey || '').trim();
|
||||
const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
|
||||
|
||||
if (newKey !== undefined) {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
|
||||
} else {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
|
||||
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
|
||||
if (!trimmedUrl) {
|
||||
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, warning };
|
||||
}
|
||||
|
||||
async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
try {
|
||||
const flights = await listFlights(creds);
|
||||
return { connected: true, flightCount: flights.length };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' };
|
||||
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Live check using the stored connection. */
|
||||
export async function getConnectionStatus(
|
||||
userId: number,
|
||||
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) return { connected: false, error: 'Not configured' };
|
||||
return probe(creds);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Test connection" from the settings form. Uses the typed URL/key when given;
|
||||
* falls back to the stored key when the key field still shows the mask.
|
||||
*/
|
||||
export async function testConnection(
|
||||
userId: number,
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
const provided = (apiKey || '').trim();
|
||||
|
||||
const stored = getAirtrailCredentials(userId);
|
||||
const effectiveUrl = trimmedUrl || stored?.baseUrl;
|
||||
const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;
|
||||
|
||||
if (!effectiveUrl || !effectiveKey) {
|
||||
return { connected: false, error: 'URL and API key required' };
|
||||
}
|
||||
|
||||
const ssrf = await checkSsrf(effectiveUrl);
|
||||
if (!ssrf.allowed && !ssrf.isPrivate) {
|
||||
return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
|
||||
}
|
||||
|
||||
return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls });
|
||||
}
|
||||
|
||||
/** The user's AirTrail flights, normalized for the import picker. */
|
||||
export async function getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
const raw = await listFlights(creds);
|
||||
return raw.map(normalizeFlight);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { db } from '../../db/database';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { isAddonEnabled } from '../adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import {
|
||||
AirtrailAuthError,
|
||||
AirtrailCreds,
|
||||
AirtrailFlightRaw,
|
||||
AirtrailSavePayload,
|
||||
getFlight,
|
||||
listFlights,
|
||||
saveFlight,
|
||||
} from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
export function syncGloballyEnabled(): boolean {
|
||||
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row?.value !== 'false';
|
||||
}
|
||||
|
||||
function broadcastUpdated(tripId: number, reservationId: number): void {
|
||||
try {
|
||||
const reservation = getReservationWithJoins(reservationId);
|
||||
if (reservation) broadcast(tripId, 'reservation:updated', { reservation });
|
||||
} catch {
|
||||
/* broadcast failure is non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function detach(tripId: number, reservationId: number): void {
|
||||
db.prepare('UPDATE reservations SET sync_enabled = 0 WHERE id = ?').run(reservationId);
|
||||
broadcastUpdated(tripId, reservationId);
|
||||
}
|
||||
|
||||
// ── AirTrail → TREK (poll) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reconcile one owner's linked reservations against their current AirTrail
|
||||
* flights: apply field changes (detected by snapshot hash, since AirTrail has no
|
||||
* updated_at) and, when a flight is gone from AirTrail, keep the TREK row but
|
||||
* stop syncing it. Only already-imported flights are touched — new AirTrail
|
||||
* flights are never auto-added to a trip. Returns how many rows changed.
|
||||
*/
|
||||
async function syncOwner(uid: number): Promise<number> {
|
||||
const creds = getAirtrailCredentials(uid);
|
||||
if (!creds) return 0; // owner disconnected — leave their linked rows as-is
|
||||
|
||||
let flights: AirtrailFlightRaw[];
|
||||
try {
|
||||
flights = await listFlights(creds);
|
||||
} catch (err) {
|
||||
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
|
||||
return 0;
|
||||
}
|
||||
const byId = new Map(flights.map(f => [String(f.id), f]));
|
||||
|
||||
const linked = db
|
||||
.prepare(
|
||||
"SELECT id, trip_id, external_id, external_hash FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?",
|
||||
)
|
||||
.all(uid) as { id: number; trip_id: number; external_id: string; external_hash: string | null }[];
|
||||
|
||||
let changed = 0;
|
||||
for (const row of linked) {
|
||||
const flight = byId.get(String(row.external_id));
|
||||
if (!flight) {
|
||||
detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing
|
||||
changed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hash = canonicalHash(flight);
|
||||
if (hash === row.external_hash) continue;
|
||||
|
||||
const current = getReservation(row.id, row.trip_id);
|
||||
if (!current) continue;
|
||||
try {
|
||||
updateReservation(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
|
||||
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
|
||||
hash,
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
broadcastUpdated(row.trip_id, row.id);
|
||||
changed++;
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
let running = false;
|
||||
|
||||
/** Background poll across every connected owner (scheduler). */
|
||||
export async function runAirtrailSync(): Promise<void> {
|
||||
if (running) return;
|
||||
if (!syncGloballyEnabled()) return;
|
||||
running = true;
|
||||
let changed = 0;
|
||||
try {
|
||||
const owners = db
|
||||
.prepare(
|
||||
"SELECT DISTINCT external_owner_user_id AS uid FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL",
|
||||
)
|
||||
.all() as { uid: number }[];
|
||||
for (const { uid } of owners) changed += await syncOwner(uid);
|
||||
if (changed > 0) logInfo(`AirTrail sync: applied ${changed} change(s)`);
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand sync of just this user's linked flights — called when the user opens
|
||||
* a trip so AirTrail-side edits show up immediately instead of waiting for the
|
||||
* background poll.
|
||||
*/
|
||||
export async function runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> {
|
||||
if (!syncGloballyEnabled()) return { changed: 0 };
|
||||
try {
|
||||
return { changed: await syncOwner(userId) };
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync (user ${userId}) failed: ${err instanceof Error ? err.message : err}`);
|
||||
return { changed: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── TREK → AirTrail (push) ───────────────────────────────────────────────────
|
||||
|
||||
function splitLocal(dt: string | null | undefined): { date: string | null; time: string | null } {
|
||||
if (!dt) return { date: null, time: null };
|
||||
const date = dt.slice(0, 10);
|
||||
const m = dt.slice(10).match(/(\d{2}:\d{2})/);
|
||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
|
||||
}
|
||||
|
||||
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||
let meta: Record<string, any> = {};
|
||||
try {
|
||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
const endpoints: any[] = reservation.endpoints || [];
|
||||
const fromEp = endpoints.find(e => e.role === 'from');
|
||||
const toEp = endpoints.find(e => e.role === 'to');
|
||||
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
|
||||
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
|
||||
if (!fromCode || !toCode) return null;
|
||||
|
||||
const dep = splitLocal(reservation.reservation_time);
|
||||
const arr = splitLocal(reservation.reservation_end_time);
|
||||
if (!dep.date) return null;
|
||||
|
||||
// Preserve the existing seat manifest (an update replaces all seats); fall back
|
||||
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
|
||||
const seats = (existing.seats ?? []).map(s => ({
|
||||
userId: s.userId,
|
||||
guestName: s.guestName,
|
||||
seat: s.seat,
|
||||
seatNumber: s.seatNumber,
|
||||
seatClass: s.seatClass,
|
||||
}));
|
||||
if (seats.length === 0) {
|
||||
seats.push({ userId: '<USER_ID>', guestName: null, seat: null, seatNumber: null, seatClass: null });
|
||||
}
|
||||
|
||||
// Push the seat the user set in TREK onto their own AirTrail seat (the one with
|
||||
// a userId), leaving any co-passenger seats untouched.
|
||||
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
|
||||
if (seatNumber) {
|
||||
const ownSeat = seats.find(s => s.userId) ?? seats[0];
|
||||
if (ownSeat) ownSeat.seatNumber = seatNumber;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(reservation.external_id),
|
||||
from: fromCode,
|
||||
to: toCode,
|
||||
departure: dep.date,
|
||||
departureTime: dep.time,
|
||||
arrival: arr.date,
|
||||
arrivalTime: arr.time,
|
||||
airline: meta.airline ?? null,
|
||||
flightNumber: meta.flight_number ?? null,
|
||||
aircraft: meta.aircraft ?? null,
|
||||
aircraftReg: meta.aircraft_reg ?? null,
|
||||
flightReason: meta.flight_reason ?? null,
|
||||
note: reservation.notes ?? null,
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a locally-edited linked reservation back to AirTrail using the importer's
|
||||
* (owner's) credentials — even if a different member made the edit. If the owner
|
||||
* is gone or the flight no longer exists in AirTrail, the link is detached so the
|
||||
* next pull's AirTrail-wins policy can't silently revert the local edit.
|
||||
*/
|
||||
export async function pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
|
||||
if (!syncGloballyEnabled()) return;
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT id, trip_id, external_id, external_owner_user_id, sync_enabled FROM reservations WHERE id = ? AND external_source = 'airtrail'",
|
||||
)
|
||||
.get(reservationId) as
|
||||
| { id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number }
|
||||
| undefined;
|
||||
if (!row || !row.sync_enabled) return;
|
||||
|
||||
const creds: AirtrailCreds | null = row.external_owner_user_id
|
||||
? getAirtrailCredentials(row.external_owner_user_id)
|
||||
: null;
|
||||
if (!creds) {
|
||||
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||
return;
|
||||
}
|
||||
|
||||
let existing: AirtrailFlightRaw | null;
|
||||
try {
|
||||
existing = await getFlight(creds, Number(row.external_id));
|
||||
} catch (err) {
|
||||
if (err instanceof AirtrailAuthError) detach(tripId, row.id);
|
||||
else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
return;
|
||||
}
|
||||
if (!existing) {
|
||||
detach(tripId, row.id); // gone in AirTrail → treat like a remote delete
|
||||
return;
|
||||
}
|
||||
|
||||
const reservation = getReservationWithJoins(row.id);
|
||||
if (!reservation) return;
|
||||
|
||||
const payload = buildSavePayload(reservation, existing);
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
await saveFlight(creds, payload);
|
||||
// Self-write suppression: re-read the saved flight and store its hash so the
|
||||
// next poll doesn't treat our own write as an inbound change.
|
||||
const saved = await getFlight(creds, Number(row.external_id));
|
||||
if (saved) {
|
||||
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
|
||||
canonicalHash(saved),
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`AirTrail push failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,14 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
}
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
archive.directory(uploadsDir, 'uploads');
|
||||
// Exclude the place-photo and trek-memory caches: both are re-derivable
|
||||
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
|
||||
// backup size. Restores self-heal — the cache dirs are recreated at startup.
|
||||
archive.glob(
|
||||
'**/*',
|
||||
{ cwd: uploadsDir, ignore: ['photos/google/**', 'photos/trek/**'], nodir: true, dot: true },
|
||||
{ prefix: 'uploads' },
|
||||
);
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
|
||||
@@ -3,6 +3,13 @@ import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './inAppNotificationActions';
|
||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
|
||||
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
|
||||
// so the client renders notification times in the viewer's own timezone (#1149).
|
||||
function toUtcIso(ts: string): string {
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
type NotificationResponse = 'positive' | 'negative';
|
||||
@@ -218,6 +225,7 @@ export function createNotificationForRecipient(
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
created_at: toUtcIso(row.created_at),
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
@@ -251,6 +259,7 @@ function getNotifications(
|
||||
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
created_at: toUtcIso(r.created_at),
|
||||
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ interface OverpassElement {
|
||||
}
|
||||
|
||||
interface WikiCommonsPage {
|
||||
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
||||
imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
||||
}
|
||||
|
||||
interface GooglePlaceResult {
|
||||
@@ -250,38 +250,130 @@ interface OverpassPoiElement {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PoiSearchResult {
|
||||
pois: OverpassPoi[];
|
||||
source: 'openstreetmap';
|
||||
truncated: boolean;
|
||||
// True when the requested viewport was too large and got shrunk to a centred
|
||||
// window before querying — the results then cover the middle of the view only.
|
||||
clamped: boolean;
|
||||
}
|
||||
|
||||
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
|
||||
// Reachability and load vary a lot by network/region — the canonical instance is
|
||||
// frequently overloaded (504s) and some community mirrors are unreachable from
|
||||
// certain networks. Racing them means whichever mirror is fastest-reachable for
|
||||
// this user answers, and an overloaded or blocked one never blocks the others.
|
||||
const OVERPASS_MIRRORS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
];
|
||||
// Per-mirror cap. Because mirrors race in parallel this is also the worst-case
|
||||
// total wait before every mirror is given up on and a 502 is returned.
|
||||
const OVERPASS_TIMEOUT_MS = 12000;
|
||||
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes
|
||||
// Overpass scan millions of elements and time out; clamping to a centred window
|
||||
// keeps the query cheap so the explore pill returns fast at ANY zoom level.
|
||||
const MAX_BBOX_SPAN_DEG = 0.5;
|
||||
|
||||
// Short-lived cache so panning back over / re-toggling the same area doesn't
|
||||
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
|
||||
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
|
||||
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
// Cap the number of cached areas so panning across the globe can't grow the map
|
||||
// without bound (entries are evicted oldest-first once the cap is reached).
|
||||
const POI_CACHE_MAX = 500;
|
||||
|
||||
// POST the query to all mirrors at once and return the first one that answers with
|
||||
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
|
||||
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
|
||||
// sum of every dead mirror's timeout.
|
||||
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const controllers: AbortController[] = [];
|
||||
|
||||
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
|
||||
const ctrl = new AbortController();
|
||||
controllers.push(ctrl);
|
||||
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
// Overpass signals an internal timeout / runtime error via `remark` while
|
||||
// still answering HTTP 200 — often fast, with an empty or partial element
|
||||
// set. Treat that as a failed attempt so a healthy mirror wins the race
|
||||
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
|
||||
// still surfaces a real error to the client instead of a silent "no places".
|
||||
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
|
||||
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
|
||||
return data.elements;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Promise.any resolves with the first mirror to return valid JSON, and only
|
||||
// rejects (AggregateError) once every mirror has failed.
|
||||
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
|
||||
} catch {
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
} finally {
|
||||
// Cancel the slower/losing requests — we already have (or have given up on) a result.
|
||||
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
limit = 60,
|
||||
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
|
||||
): Promise<PoiSearchResult> {
|
||||
const filters = CATEGORY_OSM_FILTERS[category];
|
||||
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
|
||||
// Clamp an oversized viewport to a centred window so the query stays cheap and
|
||||
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
|
||||
let { south, west, north, east } = bbox;
|
||||
let clamped = false;
|
||||
if (north - south > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (north + south) / 2;
|
||||
south = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
north = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
if (east - west > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (east + west) / 2;
|
||||
west = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
east = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
|
||||
// Serve repeat pans/toggles of the same area straight from the cache.
|
||||
const cacheKey = `${category}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`;
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
const query = `[out:json][timeout:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
|
||||
let elements: OverpassPoiElement[] = [];
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[] };
|
||||
elements = data.elements || [];
|
||||
} catch (err: any) {
|
||||
if (err?.status) throw err;
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
}
|
||||
const elements = await overpassFetch(query);
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
@@ -309,7 +401,11 @@ export async function searchOverpassPois(
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > limit;
|
||||
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
|
||||
const value: PoiSearchResult = { pois: pois.slice(0, limit), source: 'openstreetmap', truncated, clamped };
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
@@ -441,7 +537,9 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
const mime = (info as { mime?: string })?.mime || '';
|
||||
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
|
||||
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
|
||||
return { photoUrl: info.url, attribution };
|
||||
// iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it —
|
||||
// info.url is the full-resolution original (multi-megapixel camera exports).
|
||||
return { photoUrl: info.thumburl ?? info.url, attribution };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -450,7 +548,7 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
|
||||
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
|
||||
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const apiKey = getMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -458,6 +556,18 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
const searchBody: Record<string, unknown> = { textQuery: query, languageCode: toApiLang(lang) };
|
||||
// Bias results toward the caller's area when supplied — without it Google Text
|
||||
// Search falls back to the API key's billing region, which skews foreign-region queries.
|
||||
if (locationBias) {
|
||||
searchBody.locationBias = {
|
||||
circle: {
|
||||
center: { latitude: locationBias.lat, longitude: locationBias.lng },
|
||||
radius: locationBias.radius ?? 50000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -465,7 +575,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
||||
},
|
||||
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
|
||||
body: JSON.stringify(searchBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
@@ -485,6 +595,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
rating: p.rating || null,
|
||||
website: p.websiteUri || null,
|
||||
phone: p.nationalPhoneNumber || null,
|
||||
types: p.types || [],
|
||||
source: 'google',
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { avatarUrl } from './authService';
|
||||
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b'];
|
||||
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
@@ -76,13 +76,14 @@ interface ImportItem {
|
||||
category?: string;
|
||||
weight_grams?: string | number;
|
||||
bag?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const created: any[] = [];
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
@@ -105,7 +106,8 @@ export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
|
||||
const qty = Math.max(1, Math.min(999, Number(item.quantity) || 1));
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++, qty);
|
||||
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
|
||||
|
||||
/**
|
||||
* Background enrichment for list-imported places (#886).
|
||||
*
|
||||
* Google/Naver list imports only carry name + coordinates, so the imported
|
||||
* places open as bare pins (the Maps tab jumps to coordinates, no photo, no
|
||||
* open/closed). When the importer opts in and a Google Maps key is configured,
|
||||
* we re-resolve each place by name — biased to and validated against the
|
||||
* imported coordinates — to a real Google place, then fill in the empty fields
|
||||
* and persist the resolved `google_place_id` (which is what powers on-demand
|
||||
* opening hours / the proper Maps link going forward).
|
||||
*
|
||||
* This runs detached from the import request (fire-and-forget) so a long list
|
||||
* never blocks the response, and pushes each enriched row over the websocket so
|
||||
* the sidebar fills in progressively. It only ever fills EMPTY columns, so it
|
||||
* can never clobber data the import already captured (e.g. a Naver address).
|
||||
*/
|
||||
|
||||
/** A place the import produced — only the fields enrichment reads/writes. */
|
||||
export interface EnrichablePlace {
|
||||
id: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
google_place_id?: string | null;
|
||||
address?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
/** How close a search hit must be to the imported coordinates to be trusted. */
|
||||
const MATCH_RADIUS_METERS = 250;
|
||||
/** Bias the text search to roughly the imported area. */
|
||||
const SEARCH_BIAS_RADIUS_METERS = 2000;
|
||||
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
|
||||
const ENRICH_CONCURRENCY = 3;
|
||||
|
||||
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the search result that is the same place as the import: it must be a
|
||||
* Google result (have a google_place_id) with coordinates within
|
||||
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
|
||||
* null when nothing is close enough — in which case the place is left as
|
||||
* imported rather than risking a wrong-place overwrite (common-name / romanized
|
||||
* lists). Exported for unit testing.
|
||||
*/
|
||||
export function pickEnrichmentMatch(
|
||||
candidates: Record<string, unknown>[],
|
||||
target: { lat: number; lng: number },
|
||||
maxMeters: number = MATCH_RADIUS_METERS,
|
||||
): Record<string, unknown> | null {
|
||||
let best: { c: Record<string, unknown>; dist: number } | null = null;
|
||||
for (const c of candidates || []) {
|
||||
const gpid = c.google_place_id;
|
||||
const lat = c.lat;
|
||||
const lng = c.lng;
|
||||
if (typeof gpid !== 'string' || !gpid) continue;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
|
||||
const dist = haversineMeters(target, { lat, lng });
|
||||
if (dist > maxMeters) continue;
|
||||
if (!best || dist < best.dist) best = { c, dist };
|
||||
}
|
||||
return best?.c ?? null;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++];
|
||||
await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
|
||||
async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> {
|
||||
// Already linked (shouldn't happen for list imports) — nothing to resolve.
|
||||
if (place.google_place_id) return;
|
||||
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
|
||||
|
||||
const { places: results } = await searchPlaces(userId, place.name, lang, {
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
radius: SEARCH_BIAS_RADIUS_METERS,
|
||||
});
|
||||
const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
|
||||
if (!match) return;
|
||||
|
||||
const gpid = str(match.google_place_id);
|
||||
if (!gpid) return;
|
||||
|
||||
// COALESCE so enrichment only fills empty columns — never overwrites data the
|
||||
// import already captured (e.g. Naver's address) or anything the user edited.
|
||||
db.prepare(
|
||||
`UPDATE places
|
||||
SET google_place_id = COALESCE(google_place_id, ?),
|
||||
address = COALESCE(address, ?),
|
||||
website = COALESCE(website, ?),
|
||||
phone = COALESCE(phone, ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND trip_id = ?`,
|
||||
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
|
||||
|
||||
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
|
||||
// that case — a missing photo must never abort the rest of the enrichment.
|
||||
try {
|
||||
const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
|
||||
if (photo?.photoUrl) {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
|
||||
).run(photo.photoUrl, place.id, tripId);
|
||||
}
|
||||
} catch {
|
||||
/* no photo — leave image_url as-is */
|
||||
}
|
||||
|
||||
// Push the enriched row to every connected client (no socket exclusion: the
|
||||
// importer's own client should also receive the late update).
|
||||
const updated = getPlaceWithTags(place.id);
|
||||
if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a batch of just-imported places in the background. Never throws —
|
||||
* any per-place failure is swallowed so one bad lookup can't take down the
|
||||
* detached task or the process. No-ops when no Google Maps key is configured.
|
||||
*/
|
||||
export async function enrichImportedPlaces(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
places: EnrichablePlace[],
|
||||
lang?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!places.length) return;
|
||||
if (!getMapsKey(userId)) return;
|
||||
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
|
||||
try {
|
||||
await enrichOne(tripId, userId, place, lang);
|
||||
} catch (err) {
|
||||
console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user