diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx
index 263fc8a..b797fb9 100644
--- a/job-tracker-ui/src/App.tsx
+++ b/job-tracker-ui/src/App.tsx
@@ -86,21 +86,21 @@ type MeResponse = {
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")];
- if (path.startsWith("/discover")) return [t("home"), "Discover jobs"];
- if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), "Job details"];
+ if (path.startsWith("/discover")) return [t("home"), t("discoverJobs")];
+ if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), t("jobDetails")];
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
- if (path.startsWith("/operations")) return [t("home"), "Operations"];
+ if (path.startsWith("/operations")) return [t("home"), t("operations")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")];
- if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
- if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"];
+ if (path.startsWith("/correspondence/review")) return [t("home"), t("gmailReviewQueue")];
+ if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")];
if (path.startsWith("/trash")) return [t("home"), t("trash")];
- if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"];
+ if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), t("connectedAccounts")];
if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
- if (path.startsWith("/career/builder")) return [t("home"), "Career Workspace", "CV Builder"];
- if (path.startsWith("/career")) return [t("home"), "Career Workspace"];
+ if (path.startsWith("/career/builder")) return [t("home"), t("careerWorkspace"), t("cvBuilder")];
+ if (path.startsWith("/career")) return [t("home"), t("careerWorkspace")];
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
@@ -110,20 +110,20 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
function titleFor(path: string, t: (k: any) => string): string {
if (path === "/dashboard") return t("dashboard");
if (path.startsWith("/reminders")) return t("reminders");
- if (path.startsWith("/operations")) return "Operations";
- if (path.startsWith("/discover")) return "Discover jobs";
- if (/^\/jobs\/\d+/.test(path)) return "Job details";
+ if (path.startsWith("/operations")) return t("operations");
+ if (path.startsWith("/discover")) return t("discoverJobs");
+ if (/^\/jobs\/\d+/.test(path)) return t("jobDetails");
if (path.startsWith("/jobs")) return t("jobApplications");
if (path.startsWith("/kanban")) return t("kanbanBoard");
if (path.startsWith("/companies")) return t("companies");
- if (path.startsWith("/correspondence/review")) return "Gmail review queue";
- if (path.startsWith("/correspondence")) return "Correspondence inbox";
+ if (path.startsWith("/correspondence/review")) return t("gmailReviewQueue");
+ if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle");
if (path.startsWith("/trash")) return t("trash");
- if (path.startsWith("/settings/connected-accounts")) return "Connected accounts";
+ if (path.startsWith("/settings/connected-accounts")) return t("connectedAccounts");
if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return t("profile");
- if (path.startsWith("/career/builder")) return "CV Builder";
- if (path.startsWith("/career")) return "Career Workspace";
+ if (path.startsWith("/career/builder")) return t("cvBuilder");
+ if (path.startsWith("/career")) return t("careerWorkspace");
if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus");
@@ -132,8 +132,8 @@ function titleFor(path: string, t: (k: any) => string): string {
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (path === "/dashboard") return t("dashboardPageSubtitle");
- if (path.startsWith("/discover")) return "Search official job-board feeds and save opportunities to your tracker.";
- if (/^\/jobs\/\d+/.test(path)) return "Manage this application, its documents, timeline, and correspondence.";
+ if (path.startsWith("/discover")) return t("discoverJobsSubtitle");
+ if (/^\/jobs\/\d+/.test(path)) return t("jobDetailsWorkspaceSubtitle");
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
@@ -143,7 +143,8 @@ function subtitleFor(path: string, t: (k: any) => string): string | undefined {
}
function PageLoader() {
- return Loading...;
+ const { t } = useI18n();
+ return {t("loading")};
}
function LegacyApplicationRedirect() {
@@ -269,7 +270,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
- if (requireAuth === null || !authResolved) return Loading...;
+ if (requireAuth === null || !authResolved) return {t("loading")};
if (requireAuth && !me) return ;
const pageTitle = titleFor(path, t);
@@ -282,13 +283,13 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: , section: t("manage") },
{ to: "/jobs", label: t("jobApplications"), icon: , section: t("manage") },
- { to: "/discover", label: "Discover jobs", icon: , section: t("manage") },
+ { to: "/discover", label: t("discoverJobs"), icon: , section: t("manage") },
{ to: "/reminders", label: t("reminders"), icon: , badgeCount: reminderCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") },
{ to: "/companies", label: t("companies"), icon: , section: t("manage") },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: , section: t("manage") },
- { to: "/career", label: "Career Workspace", icon: , section: t("manage") },
- { to: "/career/builder", label: "CV Builder", icon: , section: t("manage") },
+ { to: "/career", label: t("careerWorkspace"), icon: , section: t("manage") },
+ { to: "/career/builder", label: t("cvBuilder"), icon: , section: t("manage") },
{ to: "/trash", label: t("trash"), icon: , section: t("manage") },
];
@@ -457,17 +458,17 @@ export default function App() {
return (
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
);
}
diff --git a/job-tracker-ui/src/ai-workspace-panel.test.tsx b/job-tracker-ui/src/ai-workspace-panel.test.tsx
deleted file mode 100644
index 063dd1a..0000000
--- a/job-tracker-ui/src/ai-workspace-panel.test.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from "react";
-import "@testing-library/jest-dom";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-
-import AiWorkspacePanel from "./components/AiWorkspacePanel";
-import Markdown from "./components/Markdown";
-import { ToastProvider } from "./toast";
-import { api } from "./api";
-import { AccountPlanProvider } from "./accountPlan";
-
-jest.mock("./api", () => ({
- api: {
- get: jest.fn(),
- post: jest.fn(),
- delete: jest.fn(),
- interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
- },
- getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
-}));
-
-const mockedApi = api as jest.Mocked;
-
-function renderPanel() {
- return render(
-
-
- ,
- );
-}
-
-beforeEach(() => {
- jest.clearAllMocks();
- mockedApi.get.mockImplementation((url: string) => {
- if (url.includes("/modules")) return Promise.resolve({ data: { modules: [], provider: "gemini" } } as any);
- if (url === "/ai/usage") return Promise.resolve({ data: { currentMonth: { calls: 3, inputCharacters: 100, outputCharacters: 50, estimatedTokens: 38 }, allTime: { calls: 8, inputCharacters: 300, outputCharacters: 120, estimatedTokens: 105 } } } as any);
- return Promise.resolve({ data: [] } as any); // history
- });
-});
-
-test("shows transparent monthly usage", async () => {
- renderPanel();
- expect(await screen.findByText(/approximately/)).toHaveTextContent("3 runs");
- expect(screen.getByText(/approximately/)).toHaveTextContent("38 tokens");
-});
-
-test("renders modules and generates a suggestion into history", async () => {
- mockedApi.post.mockResolvedValueOnce({
- data: { id: 1, module: "job-analysis", mode: null, title: "Job analysis", provider: "gemini", result: { text: "**Company**\nAcme" }, createdAtUtc: new Date().toISOString() },
- } as any);
-
- renderPanel();
-
- fireEvent.click(await screen.findByRole("button", { name: /Generate/i }));
-
- await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "job-analysis" })));
- expect(await screen.findByText("Acme")).toBeInTheDocument();
-});
-
-test("cover letter sends the selected mode", async () => {
- mockedApi.post.mockResolvedValueOnce({
- data: { id: 2, module: "cover-letter", mode: "professional", title: "Cover letter · Professional", provider: "p", result: { text: "Dear team" }, createdAtUtc: new Date().toISOString() },
- } as any);
-
- renderPanel();
- fireEvent.click(await screen.findByText("Cover Letter"));
- fireEvent.click(screen.getByRole("button", { name: /Generate/i }));
-
- await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", mode: "professional" })));
-});
-
-test("free users see a locked state and cannot start generation", async () => {
- render(
-
-
-
-
- ,
- );
-
- expect(await screen.findByText(/Build application drafts with Pro/)).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "View Pro" })).toHaveAttribute("href", "/settings");
- expect(screen.getByRole("button", { name: "Pro required" })).toBeDisabled();
- expect(mockedApi.post).not.toHaveBeenCalled();
-});
-
-test("Markdown renders headings, bold, and bullet lists", () => {
- render();
- expect(screen.getByText("Title")).toBeInTheDocument();
- expect(screen.getByText("Strengths")).toBeInTheDocument();
- expect(screen.getByText("one")).toBeInTheDocument();
- expect(screen.getByText("two")).toBeInTheDocument();
-});
diff --git a/job-tracker-ui/src/application-workspace-overlay.test.tsx b/job-tracker-ui/src/application-workspace-overlay.test.tsx
index e074e6e..08bf2b6 100644
--- a/job-tracker-ui/src/application-workspace-overlay.test.tsx
+++ b/job-tracker-ui/src/application-workspace-overlay.test.tsx
@@ -13,7 +13,6 @@ import ApplicationWorkspacePage from "./views/ApplicationWorkspacePage";
jest.mock("./components/Attachments", () => () => Documents section
);
jest.mock("./components/Correspondence", () => () => Communication section
);
-jest.mock("./components/AiWorkspacePanel", () => () => AI panel
);
jest.mock("./components/ApplicationChecklist", () => () => Checklist section
);
jest.mock("./components/ApplicationIntelligence", () => ({
ApplicationAnalysis: () => Analysis section
,
diff --git a/job-tracker-ui/src/components/AiWorkspacePanel.tsx b/job-tracker-ui/src/components/AiWorkspacePanel.tsx
deleted file mode 100644
index 906de7a..0000000
--- a/job-tracker-ui/src/components/AiWorkspacePanel.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-
-import {
- Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
- Skeleton, Stack, TextField, Tooltip, Typography,
-} from "@mui/material";
-import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
-import ContentCopyIcon from "@mui/icons-material/ContentCopy";
-import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
-import HistoryIcon from "@mui/icons-material/History";
-import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
-import ReplayIcon from "@mui/icons-material/Replay";
-
-import { getApiErrorMessage } from "../api";
-import { useToast } from "../toast";
-import Markdown from "./Markdown";
-import { AI_MODULES, AiInteraction, AiUsage, COVER_LETTER_MODES, aiWorkspaceApi } from "../aiWorkspace";
-import { useAccountPlan } from "../accountPlan";
-import ProFeatureNotice from "./ProFeatureNotice";
-
-// Phase 5 — the central AI Workspace for one job application. Every result is a suggestion the user
-// reviews and copies; nothing is applied automatically.
-export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
- const { toast } = useToast();
- const { canUseAi } = useAccountPlan();
- const [module, setModule] = useState("job-analysis");
- const [mode, setMode] = useState("professional");
- const [extra, setExtra] = useState("");
- const [provider, setProvider] = useState("");
- const [usage, setUsage] = useState(null);
- const [busy, setBusy] = useState(false);
- const [current, setCurrent] = useState(null);
- const [history, setHistory] = useState([]);
- const [loadingHistory, setLoadingHistory] = useState(true);
- const [compareWith, setCompareWith] = useState(null);
-
- const activeModule = AI_MODULES.find((m) => m.key === module)!;
-
- const loadHistory = useCallback(async () => {
- try {
- setHistory(await aiWorkspaceApi.history(jobId));
- } catch {
- /* non-fatal */
- } finally {
- setLoadingHistory(false);
- }
- }, [jobId]);
-
- useEffect(() => {
- aiWorkspaceApi.modules(jobId).then((r) => setProvider(r.provider)).catch(() => undefined);
- aiWorkspaceApi.usage().then(setUsage).catch(() => undefined);
- loadHistory();
- }, [jobId, loadHistory]);
-
- const generate = async () => {
- if (!canUseAi) return;
- setBusy(true);
- setCompareWith(null);
- try {
- const res = await aiWorkspaceApi.generate(jobId, { module, mode: module === "cover-letter" ? mode : undefined, extraContext: extra || undefined });
- setCurrent(res);
- setHistory((h) => [res, ...h]);
- aiWorkspaceApi.usage().then(setUsage).catch(() => undefined);
- } catch (err) {
- toast(getApiErrorMessage(err, "AI generation failed."), "error");
- } finally {
- setBusy(false);
- }
- };
-
- const remove = async (id: number) => {
- try {
- await aiWorkspaceApi.remove(jobId, id);
- setHistory((h) => h.filter((x) => x.id !== id));
- if (current?.id === id) setCurrent(null);
- if (compareWith?.id === id) setCompareWith(null);
- } catch (err) {
- toast(getApiErrorMessage(err, "Delete failed."), "error");
- }
- };
-
- const copy = (text: string) => {
- navigator.clipboard?.writeText(text);
- toast("Copied to clipboard.", "success");
- };
-
- const moduleHistory = useMemo(() => history, [history]);
-
- return (
-
-
- {!canUseAi && (
-
- Generate job analysis, cover letters and strategy suggestions while keeping your existing AI history available.
-
- )}
-
- AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep.
- {provider && <> Provider: {provider}.>}
- {usage && <> This month: {usage.currentMonth.calls} runs · approximately {usage.currentMonth.estimatedTokens.toLocaleString()} tokens.>}
-
-
-
- {AI_MODULES.map((m) => (
- setModule(m.key)} />
- ))}
-
-
-
- {activeModule.label}
- {activeModule.blurb}
-
- {module === "cover-letter" && (
-
- Tone
-
-
- )}
-
- setExtra(e.target.value)} />
-
- } disabled={busy || !canUseAi} onClick={generate}>
- {busy ? "Generating…" : canUseAi ? "Generate" : "Pro required"}
-
-
-
- {busy && }
-
- {current && !busy && (
-
-
- {current.title}
- } onClick={() => copy(current.result.text ?? "")}>Copy
-
-
-
- )}
-
- {compareWith && (
-
-
- Comparing: {compareWith.title} · {relTime(compareWith.createdAtUtc)}
-
-
-
-
- )}
-
-
-
-
-
- History
-
- {loadingHistory && {[0, 1, 2].map((i) => )}}
- {!loadingHistory && moduleHistory.length === 0 && (
- No AI runs yet. Generate one to build history.
- )}
-
- {moduleHistory.map((h) => (
-
- {h.title}
- {relTime(h.createdAtUtc)} · {h.provider}
-
- setCurrent(h)}>
- setCompareWith(h)}>
- copy(h.result.text ?? "")}>
-
- remove(h.id)}>
-
-
- ))}
-
-
-
- );
-}
-
-function relTime(iso: string): string {
- const mins = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
- if (mins < 1) return "just now";
- if (mins < 60) return `${mins}m ago`;
- const hrs = Math.round(mins / 60);
- if (hrs < 24) return `${hrs}h ago`;
- return new Date(iso).toLocaleDateString();
-}
diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx
index 5dba521..d5e75e5 100644
--- a/job-tracker-ui/src/components/ApplicationIntelligence.tsx
+++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx
@@ -14,7 +14,7 @@ import { useI18n } from "../i18n/I18nProvider";
// Phase 5.3 — Application Intelligence sections for the workspace.
//
// Everything here renders a deterministic, read-only backend answer. No component triggers an AI
-// generation: that stays an explicit user action in AiWorkspacePanel, so nothing on this page can
+// generation stays in the dedicated document/preparation tools, so nothing on this page can
// silently spend a token or change the user's data.
// docs/architecture/application-workspace.md.
diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx
index 3af1c4e..244b940 100644
--- a/job-tracker-ui/src/components/JobDetailsDialog.tsx
+++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx
@@ -29,7 +29,6 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
import Correspondence from "./Correspondence";
import Attachments from "./Attachments";
-import AiWorkspacePanel from "./AiWorkspacePanel";
import JobInsightTabs from "./JobInsightTabs";
import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels";
import JobFlowBar from "./JobFlowBar";
@@ -777,7 +776,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{isAdmin ? : null}
-
{attachmentPicker}
@@ -1283,8 +1281,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{history.length === 0 ? {t("jobDetailsNoHistory")} : history.map((entry) => )}
)}
- {/* AI Workspace is the last tab: index depends on whether the admin History tab is present. */}
- {tab === (isAdmin ? 10 : 9) && jobId && }
);
diff --git a/job-tracker-ui/src/confirm.test.tsx b/job-tracker-ui/src/confirm.test.tsx
index d40aaca..9bf8681 100644
--- a/job-tracker-ui/src/confirm.test.tsx
+++ b/job-tracker-ui/src/confirm.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmProvider, useConfirm } from './confirm';
+import { I18nProvider } from './i18n/I18nProvider';
function Demo() {
const { confirm } = useConfirm();
@@ -12,6 +13,11 @@ function Demo() {
);
}
+function DefaultDemo() {
+ const { confirm } = useConfirm();
+ return ;
+}
+
test('renders app-owned confirmation dialog', async () => {
render(
@@ -24,3 +30,19 @@ test('renders app-owned confirmation dialog', async () => {
expect(await screen.findByText(/are you sure/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
+
+test('uses the selected language for shared dialog defaults', async () => {
+ window.localStorage.setItem('uiLanguage', 'nb');
+ render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /open localized confirm/i }));
+ expect(await screen.findByRole('heading', { name: 'Bekreft handling' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Bekreft' })).toBeInTheDocument();
+ window.localStorage.removeItem('uiLanguage');
+});
diff --git a/job-tracker-ui/src/confirm.tsx b/job-tracker-ui/src/confirm.tsx
index afceb8d..2836f01 100644
--- a/job-tracker-ui/src/confirm.tsx
+++ b/job-tracker-ui/src/confirm.tsx
@@ -1,5 +1,6 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
+import { useOptionalI18n } from "./i18n/I18nProvider";
type ConfirmOptions = {
title?: string;
@@ -20,6 +21,7 @@ type ConfirmState = ConfirmOptions & {
const ConfirmContext = createContext(null);
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
+ const i18n = useOptionalI18n();
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const [state, setState] = useState({
open: false,
@@ -42,29 +44,32 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
resolverRef.current = resolve;
setState({
open: true,
- title: options.title ?? "Confirm action",
+ title: options.title ?? i18n?.t("confirmAction") ?? "Confirm action",
message: options.message,
- confirmLabel: options.confirmLabel ?? "Confirm",
- cancelLabel: options.cancelLabel ?? "Cancel",
+ confirmLabel: options.confirmLabel ?? i18n?.t("confirm") ?? "Confirm",
+ cancelLabel: options.cancelLabel ?? i18n?.t("cancel") ?? "Cancel",
destructive: options.destructive ?? false,
});
});
- }, []);
+ }, [i18n]);
const value = useMemo(() => ({ confirm }), [confirm]);
return (
{children}
-