feat(import): parse bookings in the background with a progress widget

Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.
This commit is contained in:
Maurice
2026-06-25 23:56:21 +02:00
parent c92c6bc07c
commit 628830011d
10 changed files with 452 additions and 354 deletions
+2
View File
@@ -20,6 +20,7 @@ import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
@@ -208,6 +209,7 @@ export default function App() {
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
+11
View File
@@ -670,6 +670,17 @@ export const reservationsApi = {
},
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
// Start a background parse: returns a job id at once; progress + result arrive
// over the WebSocket (import:progress / import:done / import:error).
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
},
// Poll a background job — recovery path when a WebSocket push was missed.
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
}
export const healthApi = {
@@ -0,0 +1,133 @@
import ReactDOM from 'react-dom'
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi } from '../../api/client'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
* Global, route-independent widget (bottom-right) that tracks background booking
* imports. Mounted once at the app root so it survives navigation. It listens to the
* user's WebSocket for import:progress / import:done / import:error and reflects each
* job; a finished job offers a "review" action that takes the user to the trip, where
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
// Server pushes import:* to the user on whatever page they're on.
useEffect(() => {
const handler = (e: Record<string, unknown>) => {
const type = typeof e.type === 'string' ? e.type : ''
if (!type.startsWith('import:')) return
const id = String(e.jobId ?? '')
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
// Backstop: poll running jobs in case a WebSocket push was missed on reconnect.
useEffect(() => {
const running = tasks.filter((task) => task.status === 'running')
if (running.length === 0) return
const iv = setInterval(() => {
for (const task of running) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
if (tasks.length === 0) return null
const review = (task: BackgroundImportTask) => {
requestReview(task.id)
navigate(`/trips/${task.tripId}`)
}
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
key={task.id}
className="bg-surface-card"
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{task.status === 'running' && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.label}
</div>
{task.status === 'running' && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
{task.status === 'done' && (
(task.items?.length ?? 0) > 0 ? (
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
) : (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
)
)}
{task.status === 'error' && (
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
)}
</div>
{task.status !== 'running' && (
<button
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label={t('common.close')}
>
<X size={13} />
</button>
)}
</div>
))}
</div>,
document.body
)
}
@@ -1,95 +1,43 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useEffect } from 'react'
import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react'
import type { BookingImportPreviewItem, BookingImportFileReport } from '@trek/shared'
import { Upload, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { reservationsApi, healthApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
interface BookingImportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
// Fired after a successful import so the page can refresh state that lives
// outside the trip store — notably the accommodations list a hotel booking
// links to (loadTrip alone leaves it stale, so the edit modal shows blanks).
onImported?: () => void
// When provided, the parsed items aren't persisted directly: each is handed
// back so the page can open the normal edit modal pre-filled for review before
// the user saves it. Falls back to direct confirm() when absent.
onReview?: (items: BookingImportPreviewItem[]) => void
}
const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']
const MAX_FILE_BYTES = 10 * 1024 * 1024
const MAX_FILES = 5
const TYPE_ICONS: Record<string, React.FC<{ size: number; color?: string }>> = {
flight: Plane,
train: Train,
hotel: Hotel,
restaurant: UtensilsCrossed,
car: Car,
cruise: Anchor,
event: Calendar,
}
function typeColor(type: string): string {
const map: Record<string, string> = {
flight: '#3b82f6',
train: '#10b981',
hotel: '#8b5cf6',
restaurant: '#f59e0b',
car: '#6b7280',
cruise: '#06b6d4',
event: '#ec4899',
}
return map[type] ?? 'var(--text-faint)'
}
function formatDateTime(iso: unknown): string {
if (!iso) return ''
const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso)
const date = str.slice(0, 10)
const time = str.length > 10 ? str.slice(11, 16) : ''
return [date, time].filter(Boolean).join(' ')
}
export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo, onImported, onReview }: BookingImportModalProps) {
/**
* Upload booking files and kick off a BACKGROUND parse. The modal closes at once;
* the parse runs server-side and is tracked by the global BackgroundTasksWidget
* (progress over the WebSocket). When it finishes, the trip page opens the per-item
* review flow — so the user can navigate and keep editing while it works.
*/
export default function BookingImportModal({ isOpen, onClose, tripId }: BookingImportModalProps) {
const { t } = useTranslation()
const toast = useToast()
const loadTrip = useTripStore((s) => s.loadTrip)
const addTask = useBackgroundTasksStore((s) => s.addTask)
const fileInputRef = useRef<HTMLInputElement>(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
type Phase = 'upload' | 'preview' | 'confirming'
const [phase, setPhase] = useState<Phase>('upload')
const [files, setFiles] = useState<File[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
const [warnings, setWarnings] = useState<string[]>([])
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
// AI fallback: addon-level availability + per-file report + in-flight retries.
const [aiParsing, setAiParsing] = useState(false)
const [fileReports, setFileReports] = useState<BookingImportFileReport[]>([])
const [retrying, setRetrying] = useState<Set<string>>(() => new Set())
const reset = () => {
setPhase('upload')
setFiles([])
setIsDragOver(false)
setLoading(false)
setError('')
setPreviewItems([])
setWarnings([])
setExcluded(new Set())
setFileReports([])
setRetrying(new Set())
}
useEffect(() => {
@@ -100,7 +48,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
useEffect(() => {
if (!isOpen) return
healthApi.features().then(f => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
}, [isOpen])
const handleClose = () => { reset(); onClose() }
@@ -140,116 +88,41 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
if (list.length) selectFiles(list)
}
// Start the parse in the background and close — the widget takes it from here.
const handleParse = async () => {
if (files.length === 0 || loading) return
setLoading(true)
setError('')
try {
// Auto-rescue: whenever AI parsing is available, files kitinerary can't
// read fall back to the LLM automatically — no extra confirmation step.
const mode = aiParsing ? 'fallback-on-empty' : 'no-ai'
const result = await reservationsApi.importBookingPreview(tripId, files, mode)
setPreviewItems(result.items ?? [])
setWarnings(result.warnings ?? [])
setFileReports(result.files ?? [])
setExcluded(new Set())
setPhase('preview')
} catch (err: any) {
const msg = err?.response?.data?.error ?? t('reservations.import.error')
setError(msg)
} finally {
setLoading(false)
}
}
// Re-run a single file through the LLM (force-ai) and merge any new items in.
const handleRetryAi = async (fileName: string) => {
const file = files.find(f => f.name === fileName)
if (!file || retrying.has(fileName)) return
setRetrying(prev => new Set(prev).add(fileName))
try {
const result = await reservationsApi.importBookingPreview(tripId, [file], 'force-ai')
setPreviewItems(prev => [...prev, ...(result.items ?? [])])
setWarnings(prev => [...prev, ...(result.warnings ?? [])])
setFileReports(prev => prev.map(r => r.fileName === fileName ? { ...r, aiUsed: true } : r))
} catch (err: any) {
const msg = err?.response?.data?.error ?? t('reservations.import.error')
setError(msg)
} finally {
setRetrying(prev => { const next = new Set(prev); next.delete(fileName); return next })
}
}
const handleConfirm = async () => {
const toImport = previewItems.filter((_, i) => !excluded.has(i))
if (toImport.length === 0) return
// Review-first flow: hand the parsed items to the page, which opens the
// normal edit modal pre-filled for each so the user checks before saving.
if (onReview) { onReview(toImport); handleClose(); return }
setPhase('confirming')
setError('')
try {
const result = await reservationsApi.importBookingConfirm(tripId, toImport)
const created = result.created ?? []
await loadTrip(tripId)
// Refresh out-of-store state (accommodations) so a freshly imported hotel
// resolves its place/date range in the reservation edit modal.
onImported?.()
if (created.length > 0) {
pushUndo?.(t('undo.importBooking'), async () => {
try {
const { reservationsApi: rApi } = await import('../../api/client')
await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {})))
} catch {}
await loadTrip(tripId)
})
toast.success(t('reservations.import.success', { count: created.length }))
} else {
toast.warning(t('reservations.import.previewEmpty'))
}
const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode)
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length })
handleClose()
} catch (err: any) {
setError(err?.response?.data?.error ?? t('reservations.import.error'))
setPhase('preview')
setLoading(false)
}
}
const toggleExclude = (idx: number) => {
setExcluded(prev => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx); else next.add(idx)
return next
})
}
const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length
if (!isOpen) return null
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 => {
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()}
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' }}
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' }}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
{phase === 'preview' && (
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<ArrowLeft size={16} />
</button>
)}
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.import.title')}
</div>
@@ -259,155 +132,45 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{/* Upload phase */}
{phase === 'upload' && (
<>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('reservations.import.acceptedFormats')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('reservations.import.acceptedFormats')}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTS.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTS.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
style={{
width: '100%', minHeight: 100, borderRadius: 12,
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
marginBottom: 12, padding: 16, boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
) : files.length > 0 ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
)}
</div>
</>
)}
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
style={{
width: '100%', minHeight: 100, borderRadius: 12,
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
marginBottom: 12, padding: 16, boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
) : files.length > 0 ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map((f) => f.name).join(', ')}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
)}
</div>
{/* Preview phase */}
{(phase === 'preview' || phase === 'confirming') && (
<>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
{t('reservations.import.previewHeading', { count: previewItems.length })}
</div>
{previewItems.length === 0 && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('reservations.import.previewEmpty')}
</div>
)}
{previewItems.map((item, idx) => {
const Icon = TYPE_ICONS[item.type] ?? Calendar
const isExcluded = excluded.has(idx)
const fromEp = item.endpoints?.find(e => e.role === 'from')
const toEp = item.endpoints?.find(e => e.role === 'to')
return (
<div
key={`${item.source.fileName}-${idx}`}
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
style={{
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
display: 'flex', gap: 10, alignItems: 'flex-start',
}}
>
<div style={{ flexShrink: 0, marginTop: 2 }}>
<Icon size={15} color={typeColor(item.type)} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}
</span>
{item.needs_review && (
<span className="bg-[rgba(245,158,11,0.15)] text-[#92400e]" style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 6 }}>
{t('reservations.import.needsReview')}
</span>
)}
</div>
{fromEp && toEp && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
{fromEp.code ?? fromEp.name} {toEp.code ?? toEp.name}
</div>
)}
{item.reservation_time && (
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{formatDateTime(item.reservation_time)}
{item.reservation_end_time && ` ${formatDateTime(item.reservation_end_time)}`}
</div>
)}
{item._accommodation?.check_in && (
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{formatDateTime(item._accommodation.check_in)} {formatDateTime(item._accommodation.check_out)}
</div>
)}
{item.confirmation_number && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
{item.confirmation_number}
</div>
)}
</div>
<button
onClick={() => toggleExclude(idx)}
className="bg-transparent text-content-faint"
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
title={t('reservations.import.removeItem')}
>
{isExcluded ? '' : <X size={12} />}
</button>
</div>
)
})}
{/* Per-file AI fallback: offer a retry for files kitinerary couldn't read. */}
{phase === 'preview' && fileReports.filter(r => r.aiAvailable && !r.aiUsed).map(r => (
<div key={`ai-${r.fileName}`} className="bg-surface-secondary" style={{ borderRadius: 10, padding: '8px 12px', marginBottom: 8, border: '1px dashed var(--border-primary)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.fileName}
</span>
<button
onClick={() => handleRetryAi(r.fileName)}
disabled={retrying.has(r.fileName)}
className="bg-accent text-accent-text"
style={{ flexShrink: 0, border: 'none', borderRadius: 8, padding: '5px 10px', fontSize: 12, fontWeight: 500, cursor: retrying.has(r.fileName) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: retrying.has(r.fileName) ? 0.6 : 1 }}
>
{retrying.has(r.fileName) ? t('reservations.import.aiParsing') : t('reservations.import.tryAi')}
</button>
</div>
))}
</>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
{warnings.join('\n')}
</div>
)}
{/* Error */}
{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}
@@ -423,28 +186,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
>
{t('common.cancel')}
</button>
{phase === 'upload' && (
<button
onClick={handleParse}
disabled={files.length === 0 || loading}
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{loading ? t('reservations.import.parsing') : t('common.import')}
</button>
)}
{(phase === 'preview' || phase === 'confirming') && (
<button
onClick={handleConfirm}
disabled={activeCount === 0 || phase === 'confirming'}
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
</button>
)}
<button
onClick={handleParse}
disabled={files.length === 0 || loading}
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{loading ? t('reservations.import.parsing') : t('common.import')}
</button>
</div>
</div>
</div>,
+19 -1
View File
@@ -18,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 { useBackgroundTasksStore } from '../store/backgroundTasksStore'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel'
@@ -211,6 +212,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
} = useTripPlanner()
// Bridge: when a finished background import is sent here for review (the user hit
// "review" in the background widget, on this or any page), open the per-item flow.
const bgTasks = useBackgroundTasksStore((s) => s.tasks)
const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss)
useEffect(() => {
const task = bgTasks.find(
(tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed,
)
if (task && task.items && task.items.length > 0) {
// Hand the items to the review flow and clear the widget entry — once the user
// hit "review", the background card has done its job.
const items = task.items
dismissBgTask(task.id)
startImportReview(items)
}
}, [bgTasks, tripId, startImportReview, dismissBgTask])
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
@@ -714,7 +732,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
/>
)}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} onImported={loadAccommodations} onReview={startImportReview} />
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog
isOpen={!!deletePlaceId}
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand'
import type { BookingImportPreviewItem } from '@trek/shared'
/**
* Tracks booking-import parses that run in the BACKGROUND (the async endpoint).
* The upload modal closes the moment a parse starts and adds a task here; the
* server pushes import:progress / import:done / import:error over the user's
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
* renders the list. The trip page turns a finished task into the review flow.
*
* Memory-only (no persist): a parse is tied to a live server job and WebSocket,
* so it shouldn't survive a reload.
*/
export interface BackgroundImportTask {
id: string // server job id
tripId: string
label: string // file name(s) being parsed
status: 'running' | 'done' | 'error'
done: number
total: number
items?: BookingImportPreviewItem[]
warnings?: string[]
error?: string
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
consumed?: boolean // review has been handed to the trip page
}
interface BackgroundTasksState {
tasks: BackgroundImportTask[]
addTask: (task: { id: string; tripId: string; label: string; total: number }) => void
setProgress: (id: string, tripId: string, done: number, total: number) => void
setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void
setError: (id: string, tripId: string, error: string) => void
requestReview: (id: string) => void
markConsumed: (id: string) => void
dismiss: (id: string) => void
}
export const useBackgroundTasksStore = create<BackgroundTasksState>((set) => {
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
set((state) => {
const idx = state.tasks.findIndex((t) => t.id === id)
if (idx === -1) {
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
return { tasks: [...state.tasks, { ...base, ...patch }] }
}
const tasks = state.tasks.slice()
tasks[idx] = { ...tasks[idx], ...patch }
return { tasks }
})
return {
tasks: [],
addTask: ({ id, tripId, label, total }) => upsert(id, tripId, { label, total, status: 'running', done: 0 }),
setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }),
setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }),
setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),
requestReview: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, reviewRequested: true } : t)) })),
markConsumed: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, consumed: true, reviewRequested: false } : t)) })),
dismiss: (id) => set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })),
}
})
@@ -1,6 +1,7 @@
import {
Controller,
Post,
Get,
Body,
Param,
Headers,
@@ -15,6 +16,7 @@ import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { BookingImportService } from './booking-import.service';
import { ImportJobsService } from './import-jobs.service';
import { bookingImportModeSchema } from '@trek/shared';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared';
@@ -30,7 +32,10 @@ const UPLOAD = {
@Controller('api/trips/:tripId/reservations/import')
@UseGuards(JwtAuthGuard)
export class BookingImportController {
constructor(private readonly bookingImport: BookingImportService) {}
constructor(
private readonly bookingImport: BookingImportService,
private readonly importJobs: ImportJobsService,
) {}
private requireTrip(tripId: string, user: User) {
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
@@ -44,6 +49,31 @@ export class BookingImportController {
}
}
/** Shared validation for both the sync and async import endpoints; returns the parsed mode. */
private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai');
if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400);
const mode = modeResult.data;
if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) {
throw new HttpException({ error: 'AI parsing is not configured' }, 409);
}
if (mode === 'no-ai' && !this.bookingImport.isAvailable()) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
if (!files || files.length === 0) throw new HttpException({ error: 'No files uploaded' }, 400);
for (const f of files) {
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
if (!ACCEPTED_EXTS.has(ext)) {
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
}
}
return mode;
}
/**
* POST /api/trips/:tripId/reservations/import/booking
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
@@ -56,39 +86,41 @@ export class BookingImportController {
@Param('tripId') tripId: string,
@UploadedFiles() files: Express.Multer.File[] | undefined,
@Body('mode') rawMode?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
): Promise<BookingImportPreviewResponse> {
const mode = this.validateImport(tripId, user, files, rawMode);
return this.bookingImport.preview(files!, mode, user.id);
}
const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai');
if (!modeResult.success) {
throw new HttpException({ error: 'Invalid mode' }, 400);
}
const mode: BookingImportMode = modeResult.data;
/**
* POST /api/trips/:tripId/reservations/import/booking/async
* Same input as /booking, but returns a job id immediately and parses in the
* background. Progress + completion are pushed over the user's WebSocket
* (import:progress / import:done / import:error). Lets the upload modal close at
* once and a background widget track the work while the user keeps navigating.
*/
@Post('booking/async')
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
async previewAsync(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFiles() files: Express.Multer.File[] | undefined,
@Body('mode') rawMode?: string,
): Promise<{ jobId: string }> {
const mode = this.validateImport(tripId, user, files, rawMode);
const jobId = this.importJobs.start(tripId, files!, mode, user.id);
return { jobId };
}
// Forcing AI requires it to be configured; otherwise surface a clear 4xx.
if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) {
throw new HttpException({ error: 'AI parsing is not configured' }, 409);
}
// For the kitinerary-only path, keep the existing 503 contract.
if (mode === 'no-ai' && !this.bookingImport.isAvailable()) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
if (!files || files.length === 0) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
// Validate extensions
for (const f of files) {
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
if (!ACCEPTED_EXTS.has(ext)) {
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
}
}
const result: BookingImportPreviewResponse = await this.bookingImport.preview(files, mode, user.id);
return result;
/**
* GET /api/trips/:tripId/reservations/import/jobs/:jobId
* Poll a background import job — recovery path for a client that missed the
* WebSocket push (navigation, reconnect). 404 once the job has expired.
*/
@Get('jobs/:jobId')
async jobStatus(@CurrentUser() user: User, @Param('jobId') jobId: string) {
const job = this.importJobs.get(jobId, user.id);
if (!job) throw new HttpException({ error: 'Job not found' }, 404);
return { status: job.status, done: job.done, total: job.total, result: job.result, error: job.error };
}
/**
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { BookingImportController } from './booking-import.controller';
import { BookingImportService } from './booking-import.service';
import { ImportJobsService } from './import-jobs.service';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
import { FeaturesController } from './features.controller';
import { LlmParseModule } from '../llm-parse/llm-parse.module';
@@ -8,6 +9,6 @@ import { LlmParseModule } from '../llm-parse/llm-parse.module';
@Module({
imports: [LlmParseModule],
controllers: [BookingImportController, FeaturesController],
providers: [BookingImportService, KitineraryExtractorService],
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
})
export class BookingImportModule {}
@@ -65,6 +65,7 @@ export class BookingImportService {
files: Express.Multer.File[],
mode: BookingImportMode,
userId: number,
onProgress?: (done: number, total: number, fileName: string) => void,
): Promise<BookingImportPreviewResponse> {
const kitineraryAvailable = this.extractor.isAvailable();
const aiAvailable = this.llmParse.isAvailable(userId);
@@ -76,6 +77,7 @@ export class BookingImportService {
const allWarnings: string[] = [];
const fileReports: BookingImportFileReport[] = [];
let processed = 0;
for (const file of files) {
let kiItems: KiReservation[] = [];
let aiUsed = false;
@@ -102,14 +104,16 @@ export class BookingImportService {
if (kiItems.length === 0) {
allWarnings.push(`${file.originalname}: no reservations found`);
continue;
} else {
const { items, warnings } = mapReservations(kiItems, file.originalname);
// LLM extraction is less certain than kitinerary — always flag for review.
if (aiUsed) for (const it of items) it.needs_review = true;
allItems.push(...items);
allWarnings.push(...warnings);
}
const { items, warnings } = mapReservations(kiItems, file.originalname);
// LLM extraction is less certain than kitinerary — always flag for review.
if (aiUsed) for (const it of items) it.needs_review = true;
allItems.push(...items);
allWarnings.push(...warnings);
// Report per-file progress so a background import can drive a live widget.
onProgress?.(++processed, files.length, file.originalname);
}
return { items: allItems, warnings: allWarnings, files: fileReports };
@@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { broadcastToUser } from '../../websocket';
import { BookingImportService } from './booking-import.service';
import type { BookingImportMode, BookingImportPreviewResponse } from '@trek/shared';
type JobStatus = 'running' | 'done' | 'error';
interface ImportJob {
id: string;
tripId: string;
userId: number;
status: JobStatus;
done: number;
total: number;
result?: BookingImportPreviewResponse;
error?: string;
createdAt: number;
}
// Keep a finished job around briefly so a client that missed the WebSocket push
// (navigation, reconnect) can still GET its result.
const JOB_TTL_MS = 10 * 60_000;
/**
* Runs a booking-import parse OFF the request: the controller returns a job id
* immediately, the parse continues here, and progress/completion are pushed to the
* user's sockets via `broadcastToUser` (which reaches them on ANY page, not just the
* trip room). This is what lets the upload modal close at once and a background widget
* track the work while the user keeps navigating. The actual parsing is the same
* `BookingImportService.preview` the synchronous endpoint uses.
*/
@Injectable()
export class ImportJobsService {
private readonly jobs = new Map<string, ImportJob>();
/** Tail of each user's job chain — parses run one at a time per user, not all at once. */
private readonly chains = new Map<number, Promise<void>>();
constructor(private readonly bookingImport: BookingImportService) {}
/** Create a job and queue it behind the user's other parses; returns the job id at once. */
start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string {
const id = randomUUID();
const job: ImportJob = { id, tripId, userId, status: 'running', done: 0, total: files.length, createdAt: Date.now() };
this.jobs.set(id, job);
// Chain onto the user's previous parse so they run sequentially (one CPU-heavy
// inference at a time), while the request returns immediately.
const prev = this.chains.get(userId) ?? Promise.resolve();
const next = prev.then(() => this.run(job, files, mode)).catch(() => {});
this.chains.set(userId, next);
void next.finally(() => {
if (this.chains.get(userId) === next) this.chains.delete(userId);
});
return id;
}
get(id: string, userId: number): ImportJob | undefined {
const job = this.jobs.get(id);
return job && job.userId === userId ? job : undefined;
}
private async run(job: ImportJob, files: Express.Multer.File[], mode: BookingImportMode): Promise<void> {
this.push(job, 'import:progress', { status: 'running', done: 0, total: job.total });
try {
const result = await this.bookingImport.preview(files, mode, job.userId, (done, total, fileName) => {
job.done = done;
this.push(job, 'import:progress', { status: 'running', done, total, fileName });
});
job.status = 'done';
job.result = result;
this.push(job, 'import:done', { result });
} catch (err) {
job.status = 'error';
job.error = err instanceof Error ? err.message : String(err);
this.push(job, 'import:error', { message: job.error });
} finally {
const id = job.id;
setTimeout(() => this.jobs.delete(id), JOB_TTL_MS).unref?.();
}
}
private push(job: ImportJob, type: string, payload: Record<string, unknown>): void {
broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload });
}
}