|
@@ -6,11 +6,16 @@
|
|
|
* shows toasts on terminal state. Lives at app level so polling continues
|
|
* shows toasts on terminal state. Lives at app level so polling continues
|
|
|
* across navigation — slice can run in the background while the user does
|
|
* across navigation — slice can run in the background while the user does
|
|
|
* other things.
|
|
* other things.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Each tracked job also gets a persistent toast (`slice-job-{id}`) with a
|
|
|
|
|
+ * spinner + elapsed-time counter that updates every second so the user has
|
|
|
|
|
+ * a continuous visual indicator while a long slice is running. The toast
|
|
|
|
|
+ * is replaced by a transient success/error toast on terminal state.
|
|
|
*/
|
|
*/
|
|
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
-import { api, type SliceJobState } from '../api/client';
|
|
|
|
|
|
|
+import { api, type SliceJobState, type SliceJobStatus } from '../api/client';
|
|
|
import { useToast } from './ToastContext';
|
|
import { useToast } from './ToastContext';
|
|
|
|
|
|
|
|
interface TrackedJob {
|
|
interface TrackedJob {
|
|
@@ -27,10 +32,24 @@ interface SliceJobTrackerContextValue {
|
|
|
const SliceJobTrackerContext = createContext<SliceJobTrackerContextValue | null>(null);
|
|
const SliceJobTrackerContext = createContext<SliceJobTrackerContextValue | null>(null);
|
|
|
|
|
|
|
|
const POLL_INTERVAL_MS = 1500;
|
|
const POLL_INTERVAL_MS = 1500;
|
|
|
|
|
+const TICK_INTERVAL_MS = 1000;
|
|
|
|
|
+
|
|
|
|
|
+const toastIdFor = (jobId: number) => `slice-job-${jobId}`;
|
|
|
|
|
+
|
|
|
|
|
+function formatElapsed(seconds: number): string {
|
|
|
|
|
+ const s = Math.max(0, Math.floor(seconds));
|
|
|
|
|
+ if (s < 60) return `${s}s`;
|
|
|
|
|
+ const m = Math.floor(s / 60);
|
|
|
|
|
+ const remS = s % 60;
|
|
|
|
|
+ if (m < 60) return `${m}m ${remS}s`;
|
|
|
|
|
+ const h = Math.floor(m / 60);
|
|
|
|
|
+ const remM = m % 60;
|
|
|
|
|
+ return `${h}h ${remM}m`;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
|
|
export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
|
|
|
const { t } = useTranslation();
|
|
const { t } = useTranslation();
|
|
|
- const { showToast } = useToast();
|
|
|
|
|
|
|
+ const { showToast, showPersistentToast, dismissToast } = useToast();
|
|
|
const queryClient = useQueryClient();
|
|
const queryClient = useQueryClient();
|
|
|
const [activeJobs, setActiveJobs] = useState<TrackedJob[]>([]);
|
|
const [activeJobs, setActiveJobs] = useState<TrackedJob[]>([]);
|
|
|
|
|
|
|
@@ -39,17 +58,52 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
|
|
|
const activeJobsRef = useRef<TrackedJob[]>([]);
|
|
const activeJobsRef = useRef<TrackedJob[]>([]);
|
|
|
activeJobsRef.current = activeJobs;
|
|
activeJobsRef.current = activeJobs;
|
|
|
|
|
|
|
|
|
|
+ // Per-job start time + latest phase, kept in refs so the 1s tick
|
|
|
|
|
+ // doesn't need to re-render on every update. Keyed by job id.
|
|
|
|
|
+ const startedAtRef = useRef<Map<number, number>>(new Map());
|
|
|
|
|
+ const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
|
|
|
|
|
+
|
|
|
|
|
+ const renderProgressToast = useCallback(
|
|
|
|
|
+ (job: TrackedJob) => {
|
|
|
|
|
+ const startedAt = startedAtRef.current.get(job.id);
|
|
|
|
|
+ if (startedAt == null) return;
|
|
|
|
|
+ const elapsedSecs = (Date.now() - startedAt) / 1000;
|
|
|
|
|
+ const phase = phaseRef.current.get(job.id) ?? 'pending';
|
|
|
|
|
+ const messageKey = phase === 'pending' ? 'slice.queuedToast' : 'slice.runningToast';
|
|
|
|
|
+ const fallback =
|
|
|
|
|
+ phase === 'pending'
|
|
|
|
|
+ ? 'Queued: {{name}} — {{elapsed}}'
|
|
|
|
|
+ : 'Slicing {{name}} — {{elapsed}}';
|
|
|
|
|
+ showPersistentToast(
|
|
|
|
|
+ toastIdFor(job.id),
|
|
|
|
|
+ t(messageKey, fallback, { name: job.sourceName, elapsed: formatElapsed(elapsedSecs) }),
|
|
|
|
|
+ 'loading',
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ [showPersistentToast, t],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
const trackJob = useCallback(
|
|
const trackJob = useCallback(
|
|
|
(id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
|
|
(id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
|
|
|
setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
|
|
setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
|
|
|
- showToast(t('slice.startedToast', 'Slicing {{name}} in the background…', { name: sourceName }), 'info');
|
|
|
|
|
|
|
+ startedAtRef.current.set(id, Date.now());
|
|
|
|
|
+ phaseRef.current.set(id, 'pending');
|
|
|
|
|
+ // Render the initial frame immediately so the user sees the toast
|
|
|
|
|
+ // before the first tick lands (~1s delay otherwise).
|
|
|
|
|
+ renderProgressToast({ id, kind, sourceName });
|
|
|
},
|
|
},
|
|
|
- [showToast, t],
|
|
|
|
|
|
|
+ [renderProgressToast],
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
const completeJob = useCallback(
|
|
const completeJob = useCallback(
|
|
|
(job: TrackedJob, state: SliceJobState) => {
|
|
(job: TrackedJob, state: SliceJobState) => {
|
|
|
setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
|
|
setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
|
|
|
|
|
+ startedAtRef.current.delete(job.id);
|
|
|
|
|
+ phaseRef.current.delete(job.id);
|
|
|
|
|
+
|
|
|
|
|
+ // Replace the persistent progress toast with a transient
|
|
|
|
|
+ // success/error toast (auto-dismisses after 3s, same as showToast).
|
|
|
|
|
+ dismissToast(toastIdFor(job.id));
|
|
|
|
|
|
|
|
if (state.status === 'completed') {
|
|
if (state.status === 'completed') {
|
|
|
// `used_embedded_settings` still comes back on the result for tests
|
|
// `used_embedded_settings` still comes back on the result for tests
|
|
@@ -70,19 +124,21 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
|
|
|
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
|
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
|
|
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
|
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
|
|
},
|
|
},
|
|
|
- [queryClient, showToast, t],
|
|
|
|
|
|
|
+ [dismissToast, queryClient, showToast, t],
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ // Status polling. Updates phase on each successful poll and triggers
|
|
|
|
|
+ // completeJob on terminal states.
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (activeJobs.length === 0) return;
|
|
if (activeJobs.length === 0) return;
|
|
|
let cancelled = false;
|
|
let cancelled = false;
|
|
|
const interval = setInterval(async () => {
|
|
const interval = setInterval(async () => {
|
|
|
if (cancelled) return;
|
|
if (cancelled) return;
|
|
|
- // Snapshot the current list so concurrent updates don't surprise us.
|
|
|
|
|
const snapshot = [...activeJobsRef.current];
|
|
const snapshot = [...activeJobsRef.current];
|
|
|
for (const job of snapshot) {
|
|
for (const job of snapshot) {
|
|
|
try {
|
|
try {
|
|
|
const state = await api.getSliceJob(job.id);
|
|
const state = await api.getSliceJob(job.id);
|
|
|
|
|
+ phaseRef.current.set(job.id, state.status);
|
|
|
if (state.status === 'completed' || state.status === 'failed') {
|
|
if (state.status === 'completed' || state.status === 'failed') {
|
|
|
completeJob(job, state);
|
|
completeJob(job, state);
|
|
|
}
|
|
}
|
|
@@ -97,6 +153,19 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
|
|
|
};
|
|
};
|
|
|
}, [activeJobs.length, completeJob]);
|
|
}, [activeJobs.length, completeJob]);
|
|
|
|
|
|
|
|
|
|
+ // 1Hz tick that re-renders each persistent progress toast with the
|
|
|
|
|
+ // current elapsed time. Independent of the status poll so the counter
|
|
|
|
|
+ // stays smooth even while the backend is slow to respond.
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (activeJobs.length === 0) return;
|
|
|
|
|
+ const tick = setInterval(() => {
|
|
|
|
|
+ for (const job of activeJobsRef.current) {
|
|
|
|
|
+ renderProgressToast(job);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, TICK_INTERVAL_MS);
|
|
|
|
|
+ return () => clearInterval(tick);
|
|
|
|
|
+ }, [activeJobs.length, renderProgressToast]);
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<SliceJobTrackerContext.Provider value={{ trackJob, activeJobs }}>
|
|
<SliceJobTrackerContext.Provider value={{ trackJob, activeJobs }}>
|
|
|
{children}
|
|
{children}
|