From 10ce0f2086a3fe3cd422c76e7a21547d6f6ff794 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 29 Aug 2026 23:37:07 +0200 Subject: [PATCH] fix(i18n): unify shared view states --- docs/work-programmes/master-progress.md | 2 + .../src/components/CompaniesTable.tsx | 6 +-- .../src/components/DashboardView.tsx | 8 ++-- job-tracker-ui/src/components/JobTable.tsx | 12 +++--- job-tracker-ui/src/components/KanbanBoard.tsx | 32 +++++++------- .../src/components/RemindersView.tsx | 8 ++-- .../src/components/ViewStateNotice.tsx | 15 ++++--- job-tracker-ui/src/i18n/translations.ts | 42 +++++++++++++++++++ .../src/kanban-grouped-board.test.tsx | 11 +++++ 9 files changed, 98 insertions(+), 38 deletions(-) diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 951ca5a..5e66cef 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -36,6 +36,7 @@ Updated: 2026-08-29 - Reused the shared EN/NO language control on the unauthenticated landing page, so language can be changed before sign-in and persists through the existing locale mechanism. - 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. ### In progress @@ -95,6 +96,7 @@ Updated: 2026-08-29 - Final localization regression gate: all 62 frontend suites and 267/267 tests passed after correcting the isolated Pro-notice provider fallback; the optimized Next production build and integrated TypeScript check passed; the full .NET 9 backend suite passed 713/713. - 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. - 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/components/CompaniesTable.tsx b/job-tracker-ui/src/components/CompaniesTable.tsx index cbe3ecf..1dd9d01 100644 --- a/job-tracker-ui/src/components/CompaniesTable.tsx +++ b/job-tracker-ui/src/components/CompaniesTable.tsx @@ -53,7 +53,7 @@ export default function CompaniesTable() { }, { initialData: [], - errorMessage: t("companiesUpdateFailed"), + errorMessage: t("companiesLoadFailed"), deps: [t], }, ); @@ -120,8 +120,8 @@ export default function CompaniesTable() { diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 94bb31e..e631959 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -146,8 +146,8 @@ export default function DashboardView() { loadSummary, { initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] }, - errorMessage: "Unable to load dashboard summary data right now.", - deps: [], + errorMessage: t("dashboardSummaryUnavailableTitle"), + deps: [t], }, ); @@ -170,8 +170,8 @@ export default function DashboardView() { loadTrends, { initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null }, - errorMessage: "Unable to load dashboard trends right now.", - deps: [months], + errorMessage: t("dashboardTrendsUnavailableTitle"), + deps: [months, t], }, ); diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 75abb84..59415be 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -315,8 +315,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col }, { initialData: { items: [], total: 0, page: 1, pageSize }, - errorMessage: "Unable to load jobs right now.", - deps: [params, refreshToken, reloadToken, pageSize], + errorMessage: t("jobTableJobsLoadFailed"), + deps: [params, refreshToken, reloadToken, pageSize, t], }, ); @@ -614,15 +614,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {companiesError ? ( diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index 0dbe126..323c1f0 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -102,8 +102,8 @@ export default function KanbanBoard() { }, { initialData: [], - errorMessage: "Unable to load the board right now.", - deps: [], + errorMessage: t("kanbanLoadFailed"), + deps: [t], }, ); @@ -143,10 +143,10 @@ export default function KanbanBoard() { try { await api.patch(`/jobapplications/${movingJobId}/status`, { status }); jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j))); - setAnnouncement(`Job moved to ${statusLabel(t, status)}.`); + setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) })); } catch (error: any) { - setMoveError(getApiErrorMessage(error, "Unable to move this job right now.")); - setAnnouncement("Job move failed."); + setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed"))); + setAnnouncement(t("kanbanMoveFailedAnnouncement")); } }; @@ -155,10 +155,10 @@ export default function KanbanBoard() { try { await api.patch(`/jobapplications/${id}/status`, { status }); jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j))); - setAnnouncement(`Job moved to ${statusLabel(t, status)}.`); + setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) })); } catch (error: any) { - setMoveError(getApiErrorMessage(error, "Unable to move this job right now.")); - setAnnouncement("Job move failed."); + setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed"))); + setAnnouncement(t("kanbanMoveFailedAnnouncement")); } }; @@ -187,8 +187,8 @@ export default function KanbanBoard() { @@ -227,11 +227,11 @@ export default function KanbanBoard() { onBlur={() => { if (dragOverColumn === key) setDragOverColumn(null); }} onKeyDown={(event) => { if (event.key === "Escape" && isDragActive) { - event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled."); + event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled")); } else if ((event.key === "Enter" || event.key === " ") && isDragActive) { event.preventDefault(); if (droppable) void onDropTo(key as PipelineGroup); - else setAnnouncement(`${label} is not a valid drop target.`); + else setAnnouncement(t("kanbanInvalidDropTarget", { status: label })); } }} onDragEnter={() => { if (isDragActive) setDragOverColumn(key); }} @@ -291,19 +291,19 @@ export default function KanbanBoard() { role="button" tabIndex={0} aria-pressed={dragJobId === j.id} - aria-label={`${j.jobTitle}, ${statusLabel(t, j.status)}. ${dragJobId === j.id ? "Picked up; focus a column and press Enter to move." : "Press Space to pick up."}`} + aria-label={t(dragJobId === j.id ? "kanbanCardPickedUpLabel" : "kanbanCardPickUpLabel", { title: j.jobTitle, status: statusLabel(t, j.status) })} onKeyDown={(event) => { if (event.key === "Escape" && dragJobId === j.id) { - event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled."); + event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled")); } else if (event.key === " ") { event.preventDefault(); const pickingUp = dragJobId !== j.id; setDragJobId(pickingUp ? j.id : null); setDragOverColumn(null); - setAnnouncement(pickingUp ? `${j.jobTitle} picked up. Focus a column and press Enter to move.` : "Keyboard move cancelled."); + setAnnouncement(pickingUp ? t("kanbanPickedUpAnnouncement", { title: j.jobTitle }) : t("kanbanKeyboardMoveCancelled")); } }} - onDragStart={() => { setDragJobId(j.id); setAnnouncement(`${j.jobTitle} picked up.`); }} + onDragStart={() => { setDragJobId(j.id); setAnnouncement(t("kanbanDragStartedAnnouncement", { title: j.jobTitle })); }} onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }} sx={{ cursor: "grab", diff --git a/job-tracker-ui/src/components/RemindersView.tsx b/job-tracker-ui/src/components/RemindersView.tsx index 4c5ca19..974a8fd 100644 --- a/job-tracker-ui/src/components/RemindersView.tsx +++ b/job-tracker-ui/src/components/RemindersView.tsx @@ -80,8 +80,8 @@ export default function RemindersView() { }, { initialData: [], - errorMessage: "Unable to load reminders right now.", - deps: [], + errorMessage: t("remindersLoadFailed"), + deps: [t], }, ); @@ -113,8 +113,8 @@ export default function RemindersView() { diff --git a/job-tracker-ui/src/components/ViewStateNotice.tsx b/job-tracker-ui/src/components/ViewStateNotice.tsx index 0eb9f9f..9560f4c 100644 --- a/job-tracker-ui/src/components/ViewStateNotice.tsx +++ b/job-tracker-ui/src/components/ViewStateNotice.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material"; import type { ViewResourceError } from "../hooks/useViewResource"; +import { useI18n } from "../i18n/I18nProvider"; type Props = { loading?: boolean; @@ -14,11 +15,14 @@ type Props = { compact?: boolean; }; -export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel = "Retry", onRetry, compact = false }: Props) { +export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel, onRetry, compact = false }: Props) { + const { t } = useI18n(); + const resolvedRetryLabel = retryLabel ?? t("retry"); + if (loading) { return ( - - + + ); } @@ -26,6 +30,7 @@ export default function ViewStateNotice({ loading = false, error = null, title, if (!error) return null; const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error"; + const detail = error.message !== title && error.message !== description ? error.message : null; return ( void onRetry()}>{retryLabel} : undefined} + action={error.retryable && onRetry ? : undefined} > {title} {description ? {description} : null} - {error.message ? {error.message} : null} + {detail ? {detail} : null} ); } diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index aeffde9..96e6a57 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1339,6 +1339,8 @@ export const translations = { remindersFollowUpCleared: "Follow-up cleared.", remindersFollowUpSet: "Follow-up set.", remindersFollowUpFailed: "Failed to set follow-up.", + remindersLoadFailed: "Unable to load reminders", + remindersLoadFailedBody: "The reminders view cannot reach the API right now.", companiesEmpty: "No companies yet.", companiesName: "Name", companiesLocation: "Location", @@ -1355,6 +1357,8 @@ export const translations = { companiesNextContactField: "Next contact", companiesUpdated: "Company updated.", companiesUpdateFailed: "Failed to update company.", + companiesLoadFailed: "Unable to load companies", + companiesLoadFailedBody: "The companies list is unavailable right now. Try again when the API is reachable.", adminUsersTitle: "Users", adminUsersSubtitle: "Admin-only user management.", adminUsersCreateUser: "Create user", @@ -1396,6 +1400,17 @@ export const translations = { kanbanAppliedAgo: "Applied {days}d ago", kanbanFollowUpNow: "Follow up now", kanbanReplyDueIn: "Reply due in {days}d", + kanbanLoadFailed: "Unable to load the kanban board", + kanbanLoadFailedBody: "The board could not reach the API.", + kanbanMoveSucceeded: "Job moved to {status}.", + kanbanMoveFailed: "Unable to move this job right now.", + kanbanMoveFailedAnnouncement: "Job move failed.", + kanbanKeyboardMoveCancelled: "Keyboard move cancelled.", + kanbanInvalidDropTarget: "{status} is not a valid drop target.", + kanbanCardPickedUpLabel: "{title}, {status}. Picked up; focus a column and press Enter to move.", + kanbanCardPickUpLabel: "{title}, {status}. Press Space to pick up.", + kanbanPickedUpAnnouncement: "{title} picked up. Focus a column and press Enter to move.", + kanbanDragStartedAnnouncement: "{title} picked up.", adminSystemTitle: "System status", adminSystemLoadFailed: "Failed to load system status.", adminSystemTestEmailFailed: "Failed to send test email.", @@ -1948,6 +1963,12 @@ export const translations = { jobTableOverview: "Overview", jobTableNoSummaryYet: "No summary yet.", jobTableNoJobsFound: "No jobs found.", + jobTableJobsLoadFailed: "Unable to load jobs", + jobTableJobsLoadFailedBody: "The jobs list cannot reach the API right now.", + jobTableTrashLoadFailed: "Unable to load trash", + jobTableTrashLoadFailedBody: "The deleted-jobs view cannot reach the API right now.", + jobTableCompanyFiltersLoadFailed: "Unable to load company filters", + jobTableCompanyFiltersLoadFailedBody: "Company filter data is unavailable right now.", jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.", jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.", jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet", @@ -3617,6 +3638,8 @@ export const translations = { remindersFollowUpCleared: "Oppfølging fjernet.", remindersFollowUpSet: "Oppfølging satt.", remindersFollowUpFailed: "Kunne ikke sette oppfølging.", + remindersLoadFailed: "Kunne ikke laste påminnelser", + remindersLoadFailedBody: "Påminnelsesvisningen fikk ikke kontakt med API-et akkurat nå.", companiesEmpty: "Ingen selskaper ennå.", companiesName: "Navn", companiesLocation: "Sted", @@ -3633,6 +3656,8 @@ export const translations = { companiesNextContactField: "Neste kontakt", companiesUpdated: "Selskap oppdatert.", companiesUpdateFailed: "Kunne ikke oppdatere selskap.", + companiesLoadFailed: "Kunne ikke laste selskaper", + companiesLoadFailedBody: "Selskapslisten er utilgjengelig akkurat nå. Prøv igjen når API-et er tilgjengelig.", adminUsersTitle: "Brukere", adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.", adminUsersCreateUser: "Opprett bruker", @@ -3674,6 +3699,17 @@ export const translations = { kanbanAppliedAgo: "Søkt for {days}d siden", kanbanFollowUpNow: "Følg opp nå", kanbanReplyDueIn: "Svar forfaller om {days}d", + kanbanLoadFailed: "Kunne ikke laste kanban-tavlen", + kanbanLoadFailedBody: "Tavlen fikk ikke kontakt med API-et.", + kanbanMoveSucceeded: "Jobben ble flyttet til {status}.", + kanbanMoveFailed: "Kunne ikke flytte denne jobben akkurat nå.", + kanbanMoveFailedAnnouncement: "Flytting av jobben mislyktes.", + kanbanKeyboardMoveCancelled: "Tastaturflytting avbrutt.", + kanbanInvalidDropTarget: "{status} er ikke et gyldig mål.", + kanbanCardPickedUpLabel: "{title}, {status}. Løftet; fokuser en kolonne og trykk Enter for å flytte.", + kanbanCardPickUpLabel: "{title}, {status}. Trykk mellomrom for å løfte.", + kanbanPickedUpAnnouncement: "{title} er løftet. Fokuser en kolonne og trykk Enter for å flytte.", + kanbanDragStartedAnnouncement: "{title} er løftet.", adminSystemTitle: "Systemstatus", adminSystemLoadFailed: "Kunne ikke laste systemstatus.", adminSystemTestEmailFailed: "Kunne ikke sende test-e-post.", @@ -4226,6 +4262,12 @@ export const translations = { jobTableOverview: "Oversikt", jobTableNoSummaryYet: "Ingen oppsummering ennå.", jobTableNoJobsFound: "Ingen jobber funnet.", + jobTableJobsLoadFailed: "Kunne ikke laste jobber", + jobTableJobsLoadFailedBody: "Jobblisten fikk ikke kontakt med API-et akkurat nå.", + jobTableTrashLoadFailed: "Kunne ikke laste papirkurven", + jobTableTrashLoadFailedBody: "Visningen for slettede jobber fikk ikke kontakt med API-et akkurat nå.", + jobTableCompanyFiltersLoadFailed: "Kunne ikke laste selskapsfiltre", + jobTableCompanyFiltersLoadFailedBody: "Selskapsdata for filtrering er utilgjengelige akkurat nå.", jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.", jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.", jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket", diff --git a/job-tracker-ui/src/kanban-grouped-board.test.tsx b/job-tracker-ui/src/kanban-grouped-board.test.tsx index 7d666c7..9bf7921 100644 --- a/job-tracker-ui/src/kanban-grouped-board.test.tsx +++ b/job-tracker-ui/src/kanban-grouped-board.test.tsx @@ -46,6 +46,7 @@ function renderBoard() { beforeEach(() => { jest.clearAllMocks(); + window.localStorage.clear(); }); test('the board collapses ten stages into the three agreed groups', async () => { @@ -108,6 +109,16 @@ test('loading and retryable error states replace the board', async () => { expect(await screen.findByRole('group', { name: 'Not Applied column' })).toBeInTheDocument(); }); +test('loading and retry controls follow the selected Bokmål locale', async () => { + window.localStorage.setItem('uiLanguage', 'nb-NO'); + mockedApi.get.mockRejectedValueOnce(new Error('offline')); + + renderBoard(); + + expect(await screen.findByText('Kunne ikke laste kanban-tavlen')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Prøv igjen' })).toBeInTheDocument(); +}); + test('dropping onto a group applies that group entry stage', async () => { mockedApi.get.mockResolvedValue({ data: [job(7, 'Saved Role', 'Saved')] } as any);