Files
TREK/client/src/store/backgroundTasksStore.ts
T
Maurice 130272cfb0 fix(booking-import): stop writing an unreadable type down as a hotel (#2076)
When neither the keyword table nor the model produced a type, the router wrote
"hotel" and cast whatever string it got to FlatType without checking it. A hotel
is a booking type, so a ferry voucher imported from the Transport tab opened the
booking form, which offers six chips and not one transport among them, and
"other" was the only honest pick left. An unrecognised string got through the
cast too and then vanished silently in nuExtractToKiReservations.

The type is validated against FLAT_TYPES now. When it cannot be read the item is
still extracted with the lodging shape so it survives mapping, but it says so,
and the tie-breaker #2080 added finally has something to fire on: isUnplaceableItem
only ever matched a type outside both form's vocabularies, and the server can
only emit its own eight, so on a real document it never matched at all.

The tab travels with the job instead of sitting in component state. The parse
deliberately outlives navigation and reload and the review is triggered by the
global widget on any page, so by the time it ran the state had remounted back to
its default and the context was gone.
2026-08-28 14:25:08 +02:00

99 lines
5.2 KiB
TypeScript

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { BookingImportPreviewItem, BookingImportMode } 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.
*
* Persisted (minimal): the server keeps the job for ~10 min and exposes a status
* endpoint, so a reload mid-parse must NOT drop the widget — we persist the running
* (and finished-but-unreviewed) tasks by id and the widget re-fetches their status
* on mount. We deliberately persist neither the parsed `items` (re-fetched) nor the
* transient review flags (so a reload never auto-reopens the review flow).
*/
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
/** The uploaded files this parse ran on — kept in memory so the review can attach the
* source document to each created booking. Not persisted (a File can't survive a reload). */
sourceFiles?: File[]
/** Mode this run used — the widget only offers an AI retry if it wasn't already one. Not persisted. */
mode?: BookingImportMode
/**
* Which tab the import was started from. It decides which form an item opens
* when its type is guessed (#2076), and unlike the other transient flags it IS
* persisted: the parse deliberately outlives navigation and reload, and the
* review is triggered by the global widget on any page, so a component-local
* value is gone by the time it is needed.
*/
kind?: 'transports' | 'bookings'
}
interface BackgroundTasksState {
tasks: BackgroundImportTask[]
addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[]; mode?: BookingImportMode; kind?: 'transports' | 'bookings' }) => 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>()(
persist(
(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, files, mode, kind }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files, mode, kind }),
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) })),
}
},
{
name: 'trek.bg-import-tasks',
// Persist only what survives a reload usefully: the job id/trip/label and a coarse
// status. The widget re-fetches each job's real status (and parsed items) on mount,
// so we keep neither the heavy `items`/`warnings` nor the transient review flags —
// that also guarantees a reload never re-opens the review flow on its own.
partialize: (state) => ({
tasks: state.tasks
.filter((t) => !t.consumed && t.status !== 'error')
// `kind` rides along: it is not a review flag but the context the review
// needs, and without it a reload silently falls back to 'bookings'.
.map((t) => ({ id: t.id, tripId: t.tripId, label: t.label, status: t.status, done: t.done, total: t.total, kind: t.kind })),
}),
},
),
)