From cf6509ce1359f77889fbf49fd7dbbf9d1ddd0f9c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 29 Aug 2026 23:40:29 +0200 Subject: [PATCH] fix(i18n): localize admin recovery states --- docs/work-programmes/master-progress.md | 2 + job-tracker-ui/src/admin-audit-page.test.tsx | 55 ++++++++++++++++ .../src/components/SettingsView.tsx | 8 +-- job-tracker-ui/src/i18n/translations.ts | 26 ++++++++ job-tracker-ui/src/views/AdminAuditPage.tsx | 62 +++++++++---------- .../src/views/CareerProfilePage.tsx | 10 +-- job-tracker-ui/src/views/ProfilePage.tsx | 4 +- 7 files changed, 125 insertions(+), 42 deletions(-) create mode 100644 job-tracker-ui/src/admin-audit-page.test.tsx diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 5e66cef..8863c9e 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -37,6 +37,7 @@ Updated: 2026-08-29 - Finished the remaining active CV Builder format labels: long-document page counts, US Letter naming, and localized long/short date examples now follow the selected UI language. - Localized the Admin System runtime and CV benchmark diagnostics, including probe/email failures, model/Ollama state, parser findings, benchmark summaries, fallback guidance, and locale-aware timestamps while leaving runtime/provider/file values untouched. - Consolidated active list/dashboard/Kanban/reminder loading and failure presentation through the shared resource notice. Retry/progress accessibility follows EN/NB, duplicate fallback text is suppressed, and Kanban drag/keyboard announcements are localized. +- Migrated Admin Audit to the shared retryable resource state so an unavailable API is no longer misreported as an empty log. Audit actions/dates, notification-setting feedback, profile-load fallbacks, and CV extraction run notifications now follow EN/NB while stored event/profile content remains unchanged. ### In progress @@ -97,6 +98,7 @@ Updated: 2026-08-29 - Career Profile atomicity/CV locale verification: focused backend 55/55 and frontend 10/10 passed; the targeted long Code-template Playwright/PDF flow passed 1/1 with no duplicate-key or out-of-range locale warning. Its fresh disposable database contained exactly 9 experiences, 1 education, 8 skills, 1 project, 1 certification and 2 languages, with zero duplicate experience ItemKeys. - Post-fix complete regression: backend 716/716 and frontend 62 suites with 268/268 tests passed; the optimized Next production build and integrated TypeScript check passed. - Shared view-state focused verification: 3 suites and 15/15 tests passed, including Bokmål loading/retry and Kanban interaction coverage; TypeScript passed. +- Admin Audit/settings/profile focused verification: 3 suites and 17/17 tests passed, including unavailable-versus-empty recovery and Bokmål audit actions that preserve stored event content; TypeScript passed. - Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation. - Full backend: 712/712 tests passed on .NET 9. - Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. diff --git a/job-tracker-ui/src/admin-audit-page.test.tsx b/job-tracker-ui/src/admin-audit-page.test.tsx new file mode 100644 index 0000000..dcc253d --- /dev/null +++ b/job-tracker-ui/src/admin-audit-page.test.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { ToastProvider } from "./toast"; +import AdminAuditPage from "./views/AdminAuditPage"; + +jest.mock("./api", () => ({ + api: { get: jest.fn(), post: jest.fn() }, + getApiErrorMessage: jest.fn((_error, fallback) => fallback), +})); + +const mockedApi = api as jest.Mocked; + +function renderPage() { + return render(); +} + +beforeEach(() => { + jest.clearAllMocks(); + window.localStorage.clear(); +}); + +test("distinguishes an unavailable audit log from an empty audit log and retries", async () => { + mockedApi.get.mockRejectedValueOnce(new Error("offline")); + renderPage(); + + expect(await screen.findByText("Unable to load audit events")).toBeInTheDocument(); + expect(screen.queryByText("No events.")).not.toBeInTheDocument(); + + mockedApi.get.mockResolvedValueOnce({ data: [] } as any); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("No events.")).toBeInTheDocument(); +}); + +test("localizes audit actions in Bokmål without translating stored event data", async () => { + window.localStorage.setItem("uiLanguage", "nb-NO"); + mockedApi.get.mockResolvedValueOnce({ data: [{ + id: 7, + type: "Deleted", + at: "2026-08-29T12:00:00Z", + jobApplicationId: 42, + companyName: "Example AS", + note: "User-authored note", + }] } as any); + + renderPage(); + + expect(await screen.findByRole("button", { name: "Angre" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Gjenopprett" })).toBeInTheDocument(); + expect(screen.getByText("Example AS - Jobb #42")).toBeInTheDocument(); + expect(screen.getByText("User-authored note")).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index fcf75d9..760127c 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -79,9 +79,9 @@ export default function SettingsView({ let active = true; api.get("/notification-settings") .then(({ data }) => { if (active) setNotificationPrefs(data); }) - .catch((error) => { if (active) setNotificationError(getApiErrorMessage(error, "Notification settings could not be loaded.")); }); + .catch((error) => { if (active) setNotificationError(getApiErrorMessage(error, t("settingsNotificationsLoadFailed"))); }); return () => { active = false; }; - }, []); + }, [t]); const saveNotifications = async () => { if (!notificationPrefs) return; @@ -90,9 +90,9 @@ export default function SettingsView({ try { const { data } = await api.put("/notification-settings", notificationPrefs); setNotificationPrefs(data); - toast("Notification settings saved.", "success"); + toast(t("settingsNotificationsSaved"), "success"); } catch (error) { - setNotificationError(getApiErrorMessage(error, "Notification settings could not be saved.")); + setNotificationError(getApiErrorMessage(error, t("settingsNotificationsSaveFailed"))); } finally { setSavingNotifications(false); } }; diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 96e6a57..1bcec60 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -991,6 +991,9 @@ export const translations = { settingsNotificationsFollowUpReminders: "Email reminders for follow-ups", settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs", settingsNotificationsInAppReminders: "Highlight reminders in the app", + settingsNotificationsLoadFailed: "Notification settings could not be loaded.", + settingsNotificationsSaved: "Notification settings saved.", + settingsNotificationsSaveFailed: "Notification settings could not be saved.", settingsCheckSystemStatus: "Check system status", profileTitle: "Profile", profileHeadlinePlaceholder: "Add a short headline to personalize your account view.", @@ -1057,6 +1060,8 @@ export const translations = { profileCvRetryQueued: "CV processing queued again.", profileCvRetryFailed: "Could not retry CV processing.", profileCvRetryProcessing: "Retry processing", + profileCvRunFailed: "CV {trigger} failed.", + profileCvRunReady: "CV {trigger} is ready to review.", profileCvDiffSummary: "{added} additions | {updated} updates", profileCvNeedAttention: "{count} need attention", profileCvNoChangesFound: "No profile changes found", @@ -1715,6 +1720,14 @@ export const translations = { attachmentsDownloadFailed: "Download failed.", attachmentsPreviewFailed: "Preview failed.", adminAuditRestored: "Restored.", + adminAuditRestore: "Restore", + adminAuditRestoreFailed: "Restore failed.", + adminAuditUndo: "Undo", + adminAuditUndone: "Undone.", + adminAuditUndoFailed: "Undo failed.", + adminAuditLoadFailed: "Unable to load audit events", + adminAuditLoadFailedBody: "The audit log cannot reach the API right now.", + adminAuditJobFallback: "Job #{id}", adminAuditTitle: "Audit log", adminAuditSubtitle: "Admin-only.", adminAuditAt: "At", @@ -3290,6 +3303,9 @@ export const translations = { settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger", settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber", settingsNotificationsInAppReminders: "Fremhev påminnelser i appen", + settingsNotificationsLoadFailed: "Varslingsinnstillingene kunne ikke lastes.", + settingsNotificationsSaved: "Varslingsinnstillingene er lagret.", + settingsNotificationsSaveFailed: "Varslingsinnstillingene kunne ikke lagres.", settingsCheckSystemStatus: "Sjekk systemstatus", profileTitle: "Profil", profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.", @@ -3356,6 +3372,8 @@ export const translations = { profileCvRetryQueued: "CV-behandlingen er satt i kø på nytt.", profileCvRetryFailed: "Kunne ikke prøve CV-behandlingen på nytt.", profileCvRetryProcessing: "Prøv behandlingen på nytt", + profileCvRunFailed: "CV {trigger} mislyktes.", + profileCvRunReady: "CV {trigger} er klar til gjennomgang.", profileCvDiffSummary: "{added} tillegg | {updated} oppdateringer", profileCvNeedAttention: "{count} trenger gjennomgang", profileCvNoChangesFound: "Ingen profilendringer funnet", @@ -4014,6 +4032,14 @@ export const translations = { attachmentsDownloadFailed: "Nedlasting mislyktes.", attachmentsPreviewFailed: "Forhåndsvisning mislyktes.", adminAuditRestored: "Gjenopprettet.", + adminAuditRestore: "Gjenopprett", + adminAuditRestoreFailed: "Gjenoppretting mislyktes.", + adminAuditUndo: "Angre", + adminAuditUndone: "Angret.", + adminAuditUndoFailed: "Kunne ikke angre.", + adminAuditLoadFailed: "Kunne ikke laste revisjonshendelser", + adminAuditLoadFailedBody: "Revisjonsloggen fikk ikke kontakt med API-et akkurat nå.", + adminAuditJobFallback: "Jobb #{id}", adminAuditTitle: "Revisjonslogg", adminAuditSubtitle: "Kun for admin.", adminAuditAt: "Tidspunkt", diff --git a/job-tracker-ui/src/views/AdminAuditPage.tsx b/job-tracker-ui/src/views/AdminAuditPage.tsx index 2962b58..37af0d3 100644 --- a/job-tracker-ui/src/views/AdminAuditPage.tsx +++ b/job-tracker-ui/src/views/AdminAuditPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useState } from "react"; import { Box, @@ -17,6 +17,8 @@ import { import { api } from "../api"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +import { useViewResource } from "../hooks/useViewResource"; +import ViewStateNotice from "../components/ViewStateNotice"; type AuditItem = { id: number; @@ -39,36 +41,26 @@ function canUndo(type: string) { export default function AdminAuditPage() { const { toast } = useToast(); - const { t } = useI18n(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(false); + const { language, t } = useI18n(); const [busyId, setBusyId] = useState(null); - const load = useCallback(async () => { - setLoading(true); - try { + const auditResource = useViewResource( + async () => { const r = await api.get("/admin/audit?take=200"); - setItems(r.data ?? []); - } catch { - setItems([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); + return r.data ?? []; + }, + { initialData: [], errorMessage: t("adminAuditLoadFailed"), deps: [t] }, + ); const undo = async (e: AuditItem) => { if (!canUndo(e.type)) return; setBusyId(e.id); try { const res = await api.post<{ ok: boolean; message: string }>(`/admin/audit/${e.id}/undo`, {}); - toast(res.data?.message || "Undone.", "success"); - await load(); + toast(res.data?.message || t("adminAuditUndone"), "success"); + await auditResource.reload(); } catch (err: any) { - const msg = err?.response?.data?.message || err?.response?.data || err?.message || "Undo failed."; + const msg = err?.response?.data?.message || err?.response?.data || err?.message || t("adminAuditUndoFailed"); toast(String(msg), "error"); } finally { setBusyId(null); @@ -80,16 +72,16 @@ export default function AdminAuditPage() { try { await api.post(`/jobapplications/${e.jobApplicationId}/restore`, {}); toast(t("adminAuditRestored"), "success"); - await load(); + await auditResource.reload(); } catch (err: any) { - const msg = err?.response?.data || err?.message || "Restore failed."; + const msg = err?.response?.data || err?.message || t("adminAuditRestoreFailed"); toast(String(msg), "error"); } finally { setBusyId(null); } }; - const rows = useMemo(() => items, [items]); + const rows = auditResource.data; return ( @@ -100,7 +92,15 @@ export default function AdminAuditPage() { {t("adminAuditSubtitle")} - + + + {!auditResource.error ? @@ -113,7 +113,7 @@ export default function AdminAuditPage() { - {loading ? ( + {auditResource.loading ? ( {t("loading")} @@ -127,7 +127,7 @@ export default function AdminAuditPage() { ) : ( rows.map((e) => { - const jobLabel = e.jobTitle || `Job #${e.jobApplicationId}`; + const jobLabel = e.jobTitle || t("adminAuditJobFallback", { id: e.jobApplicationId }); const ownerLabel = e.ownerEmail || e.ownerUserName || e.ownerUserId || "-"; const details = e.oldValue || e.newValue ? `${e.oldValue ?? ""} -> ${e.newValue ?? ""}` : ""; const disabled = busyId === e.id; @@ -135,7 +135,7 @@ export default function AdminAuditPage() { return ( - {e.at ? new Date(e.at).toLocaleString() : ""} + {e.at ? new Date(e.at).toLocaleString(language === "nb" ? "nb-NO" : "en-GB") : ""} @@ -161,12 +161,12 @@ export default function AdminAuditPage() { {canUndo(e.type) ? ( ) : null} {showRestore ? ( ) : null} @@ -177,7 +177,7 @@ export default function AdminAuditPage() { )}
-
+
: null}
); } diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 1c9a377..6824dc5 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -250,11 +250,11 @@ export default function CareerProfilePage() { setLoadError(null); } catch (error: any) { setMe(null); - setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now.")); + setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed"))); } finally { setLoading(false); } - }, []); + }, [t]); useEffect(() => { void loadProfile(); @@ -292,14 +292,14 @@ export default function CareerProfilePage() { const status = cvRunStatus(run); const prior = previous[run.id]; if (activeRunLabels.has(prior) && status === "pending_review") { - toast(`CV ${run.trigger} is ready to review.`, "info"); + toast(t("profileCvRunReady", { trigger: run.trigger }), "info"); } if (activeRunLabels.has(prior) && status === "failed") { - toast(run.errorMessage || `CV ${run.trigger} failed.`, "error"); + toast(run.errorMessage || t("profileCvRunFailed", { trigger: run.trigger }), "error"); } previous[run.id] = status; } - }, [extractionRuns, toast]); + }, [extractionRuns, t, toast]); // Field-review lookup passed to the extracted sections. They stay decoupled from the full profile // shape; the parent still owns structuredCv and the metadata source. diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index aa010b4..d1efa9d 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -108,11 +108,11 @@ export default function ProfilePage() { setLoadError(null); } catch (error: any) { setMe(null); - setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now.")); + setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed"))); } finally { setLoading(false); } - }, []); + }, [t]); useEffect(() => { void loadProfile();