mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
fix(import): keep the parse-progress widget across a reload
Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -24,6 +24,31 @@ export default function BackgroundTasksWidget() {
|
|||||||
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
||||||
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
||||||
|
|
||||||
|
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
|
||||||
|
// that was still running when the page reloaded must keep its widget, so re-fetch each
|
||||||
|
// job's real status (and its parsed items) once. A job the server has since dropped
|
||||||
|
// (404, expired) is removed so no stale card lingers.
|
||||||
|
const didRehydrate = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (didRehydrate.current) return
|
||||||
|
didRehydrate.current = true
|
||||||
|
const restored = useBackgroundTasksStore.getState().tasks
|
||||||
|
for (const task of restored) {
|
||||||
|
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((err: { response?: { status?: number } }) => {
|
||||||
|
if (err?.response?.status === 404) dismiss(task.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// run once on mount against whatever was rehydrated from storage
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Server pushes import:* to the user on whatever page they're on.
|
// Server pushes import:* to the user on whatever page they're on.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Record<string, unknown>) => {
|
const handler = (e: Record<string, unknown>) => {
|
||||||
@@ -42,12 +67,14 @@ export default function BackgroundTasksWidget() {
|
|||||||
return () => removeListener(handler)
|
return () => removeListener(handler)
|
||||||
}, [setProgress, setDone, setError])
|
}, [setProgress, setDone, setError])
|
||||||
|
|
||||||
// Backstop: poll running jobs in case a WebSocket push was missed on reconnect.
|
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
|
||||||
|
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
|
||||||
|
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const running = tasks.filter((task) => task.status === 'running')
|
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
|
||||||
if (running.length === 0) return
|
if (pending.length === 0) return
|
||||||
const iv = setInterval(() => {
|
const iv = setInterval(() => {
|
||||||
for (const task of running) {
|
for (const task of pending) {
|
||||||
reservationsApi
|
reservationsApi
|
||||||
.importJobStatus(task.tripId, task.id)
|
.importJobStatus(task.tripId, task.id)
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
@@ -79,8 +106,8 @@ export default function BackgroundTasksWidget() {
|
|||||||
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' }}
|
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 }}>
|
<div style={{ flexShrink: 0, marginTop: 1 }}>
|
||||||
{task.status === 'running' && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
||||||
{task.status === 'done' && <CheckCircle2 size={16} color="#10b981" />}
|
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
|
||||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,7 +124,10 @@ export default function BackgroundTasksWidget() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{task.status === 'done' && (
|
{task.status === 'done' && (
|
||||||
(task.items?.length ?? 0) > 0 ? (
|
task.items === undefined ? (
|
||||||
|
// Restored from a reload; items are being re-fetched (see the poll backstop).
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
|
||||||
|
) : task.items.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => review(task)}
|
onClick={() => review(task)}
|
||||||
className="bg-accent text-accent-text"
|
className="bg-accent text-accent-text"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,8 +9,11 @@ import type { BookingImportPreviewItem } from '@trek/shared'
|
|||||||
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
|
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
|
||||||
* renders the list. The trip page turns a finished task into the review flow.
|
* 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,
|
* Persisted (minimal): the server keeps the job for ~10 min and exposes a status
|
||||||
* so it shouldn't survive a reload.
|
* 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 {
|
export interface BackgroundImportTask {
|
||||||
id: string // server job id
|
id: string // server job id
|
||||||
@@ -36,28 +40,44 @@ interface BackgroundTasksState {
|
|||||||
dismiss: (id: string) => void
|
dismiss: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBackgroundTasksStore = create<BackgroundTasksState>((set) => {
|
export const useBackgroundTasksStore = create<BackgroundTasksState>()(
|
||||||
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
|
persist(
|
||||||
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
|
(set) => {
|
||||||
set((state) => {
|
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
|
||||||
const idx = state.tasks.findIndex((t) => t.id === id)
|
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
|
||||||
if (idx === -1) {
|
set((state) => {
|
||||||
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
|
const idx = state.tasks.findIndex((t) => t.id === id)
|
||||||
return { tasks: [...state.tasks, { ...base, ...patch }] }
|
if (idx === -1) {
|
||||||
}
|
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
|
||||||
const tasks = state.tasks.slice()
|
return { tasks: [...state.tasks, { ...base, ...patch }] }
|
||||||
tasks[idx] = { ...tasks[idx], ...patch }
|
}
|
||||||
return { tasks }
|
const tasks = state.tasks.slice()
|
||||||
})
|
tasks[idx] = { ...tasks[idx], ...patch }
|
||||||
|
return { tasks }
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tasks: [],
|
tasks: [],
|
||||||
addTask: ({ id, tripId, label, total }) => upsert(id, tripId, { label, total, status: 'running', done: 0 }),
|
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' }),
|
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 }),
|
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 }),
|
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)) })),
|
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)) })),
|
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) })),
|
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')
|
||||||
|
.map((t) => ({ id: t.id, tripId: t.tripId, label: t.label, status: t.status, done: t.done, total: t.total })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user