fix(operations): preserve task state and accessible recovery

This commit is contained in:
cesnimda
2026-10-17 04:03:50 +02:00
parent 40cfe04df5
commit c5176e3251
16 changed files with 5466 additions and 129 deletions
+26
View File
@@ -429,6 +429,19 @@ export const translations = {
operations: "Operations",
refresh: "Refresh",
operationsSubtitle: "Background work survives navigation and refresh.",
operationsActivity: "Recent activity",
operationsRecentHelp: "Shows up to 50 recent notifications and 50 recent operations.",
operationsCommittedRefreshFailed: "The action completed, but recent activity could not be refreshed. Refresh before taking another action.",
operationsCreated: "Created {date}",
operationsTaskCv: "CV processing",
operationsTaskStrategy: "Strategy snapshot",
operationsStatusQueued: "Queued",
operationsStatusRunning: "Running",
operationsStatusWaitingForRetry: "Waiting to retry",
operationsStatusWaitingForFallback: "Waiting for approved fallback",
operationsStatusSucceeded: "Completed",
operationsStatusFailed: "Failed",
operationsStatusCancelled: "Cancelled",
operationsLoadFailed: "Operations could not be loaded.",
operationsActionFailed: "The action could not be completed.",
operationsLoading: "Loading operations",
@@ -3173,6 +3186,19 @@ export const translations = {
operations: "Operasjoner",
refresh: "Oppdater",
operationsSubtitle: "Bakgrunnsarbeid fortsetter når du navigerer eller oppdaterer siden.",
operationsActivity: "Nylig aktivitet",
operationsRecentHelp: "Viser opptil 50 nylige varsler og 50 nylige operasjoner.",
operationsCommittedRefreshFailed: "Handlingen er fullført, men nylig aktivitet kunne ikke oppdateres. Oppdater før du utfører en ny handling.",
operationsCreated: "Opprettet {date}",
operationsTaskCv: "CV-behandling",
operationsTaskStrategy: "Strategioversikt",
operationsStatusQueued: "I kø",
operationsStatusRunning: "Pågår",
operationsStatusWaitingForRetry: "Venter på nytt forsøk",
operationsStatusWaitingForFallback: "Venter på godkjent reservevalg",
operationsStatusSucceeded: "Fullført",
operationsStatusFailed: "Mislyktes",
operationsStatusCancelled: "Avbrutt",
operationsLoadFailed: "Operasjonene kunne ikke lastes.",
operationsActionFailed: "Handlingen kunne ikke fullføres.",
operationsLoading: "Laster operasjoner",
+168 -6
View File
@@ -1,10 +1,11 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import OperationsPage from "./views/OperationsPage";
import { translations } from "./i18n/translations";
jest.mock("./api", () => ({
api: {
@@ -43,20 +44,45 @@ const notification = {
readAtUtc: null,
};
let serverOperations: any[];
let serverNotifications: any[];
function deferred<T = any>() {
let resolve!: (value: T) => void;
let reject!: (cause: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
async function language(value: "en" | "nb") {
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: value })));
}
function response(url: string) { return { data: url.startsWith("/operations") ? serverOperations : serverNotifications } as any; }
function activity() { return screen.getByRole("region", { name: "Recent activity" }); }
beforeEach(() => {
jest.clearAllMocks();
window.localStorage.clear();
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
data: url.startsWith("/operations") ? [operation] : [notification],
} as any));
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
document.documentElement.lang = "en";
serverOperations = [{ ...operation }]; serverNotifications = [{ ...notification }];
mockedApi.get.mockReset().mockImplementation((url: string) => Promise.resolve(response(url)));
mockedApi.post.mockReset().mockImplementation((url: string) => {
if (url.startsWith("/operations")) {
serverOperations = serverOperations.map(item => ({ ...item, status: url.endsWith("/cancel") ? "cancelled" : "queued", canCancel: url.endsWith("/retry"), canRetry: url.endsWith("/cancel") }));
return Promise.resolve({ data: serverOperations[0] } as any);
}
serverNotifications = serverNotifications.map(item => ({ ...item, readAtUtc: "2026-08-02T12:06:00Z" }));
return Promise.resolve({ data: undefined } as any);
});
mockedApi.delete.mockReset().mockImplementation(() => { serverNotifications = []; return Promise.resolve({ data: undefined } as any); });
});
afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); });
test("shows persistent operation and notification states with accessible actions", async () => {
renderPage();
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
const task = screen.getByRole("article", { name: operation.taskType });
expect(task).toHaveAccessibleName(operation.taskType);
expect(task).toHaveAttribute("data-operation-id", operation.id);
expect(screen.getByText("Analysing role")).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "strategy_snapshot progress" })).toHaveAttribute("aria-valuenow", "40");
expect(screen.getByText("Operation failed")).toBeInTheDocument();
@@ -85,6 +111,8 @@ test("shows honest empty and failure states", async () => {
mockedApi.get.mockRejectedValue(new Error("offline"));
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument();
expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
});
test("localizes operation controls and empty states in Norwegian Bokmål", async () => {
@@ -97,3 +125,137 @@ test("localizes operation controls and empty states in Norwegian Bokmål", async
expect(screen.getByText("Ingen bakgrunnsoperasjoner ennå.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Oppdater" })).toBeInTheDocument();
});
test("[P7-08 operations] initial failure offers single-flight GET Retry without false empty or writes", async () => {
mockedApi.get.mockRejectedValue(new Error("offline")); renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
expect((mockedApi.get.mock.calls[0][1]?.signal as AbortSignal).aborted).toBe(true);
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
const retry = screen.getByRole("button", { name: "Retry" }); act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
expect(mockedApi.get).toHaveBeenCalledTimes(4); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: [] })); expect(await screen.findByText("No notifications.")).toBeVisible();
expect(screen.getByRole("heading", { name: "Recent activity", level: 2 })).toHaveFocus(); expect(activity()).toHaveAttribute("aria-busy", "false");
expect(mockedApi.get).toHaveBeenCalledWith("/operations?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
});
test("[P7-08 operations] cached refresh failure preserves known rows and does not fabricate empty results", async () => {
renderPage(); expect(await screen.findByText(operation.taskType)).toBeVisible(); mockedApi.get.mockRejectedValue(new Error("offline"));
fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
expect(screen.getByText(operation.taskType)).toBeVisible(); expect(screen.getByText(notification.title)).toBeVisible();
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test.each(["Cancel", "Retry", "Mark read", "Dismiss"])("[P7-08 operations] same-render duplicate %s reserves exactly one mutation and blocks competing work", async label => {
if (label === "Retry") serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
const pending = deferred(); mockedApi.post.mockImplementation(() => pending.promise); mockedApi.delete.mockImplementation(() => pending.promise);
renderPage(); const button = await screen.findByRole("button", { name: label });
act(() => { fireEvent.click(button); fireEvent.click(button); fireEvent.click(screen.getByRole("button", { name: label === "Dismiss" ? "Mark read" : "Dismiss" })); });
expect(mockedApi.post.mock.calls.length + mockedApi.delete.mock.calls.length).toBe(1); expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
await act(async () => pending.resolve({ data: label === "Cancel" || label === "Retry" ? { ...serverOperations[0], canCancel: false, canRetry: false } : undefined }));
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
});
test("[P7-08 operations] language and ordinary rerenders reformat cached data without refetch or polling restart", async () => {
const intervals = jest.spyOn(window, "setInterval"); const cleared = jest.spyOn(window, "clearInterval");
serverOperations = [{ ...operation, taskType: "strategy.snapshot", status: "waiting_for_retry" }]; const { rerender } = renderPage();
expect(await screen.findByText("Strategy snapshot")).toBeVisible(); expect(screen.getByText("Waiting to retry")).toBeVisible(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
const starts = intervals.mock.calls.length; const pollId = intervals.mock.results[intervals.mock.calls.findIndex(([, delay]) => delay === 15000)].value; await language("nb");
expect(screen.getByText("Strategioversikt")).toBeVisible(); expect(screen.getByText("Venter på nytt forsøk")).toBeVisible();
expect(screen.getByText(`Opprettet ${new Date(operation.createdAtUtc).toLocaleString("nb-NO")}`)).toBeVisible();
rerender(<I18nProvider><OperationsPage /></I18nProvider>); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(intervals).toHaveBeenCalledTimes(starts); expect(cleared).not.toHaveBeenCalledWith(pollId);
});
test("[P7-08 operations] 15-second polling is single-flight and resumes after the held pair completes", async () => {
jest.useFakeTimers(); renderPage(); await act(async () => {}); expect(mockedApi.get).toHaveBeenCalledTimes(2);
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
await act(async () => jest.advanceTimersByTime(30000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
await act(async () => pending.resolve({ data: [] })); mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(6); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("[P7-08 operations] mutation invalidates a held old poll before adopting the returned same-ID DTO", async () => {
jest.useFakeTimers(); renderPage(); await act(async () => {}); const oldOperations = deferred(); const oldNotices = deferred();
mockedApi.get.mockImplementationOnce(() => oldOperations.promise).mockImplementationOnce(() => oldNotices.promise);
await act(async () => jest.advanceTimersByTime(15000)); const signal = mockedApi.get.mock.calls[2][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal);
const post = deferred(); const refresh = deferred(); mockedApi.post.mockImplementationOnce(() => post.promise); mockedApi.get.mockImplementation(() => refresh.promise);
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(signal.aborted).toBe(true);
const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
await act(async () => post.resolve({ data: cancelled })); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Cancelled");
await act(async () => { oldOperations.resolve({ data: [{ ...operation, progressStage: "Stale old poll" }] }); oldNotices.resolve({ data: [notification] }); });
expect(screen.queryByText("Stale old poll")).not.toBeInTheDocument();
await act(async () => refresh.resolve({ data: [] })); expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test("[P7-08 operations] committed cancel plus failed refresh announces truth and GET Retry cannot replay or unlock stale writes", async () => {
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
mockedApi.post.mockResolvedValueOnce({ data: cancelled } as any); mockedApi.get.mockRejectedValue(new Error("read offline")); act(() => cancel.focus()); fireEvent.click(cancel);
const failure = await screen.findByRole("alert"); expect(failure).toHaveTextContent("The action completed, but recent activity could not be refreshed. Refresh before taking another action.");
const item = screen.getByRole("article", { name: operation.taskType }); expect(within(item).getByRole("button", { name: "Retry" })).toBeDisabled(); expect(item).toHaveTextContent("Cancelled"); expect(changed).toHaveBeenCalledTimes(1);
expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
serverOperations = [cancelled]; mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
fireEvent.click(within(failure).getByRole("button", { name: "Retry" })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(changed).toHaveBeenCalledTimes(1); expect(within(item).getByRole("button", { name: "Retry" })).toBeEnabled();
} finally { window.removeEventListener("notifications-changed", changed); }
});
test.each(["Mark read", "Dismiss"])("[P7-08 operations] committed204 %s updates notices locally even when refresh fails without completing a task", async label => {
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
try { renderPage(); const action = await screen.findByRole("button", { name: label }); mockedApi.get.mockRejectedValue(new Error("offline")); fireEvent.click(action);
expect(await screen.findByRole("alert")).toHaveTextContent("The action completed, but recent activity could not be refreshed.");
expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); if (label === "Dismiss") expect(screen.queryByText(notification.title)).not.toBeInTheDocument(); else expect(screen.getByText(notification.title)).toBeVisible();
expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Running"); expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(mockedApi.post.mock.calls.filter(([url]) => String(url).startsWith("/operations"))).toEqual([]); expect(changed).toHaveBeenCalledTimes(1);
} finally { window.removeEventListener("notifications-changed", changed); }
});
test("[P7-08 operations] failed POST preserves eligibility and current-locale recovery without false commit or new GET", async () => {
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); act(() => cancel.focus()); fireEvent.click(cancel); await language("nb"); expect(mockedApi.get).toHaveBeenCalledTimes(2);
await act(async () => pending.reject(new Error("offline"))); expect(await screen.findByRole("alert")).toHaveTextContent(translations.nb.operationsActionFailed); expect(screen.getByRole("button", { name: translations.nb.cancel })).toBeEnabled(); expect(screen.getByRole("button", { name: translations.nb.cancel })).toHaveFocus(); expect(changed).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
await language("en"); expect(screen.getByRole("alert")).toHaveTextContent(translations.en.operationsActionFailed); expect(mockedApi.get).toHaveBeenCalledTimes(2);
} finally { window.removeEventListener("notifications-changed", changed); }
});
test("[P7-08 operations] server action admission overrides displayed status and unknown metadata remains honest", async () => {
serverOperations = [{ ...operation, taskType: "Unknown_Task_Type", status: "succeeded", progressStage: "arbitrary-stage", failureCategory: "unmapped_category", canCancel: true, canRetry: false }, { ...operation, id: "foreign-looking-local-fixture", taskType: "cv.process", status: "running", canCancel: false, canRetry: true }, { ...operation, id: "unmapped-prototype-code", taskType: "toString", status: "constructor", canCancel: false, canRetry: false }];
renderPage(); expect(await screen.findByText("Unknown_Task_Type")).toBeVisible(); const first = screen.getByRole("article", { name: "Unknown_Task_Type" }); expect(first).toHaveAttribute("data-operation-id", operation.id); expect(within(first).getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(within(first).queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); expect(first).toHaveTextContent("arbitrary-stage"); expect(first).toHaveTextContent("unmapped category");
const second = screen.getByRole("article", { name: "CV processing" }); expect(second).toHaveAttribute("data-operation-id", "foreign-looking-local-fixture"); expect(within(second).queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(within(second).getByRole("button", { name: "Retry" })).toBeEnabled();
const unknown = screen.getByRole("article", { name: "toString" }); expect(unknown).toHaveAttribute("data-operation-id", "unmapped-prototype-code"); expect(unknown).toHaveTextContent("constructor"); expect(within(unknown).queryAllByRole("button")).toHaveLength(0);
});
test("[P7-08 operations] unmount aborts a pending read and late completion cannot fetch or publish", async () => {
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise); const { unmount } = renderPage(); const signal = mockedApi.get.mock.calls[0][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal); unmount(); expect(signal.aborted).toBe(true); await act(async () => pending.resolve({ data: [operation] })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("[P7-08 operations] unmount during a write prevents its late follow-up GET, event and focus effects", async () => {
const changed = jest.fn(); window.addEventListener("notifications-changed", changed); const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise);
try { const { unmount } = renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); unmount(); await act(async () => pending.resolve({ data: { ...operation, status: "cancelled" } })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(changed).not.toHaveBeenCalled(); } finally { window.removeEventListener("notifications-changed", changed); }
});
test("[P7-08 operations] removing the initiating control restores meaningful focus while deliberate focus elsewhere stays put", async () => {
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); renderPage(); const mark = await screen.findByRole("button", { name: "Mark read" }); act(() => mark.focus()); fireEvent.click(mark); serverNotifications = [{ ...notification, readAtUtc: "2026-08-02T12:06:00Z" }]; await act(async () => pending.resolve({ data: undefined })); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
const next = deferred(); mockedApi.delete.mockImplementationOnce(() => next.promise); const dismiss = screen.getByRole("button", { name: "Dismiss" }); act(() => dismiss.focus()); fireEvent.click(dismiss); const external = document.createElement("button"); external.textContent = "Deliberate external target"; document.body.appendChild(external); act(() => external.focus()); serverNotifications = []; await act(async () => next.resolve({ data: undefined })); expect(external).toHaveFocus(); external.remove();
});
test("[P7-08 operations] accepted background polls preserve surviving and deliberate focus and recover removed focused controls", async () => {
jest.useFakeTimers(); renderPage(); await act(async () => {});
const cancel = screen.getByRole("button", { name: "Cancel" }); act(() => cancel.focus());
serverOperations = [{ ...operation, progressStage: "Fresh progress with the same action" }];
await act(async () => jest.advanceTimersByTime(15000)); expect(cancel).toHaveFocus(); expect(cancel.isConnected).toBe(true);
const external = document.createElement("button"); external.textContent = "Deliberate target outside recent activity"; document.body.appendChild(external);
try {
act(() => external.focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus(); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
serverOperations = [{ ...operation }]; await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus();
act(() => screen.getByRole("button", { name: "Cancel" }).focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Mark read" }).focus()); serverNotifications = [];
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
expect(mockedApi.get).toHaveBeenCalledTimes(12); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
} finally { external.remove(); }
});
+135 -110
View File
@@ -1,147 +1,172 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
LinearProgress,
Paper,
Stack,
Typography,
} from "@mui/material";
import { Alert, Box, Button, Chip, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { notificationDateLabel, UserNotification } from "../notifications";
import type { TranslationKey } from "../i18n/translations";
import { PageHeader, PageSection } from "../layout/PageLayout";
import { notificationDateLabel, type UserNotification } from "../notifications";
import type { UserOperation } from "../types";
type Operation = {
id: string;
taskType: string;
status: string;
subjectType?: string | null;
createdAtUtc: string;
completedAtUtc?: string | null;
cancellationRequestedAtUtc?: string | null;
progressStage?: string | null;
progressPercent?: number | null;
failureCategory?: string | null;
canCancel: boolean;
canRetry: boolean;
const statusKeys: Record<string, TranslationKey> = {
queued: "operationsStatusQueued", running: "operationsStatusRunning",
waiting_for_retry: "operationsStatusWaitingForRetry", waiting_for_external_fallback: "operationsStatusWaitingForFallback",
succeeded: "operationsStatusSucceeded", failed: "operationsStatusFailed", cancelled: "operationsStatusCancelled",
};
const statusLabel = (value: string) => value.replaceAll("_", " ");
const taskKeys: Record<string, TranslationKey> = { "cv.process": "operationsTaskCv", "strategy.snapshot": "operationsTaskStrategy" };
const buttonSx = { minHeight: { xs: 44, sm: 36 }, whiteSpace: "normal", overflowWrap: "anywhere", "@media (pointer: coarse)": { minHeight: 44 } } as const;
type Failure = { cause: unknown; kind: "load" | "action" | "committed" };
type ActionUpdate = { operation?: UserOperation; readId?: string; dismissId?: string };
export default function OperationsPage() {
const { language, t } = useI18n();
const [operations, setOperations] = useState<Operation[]>([]);
const [operations, setOperations] = useState<UserOperation[]>([]);
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
const [error, setError] = useState<Failure | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const mounted = useRef(false);
const sequence = useRef(0);
const activeLoad = useRef<AbortController | null>(null);
const pendingAction = useRef(false);
const refreshRequired = useRef(false);
const activityRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const actionFocus = useRef<HTMLElement | null>(null);
const readFocus = useRef<HTMLElement | null>(null);
const load = useCallback(async (showLoading = false) => {
if (showLoading) setLoading(true);
const load = useCallback(async (afterWrite = false) => {
if (!mounted.current || activeLoad.current || pendingAction.current && !afterWrite) return;
const request = ++sequence.current;
const controller = new AbortController();
activeLoad.current = controller;
setLoading(true);
try {
const [operationResponse, notificationResponse] = await Promise.all([
api.get<Operation[]>("/operations?limit=50"),
api.get<UserNotification[]>("/notifications?limit=50"),
api.get<UserOperation[]>("/operations?limit=50", { signal: controller.signal }),
api.get<UserNotification[]>("/notifications?limit=50", { signal: controller.signal }),
]);
if (!mounted.current || controller.signal.aborted || request !== sequence.current) return;
const active = document.activeElement;
readFocus.current = active instanceof HTMLElement && activityRef.current?.contains(active) ? active : null;
setOperations(operationResponse.data ?? []);
setNotifications(notificationResponse.data ?? []);
setHasLoaded(true);
refreshRequired.current = false;
setError(null);
} catch (requestError) {
setError(getApiErrorMessage(requestError, t("operationsLoadFailed")));
} catch (cause) {
if (mounted.current && !controller.signal.aborted && request === sequence.current) {
setError({ cause, kind: refreshRequired.current ? "committed" : "load" });
controller.abort();
}
} finally {
setLoading(false);
if (mounted.current && request === sequence.current) { activeLoad.current = null; setLoading(false); }
}
}, [t]);
}, []);
useEffect(() => {
mounted.current = true;
void load();
const interval = window.setInterval(() => void load(), 15000);
return () => window.clearInterval(interval);
return () => { mounted.current = false; sequence.current += 1; activeLoad.current?.abort(); activeLoad.current = null; window.clearInterval(interval); };
}, [load]);
const runAction = async (key: string, action: () => Promise<unknown>, notificationsChanged = false) => {
if (busyKey) return;
useEffect(() => {
if (loading || busyKey || !actionFocus.current) return;
const target = actionFocus.current;
actionFocus.current = null;
if (document.activeElement !== activityRef.current) return;
if (target.isConnected && !target.matches(":disabled")) target.focus();
else headingRef.current?.focus();
}, [busyKey, error, loading, notifications, operations]);
useEffect(() => {
const target = readFocus.current;
readFocus.current = null;
if (target && !target.isConnected && document.activeElement === document.body) headingRef.current?.focus();
}, [loading, notifications, operations]);
const rememberFocus = () => {
const active = document.activeElement;
if (active instanceof HTMLElement && active !== activityRef.current && activityRef.current?.contains(active)) {
actionFocus.current = active;
activityRef.current.focus();
}
};
const refresh = () => { rememberFocus(); void load(); };
const runAction = async (key: string, action: () => Promise<ActionUpdate>) => {
if (!mounted.current || pendingAction.current || refreshRequired.current) return;
pendingAction.current = true;
sequence.current += 1;
activeLoad.current?.abort();
activeLoad.current = null;
setLoading(false);
rememberFocus();
setBusyKey(key);
setError(null);
try {
await action();
await load();
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
} catch (requestError) {
setError(getApiErrorMessage(requestError, t("operationsActionFailed")));
const update = await action();
if (!mounted.current) return;
if (update.operation) setOperations(items => items.map(item => item.id === update.operation!.id ? update.operation! : item));
if (update.readId) setNotifications(items => items.map(item => item.id === update.readId ? { ...item, readAtUtc: new Date().toISOString() } : item));
if (update.dismissId) setNotifications(items => items.filter(item => item.id !== update.dismissId));
// A failed follow-up read must never invite replay of a committed write.
refreshRequired.current = true;
window.dispatchEvent(new Event("notifications-changed"));
await load(true);
} catch (cause) {
if (mounted.current) setError({ cause, kind: "action" });
} finally {
setBusyKey(null);
if (mounted.current) { pendingAction.current = false; setBusyKey(null); }
}
};
return (
<Stack spacing={2}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Typography color="text.secondary">{t("operationsSubtitle")}</Typography>
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>{t("refresh")}</Button>
</Box>
const locked = busyKey !== null || error?.kind === "committed";
const taskLabel = (task: string) => Object.hasOwn(taskKeys, task) ? t(taskKeys[task]) : task;
const dateLabel = (value: string) => notificationDateLabel(value, language === "nb" ? "nb-NO" : "en-GB");
const errorTitle = error ? t(error.kind === "committed" ? "operationsCommittedRefreshFailed" : error.kind === "action" ? "operationsActionFailed" : "operationsLoadFailed") : "";
const errorDetail = error ? getApiErrorMessage(error.cause, errorTitle) : "";
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>{t("notifications")}</Typography>
{notifications.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
<Stack spacing={1.5}>
{notifications.map((notification) => (
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
<Typography color="text.secondary">{notification.message}</Typography>
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en")}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{!notification.readAtUtc ? (
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
{t("notificationsMarkRead")}
</Button>
) : null}
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
{t("notificationsDismiss")}
</Button>
</Stack>
</Box>
))}
return <Stack ref={activityRef} role="region" aria-label={t("operationsActivity")} aria-busy={loading || busyKey !== null} tabIndex={-1} spacing={2} sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("operationsActivity")} titleRef={headingRef} description={t("operationsSubtitle")} actions={<Button variant="outlined" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("refresh")}</Button>} />
<Typography variant="body2" color="text.secondary">{t("operationsRecentHelp")}</Typography>
{error ? <Alert severity="error" sx={{ overflowWrap: "anywhere", display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", "& .MuiAlert-message": { minWidth: 0 }, "& .MuiAlert-action": { gridColumn: 2, m: 0, p: 0, justifySelf: "start" } }} action={<Button color="inherit" size="small" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("retry")}</Button>}>
<Typography variant="body2">{errorTitle}</Typography>
{errorDetail !== errorTitle ? <Typography variant="body2">{errorDetail}</Typography> : null}
</Alert> : null}
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
<PageSection surface title={t("notifications")}>
{hasLoaded && notifications.length === 0 ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
<Stack spacing={1.5}>{notifications.map(notification => <Paper key={notification.id} component="article" aria-label={notification.title} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
<Typography component="h3" variant="sectionTitle">{notification.title}</Typography>
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{notification.message}</Typography>
<Typography variant="caption" color="text.secondary">{dateLabel(notification.createdAtUtc)}</Typography>
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{!notification.readAtUtc ? <Button size="small" disabled={locked} sx={buttonSx} onClick={() => void runAction("read-" + notification.id, async () => { await api.post("/notifications/" + notification.id + "/read"); return { readId: notification.id }; })}>{t("notificationsMarkRead")}</Button> : null}
<Button size="small" color="inherit" disabled={locked} sx={buttonSx} onClick={() => void runAction("dismiss-" + notification.id, async () => { await api.delete("/notifications/" + notification.id); return { dismissId: notification.id }; })}>{t("notificationsDismiss")}</Button>
</Stack>
</Paper>
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>{t("operations")}</Typography>
{operations.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
<Stack spacing={1.5}>
{operations.map((operation) => (
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
<Chip size="small" label={statusLabel(operation.status)} />
</Box>
<Typography variant="caption" color="text.secondary">{t("operationsStarted", { date: notificationDateLabel(operation.createdAtUtc, language === "nb" ? "nb-NO" : "en") })}</Typography>
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: operation.taskType })} sx={{ mt: 1 }} /> : null}
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>{t("operationsFailed", { category: statusLabel(operation.failureCategory) })}</Alert> : null}
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{operation.canCancel ? (
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
{t("cancel")}
</Button>
) : null}
{operation.canRetry ? (
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
{t("retry")}
</Button>
) : null}
</Stack>
</Box>
))}
</Paper>)}</Stack>
</PageSection>
<PageSection surface title={t("operations")}>
{hasLoaded && operations.length === 0 ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
<Stack spacing={1.5}>{operations.map(operation => <Paper key={operation.id} component="article" aria-label={taskLabel(operation.taskType)} data-operation-id={operation.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", minWidth: 0 }}>
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere", minWidth: 0 }}>{taskLabel(operation.taskType)}</Typography>
<Chip size="small" label={Object.hasOwn(statusKeys, operation.status) ? t(statusKeys[operation.status]) : operation.status.replaceAll("_", " ")} sx={{ maxWidth: "100%", height: "auto", "& .MuiChip-label": { whiteSpace: "normal", py: 0.5, overflowWrap: "anywhere" } }} />
</Box>
<Typography variant="caption" color="text.secondary">{t("operationsCreated", { date: dateLabel(operation.createdAtUtc) })}</Typography>
{operation.progressStage ? <Typography variant="body2" sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: taskLabel(operation.taskType) })} sx={{ mt: 1 }} /> : null}
{operation.cancellationRequestedAtUtc ? <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1, overflowWrap: "anywhere" }}>{t("operationsFailed", { category: operation.failureCategory.replaceAll("_", " ") })}</Alert> : null}
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{operation.canCancel ? <Button size="small" color="error" disabled={locked} sx={buttonSx} onClick={() => void runAction("cancel-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/cancel")).data }))}>{t("cancel")}</Button> : null}
{operation.canRetry ? <Button size="small" variant="outlined" disabled={locked} sx={buttonSx} onClick={() => void runAction("retry-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/retry")).data }))}>{t("retry")}</Button> : null}
</Stack>
</Paper>
</Stack>
);
</Paper>)}</Stack>
</PageSection>
</Stack>;
}