From 9edcbfc5de684aea3c1996919e3a9b7bb2372908 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 29 Aug 2026 16:44:44 +0200 Subject: [PATCH] feat(workspace): refine career workflows Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests. --- .../ApplicationIntelligenceTests.cs | 25 +++ JobTrackerApi.Tests/JobCvMatchServiceTests.cs | 10 +- .../ApplicationIntelligenceService.cs | 43 +++- JobTrackerApi/Services/JobCvMatchService.cs | 4 +- docs/work-programmes/master-progress.md | 66 +++++- job-tracker-ui/e2e/smoke.spec.ts | 23 +- job-tracker-ui/package.json | 6 +- job-tracker-ui/playwright.config.ts | 11 +- job-tracker-ui/scripts/run-e2e.mjs | 33 +++ .../src/application-checklist.test.tsx | 25 ++- .../src/application-intelligence.test.tsx | 20 ++ .../src/application-workflow-assist.test.tsx | 2 +- job-tracker-ui/src/applicationWorkspace.ts | 4 + .../src/career-workspace-page.test.tsx | 10 +- .../src/components/ApplicationChecklist.tsx | 73 ++++--- .../components/ApplicationIntelligence.tsx | 8 + .../components/ApplicationWorkflowAssist.tsx | 49 +++-- .../src/components/DashboardView.tsx | 198 +++++++++--------- .../src/components/OnboardingChecklist.tsx | 22 +- .../src/components/SettingsView.tsx | 34 +-- .../src/daily-control-loop.test.tsx | 28 ++- job-tracker-ui/src/i18n/translations.ts | 184 +++++++++++++++- job-tracker-ui/src/layout/AppShell.tsx | 10 +- job-tracker-ui/src/profile-page.test.tsx | 46 ++-- job-tracker-ui/src/settings-view.test.tsx | 1 + job-tracker-ui/src/theme.ts | 15 ++ .../src/views/ApplicationWorkspacePage.tsx | 2 +- .../src/views/CareerProfilePage.tsx | 84 ++++++-- .../views/career/CareerWorkspaceOverview.tsx | 27 ++- 29 files changed, 827 insertions(+), 236 deletions(-) create mode 100644 job-tracker-ui/scripts/run-e2e.mjs diff --git a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs index f08c208..6b2eb83 100644 --- a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs +++ b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs @@ -395,4 +395,29 @@ public sealed class ApplicationIntelligenceTests // Reading the match must not itself call the AI or append history. Assert.Equal(interactionsBefore, await db.AiInteractions.CountAsync()); } + + [Fact] + public async Task Match_uses_saved_English_translation_when_linked_cv_is_English() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1", j => + { + j.Description = "Vi ser etter en utvikler med erfaring fra C# og skalerbare systemer."; + j.DescriptionLanguage = "nb-NO"; + j.TranslatedDescription = "We need a developer with C# and scalable systems experience."; + }); + await SeedProfileAsync(db, "user-1"); + await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings { Language = "en" }); + + var match = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.NotNull(match); + Assert.Equal("nb", match!.JobLanguage); + Assert.Equal("en", match.CvLanguage); + Assert.True(match.LanguageMismatch); + Assert.True(match.UsedTranslatedJobDescription); + Assert.Contains("C#", match.MatchedSkills); + Assert.DoesNotContain(match.MissingSkills, value => value.Equals("utvikler", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/JobTrackerApi.Tests/JobCvMatchServiceTests.cs b/JobTrackerApi.Tests/JobCvMatchServiceTests.cs index 85666fa..92c01c7 100644 --- a/JobTrackerApi.Tests/JobCvMatchServiceTests.cs +++ b/JobTrackerApi.Tests/JobCvMatchServiceTests.cs @@ -18,7 +18,15 @@ public sealed class JobCvMatchServiceTests "Senior systemutvikler", "Vi ser etter deg som har erfaring med C# og ASP.NET Core. Du vil designe skalerbare distribuerte systemer. Gode samarbeidsevner er en fordel.", new[] { "C#", "ASP.NET Core", "designe skalerbare distribuerte systemer" }, - new[] { "med", "til", "for", "som", "erfaring", "ser" }, + new[] { "med", "til", "for", "som", "erfaring", "ser", "både", "del", "dnbs", "faglig", "får", "god", "hos", "utvikling" }, + }; + yield return new object[] + { + "Norwegian recruitment filler", + "Systemutvikler", + "Hos DNBs faglige miljø får du både være del av god utvikling og bidra til moderne løsninger med C# og Azure.", + new[] { "C#", "Azure" }, + new[] { "både", "del", "dnbs", "faglig", "får", "god", "hos", "utvikling", "bidra", "løsninger" }, }; yield return new object[] { diff --git a/JobTrackerApi/Services/ApplicationIntelligenceService.cs b/JobTrackerApi/Services/ApplicationIntelligenceService.cs index deb711c..35e8003 100644 --- a/JobTrackerApi/Services/ApplicationIntelligenceService.cs +++ b/JobTrackerApi/Services/ApplicationIntelligenceService.cs @@ -51,7 +51,11 @@ public sealed record CareerMatchDto( IReadOnlyList Suggestions, int AiSuggestionCount, bool HasSelectedCv = false, - string? SelectedCvName = null); + string? SelectedCvName = null, + string JobLanguage = "en", + string? CvLanguage = null, + bool LanguageMismatch = false, + bool UsedTranslatedJobDescription = false); public interface IApplicationIntelligenceService { @@ -259,7 +263,24 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer ["Skills"] = skillsText, }; - var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections); + var cvCorpus = string.Join("\n", sections.Values); + var jobLanguage = NormalizeMatchLanguage(job.DescriptionLanguage, job.Description); + var cvLanguage = NormalizeMatchLanguage(settings.Language, cvCorpus); + var languageMismatch = !string.Equals(jobLanguage, cvLanguage, StringComparison.OrdinalIgnoreCase); + + // Imported Norwegian adverts may already carry a trusted English translation. When the + // explicitly linked CV is English, compare against that saved translation instead of + // penalising equivalent wording in different languages. This endpoint remains read-only: + // it never starts a translation request or mutates either source document. + var useTranslatedJobDescription = languageMismatch + && jobLanguage == "nb" + && cvLanguage == "en" + && !string.IsNullOrWhiteSpace(job.TranslatedDescription); + var matchDescription = useTranslatedJobDescription + ? job.TranslatedDescription! + : job.Description ?? string.Empty; + + var result = _match.Evaluate(job.JobTitle, matchDescription, sections); var relevantExperience = experiences .Select(e => new { Entry = e, Override = Override(settings, e.ItemKey) }) @@ -297,7 +318,11 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer Suggestions: Suggestions(result, relevantExperience.Count), AiSuggestionCount: aiCount, HasSelectedCv: true, - SelectedCvName: attached.Name); + SelectedCvName: attached.Name, + JobLanguage: jobLanguage, + CvLanguage: cvLanguage, + LanguageMismatch: languageMismatch, + UsedTranslatedJobDescription: useTranslatedJobDescription); } // Suggestions describe what the USER could change. They never edit anything themselves. @@ -411,4 +436,16 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string NormalizeMatchLanguage(string? declaredLanguage, string? text) + { + var language = string.IsNullOrWhiteSpace(declaredLanguage) + ? LanguageDetector.Detect(text) + : declaredLanguage.Trim().ToLowerInvariant(); + return language.StartsWith("nb", StringComparison.Ordinal) + || language.StartsWith("no", StringComparison.Ordinal) + || language.StartsWith("nn", StringComparison.Ordinal) + ? "nb" + : "en"; + } } diff --git a/JobTrackerApi/Services/JobCvMatchService.cs b/JobTrackerApi/Services/JobCvMatchService.cs index 8025fd6..dd4a9d4 100644 --- a/JobTrackerApi/Services/JobCvMatchService.cs +++ b/JobTrackerApi/Services/JobCvMatchService.cs @@ -80,7 +80,9 @@ namespace JobTrackerApi.Services "mot", "uten", "hvordan", "ingen", "din", "ditt", "blir", "samme", "hvilken", "hvilke", "erfaring", "erfaringer", "kvalifikasjoner", "arbeidsoppgaver", "stilling", "stillingen", "søker", "ser", "ønsker", "mulighet", "spennende", "arbeidsmiljø", "selskap", "bedrift", "kandidat", - "relevant", "fordel", "gode", "dyktig", "sammen", + "relevant", "fordel", "gode", "god", "godt", "dyktig", "sammen", "både", "del", "dnbs", + "faglig", "faglige", "får", "få", "hos", "utvikling", "ansvar", "ansvarlig", "bidra", + "tilbyr", "kollega", "kollegaer", "løsning", "løsninger", // Common source-page chrome and consent text must never become tailoring advice. "cookie", "cookies", "privacy", "terms", "conditions", "menu", "home", "login", "contact", "website", "settings", "navigation", "jobs", "apply", "application", "share", "save", "accept", diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index b961c7d..cd5772a 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -1,6 +1,70 @@ # JobTracker master programme progress -Updated: 2026-08-15 +Updated: 2026-08-29 + +## 2026-08-29 audit implementation programme + +### Completed + +- Introduced shared application spacing tokens for 16px mobile gutters, 24px tablet/desktop gutters, 32px wide-screen gutters, 24px section rhythm, and 16–20px card padding. `AppShell` and the first redesigned surfaces consume these tokens. +- Reworked the Dashboard around today's prioritized actions. Empty accounts no longer render zero-value metrics, ten empty funnel stages, empty time-in-stage, company, skill, or activity panels. The activity SVG now scales to its container instead of requiring a clipped fixed-width mobile canvas. +- Made onboarding dismissible per account/browser while preserving the checklist until it is completed or dismissed. +- Made Settings use a compact section selector at mobile widths, kept tabs for larger screens, narrowed the content column, and removed its duplicate in-page title. Newly touched settings copy is localized in English and Norwegian Bokmål. +- Replaced the ordinary-user Google configuration variable instruction with a human-readable unavailable state. +- Made Playwright independent of nested `npm` calls and accidental system `.NET` ordering. The E2E launcher resolves `DOTNET_HOST_PATH`, `DOTNET_ROOT`, or the user-local SDK before falling back to PATH and invokes Next/Playwright through the current Node runtime. +- Made application CV matching language-aware. It detects/normalizes the advert and linked-CV languages, uses an already-saved English advert translation for an English CV when available, never starts a translation or mutates either document during a match read, and tells the user when a cross-language comparison is translated or limited. +- Expanded the shared deterministic keyword filter to remove Norwegian recruitment filler including `både`, `del`, `dnbs`, `faglig`, `får`, `god`, `hos`, and `utvikling` instead of presenting those words as CV gaps. +- Split the Career Workspace into Overview, Career Profile, and Import Review destinations. Navigation is URL-backed, uses tabs above mobile widths and a labelled select at 375px, and preserves unsaved editor state while changing sections. Import diagnostics no longer force users to scroll through the normal profile editor. +- Removed the duplicate Career Workspace title inside the Overview content; the shared application shell remains the single page-level heading. +- Forced local Next development and Playwright's non-export server onto webpack after reproducing a Next 16 Turbopack panic (`Next.js package not found`) with the bundled portable Node runtime. +- Localized the Job Workspace recruiter-status suggestion and strategy snapshot end to end, including status labels, action buttons, empty/loading outcomes, queued-operation feedback, and both English and Norwegian Bokmål copy. +- Improved the Job Workspace overview card grid so compact screens use one readable column, tablets use two, and wide screens use five without leaving a cramped orphan card. +- Moved the remaining shared shell navigation labels into the global locale catalogue so sidebar and mobile-navigation controls announce themselves in the selected language. +- Localized the application checklist from stable system keys rather than persisted English text. Categories, generated tasks, descriptions, progress, errors, tooltips, and accessible reorder/delete names now switch between English and Bokmål while user-authored task text remains untouched. + +### In progress + +- P0 deployment and production health revalidation. Commit `4d4af47` is pushed and Gitea Actions run 696 exists, but the run remained queued while the public health endpoint returned 502 during the first check. +- Shared UI foundation rollout to remaining screens and standardized loading/empty/error copy. +- Shared loading/empty/error-state consolidation is the next active UI package. + +### Remaining + +- Finish production deployment/health verification and revalidate the production-only blockers. +- Continue shared hierarchy/responsive work across Career Workspace, Job Workspace, Profile, Admin, Jobs tables, Kanban, and CV Builder. +- Add a real frontend lint dependency/configuration once package-index access is explicitly authorized; the current lockfile contains no ESLint packages. +- Continue maintainability packages, accessibility automation, mobile regression coverage, and the prioritized product-value roadmap after the foundations are stable. + +### Blocked + +- ESLint installation is blocked by the repository rule requiring explicit permission before package-index/internet access. +- Production rollout remains dependent on an online Gitea runner matching `ubuntu-latest` and the deployment host becoming healthy. +- Existing external/provider/retention decisions remain listed in `BLOCKERS.md` and are not silently reclassified here. + +### Discovered during implementation + +- The prior E2E command relied on both `dotnet` and nested `npm` being discoverable through ambient PATH. This reproduced the audit's tooling failure on the current workstation. +- The 375px Settings regression test still expected desktop tabs. It now verifies the responsive section selector instead. +- The previous Dashboard rendered its time-in-stage empty state using the unrelated “No tags yet” copy. Empty analytical panels are now suppressed instead of presenting mismatched messages. +- The Application Analysis match previously ignored both `JobApplication.DescriptionLanguage`/`TranslatedDescription` and the linked CV's language setting. This could under-score an English CV against a Norwegian advert even when import had already stored a usable English translation. +- The bundled portable Node runtime starts Next 16 successfully, but Turbopack repeatedly panicked while emitting `/page`; the webpack development path serves the same route correctly and is now the documented/scripted default. + +### Verification + +- Focused frontend: 2 suites, 6 tests passed. +- Full frontend: 62 suites, 256 tests passed. +- Next production build and TypeScript: passed. +- Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors. +- Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate. +- Focused backend match/intelligence verification: 34/34 passed. +- Focused frontend application-intelligence verification: 11/11 passed. +- Career/Profile focused verification: 2 suites, 19/19 passed, including the final navigation/state-preservation regression; final full-suite/E2E gates remain pending. +- Job Workspace focused verification: 2 suites, 9/9 passed; TypeScript passed. +- Checklist localization verification: 7/7 passed, including a Bokmål regression that preserves user-authored content. +- Full backend: 712/712 tests passed on .NET 9. +- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. +- Playwright: 8/10 passed on the first complete run; both failures were ambiguous selectors in the newly responsive Career selector, not product failures. Both corrected focused regressions now pass (2/2); final full rerun remains in the end-of-batch gate. +- Manual desktop browser review: webpack development server rendered the new Career navigation and Overview correctly in dark mode; API-dependent profile status remained unavailable because the backend was not running for that isolated UI review. - **Overall programme status:** Active but externally blocked. Eight packages are locally verified and twenty-five are implemented with verification incomplete. The prioritized admin-only version indicator, every immediate repository/browser item, SEC-009, the PROD-001 read-only inventory, and the PROD-003 safe benchmark harness are complete on the release branch. - **Current work package:** None. Every remaining package now requires a user/operator decision, authorized production mutation/restore/provider action, or explicit package-index access. diff --git a/job-tracker-ui/e2e/smoke.spec.ts b/job-tracker-ui/e2e/smoke.spec.ts index 8b61034..bc32865 100644 --- a/job-tracker-ui/e2e/smoke.spec.ts +++ b/job-tracker-ui/e2e/smoke.spec.ts @@ -128,10 +128,14 @@ test("a Free account keeps manual work available while AI actions stay honestly await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark")); await page.setViewportSize({ width: 375, height: 900 }); await page.goto("/career"); + await page.getByRole("combobox", { name: "Career workspace section" }).click(); + await page.getByRole("option", { name: "Import review" }).click(); await expect(page.getByText(/Build your Career Profile faster with Pro/)).toBeVisible(); - await expect(page.getByLabel("Professional headline")).toBeEnabled(); await page.getByRole("button", { name: "Dismiss Build your Career Profile faster with Pro." }).click(); await expect(page.getByText(/Build your Career Profile faster with Pro/)).toHaveCount(0); + await page.getByRole("combobox", { name: "Career workspace section" }).click(); + await page.getByRole("option", { name: "Career profile" }).click(); + await expect(page.getByLabel("Professional headline")).toBeEnabled(); await page.goto("/settings"); await expect(page.getByText(/Free plan includes core job tracking without AI/i)).toBeVisible(); @@ -140,7 +144,8 @@ test("a Free account keeps manual work available while AI actions stay honestly const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(1); - await page.getByRole("tab", { name: "Backup" }).click(); + await page.getByLabel("Settings section").click(); + await page.getByRole("option", { name: "Backup" }).click(); await expect(page.getByText(/requires a sign-in from the last 15 minutes/i)).toBeVisible(); const exportResponsePromise = page.waitForResponse((response) => response.url().endsWith("/api/export/account") && response.request().method() === "POST"); await page.getByRole("button", { name: "Download readable account export" }).click(); @@ -302,6 +307,20 @@ test("Career Workspace loads from the authenticated application shell", async ({ await expect(page.getByRole("heading", { name: "Career Workspace" })).toBeVisible(); await expect(page.getByRole("link", { name: "Open CV Builder" })).toHaveAttribute("href", "/career/builder"); + await page.setViewportSize({ width: 375, height: 900 }); + const careerSection = page.getByRole("combobox", { name: "Career workspace section" }); + await expect(careerSection).toBeVisible(); + await careerSection.click(); + await page.getByRole("option", { name: "Career profile" }).click(); + await expect(page).toHaveURL(/\/career\?section=profile$/); + await expect(page.getByRole("heading", { name: "Career profile" })).toBeVisible(); + await careerSection.click(); + await page.getByRole("option", { name: "Import review" }).click(); + await expect(page).toHaveURL(/\/career\?section=import$/); + await expect(page.getByRole("heading", { name: "Master CV" })).toBeVisible(); + await careerSection.click(); + await page.getByRole("option", { name: "Overview" }).click(); + for (const width of [375, 768, 1440]) { await page.setViewportSize({ width, height: 900 }); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index e5313ec..0ea4fed 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -29,12 +29,12 @@ "web-vitals": "^2.1.4" }, "scripts": { - "dev": "next dev", - "start": "next dev", + "dev": "next dev --webpack", + "start": "next dev --webpack", "serve:export": "node ./scripts/serve-export.mjs", "build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build", "test": "jest", - "test:e2e": "dotnet build ../JobTrackerApi/JobTrackerApi.csproj --configuration Release && playwright test" + "test:e2e": "node ./scripts/run-e2e.mjs" }, "browserslist": { "production": [ diff --git a/job-tracker-ui/playwright.config.ts b/job-tracker-ui/playwright.config.ts index 6a05721..559b216 100644 --- a/job-tracker-ui/playwright.config.ts +++ b/job-tracker-ui/playwright.config.ts @@ -7,6 +7,11 @@ const appUrl = "http://localhost:3300"; const dataRoot = path.join(os.tmpdir(), `jobtracker-e2e-${process.pid}-${Date.now().toString(36)}`); const reuseServers = process.env.PLAYWRIGHT_REUSE_SERVERS === "true"; const useStaticExport = process.env.PLAYWRIGHT_STATIC_EXPORT === "true"; +const quote = (value: string) => `"${value.replaceAll('"', '\\"')}"`; +const nodeCommand = quote(process.execPath); +const dotnetCommand = quote(process.env.DOTNET_HOST_PATH || "dotnet"); +const nextCommand = quote(path.resolve("node_modules/next/dist/bin/next")); +const exportServerCommand = quote(path.resolve("scripts/serve-export.mjs")); export default defineConfig({ testDir: "./e2e", @@ -22,7 +27,7 @@ export default defineConfig({ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: [ { - command: "dotnet run --no-build --configuration Release --project ../JobTrackerApi/JobTrackerApi.csproj --urls http://localhost:5302", + command: `${dotnetCommand} run --no-build --configuration Release --project ../JobTrackerApi/JobTrackerApi.csproj --urls http://localhost:5302`, url: `${apiUrl}/health`, timeout: 120_000, reuseExistingServer: reuseServers, @@ -43,8 +48,8 @@ export default defineConfig({ }, { command: useStaticExport - ? "npm run build && npm run serve:export" - : "npm run dev -- --hostname localhost --port 3300", + ? `${nodeCommand} ${nextCommand} build && ${nodeCommand} ${exportServerCommand}` + : `${nodeCommand} ${nextCommand} dev --webpack --hostname localhost --port 3300`, url: appUrl, timeout: 120_000, reuseExistingServer: reuseServers, diff --git a/job-tracker-ui/scripts/run-e2e.mjs b/job-tracker-ui/scripts/run-e2e.mjs new file mode 100644 index 0000000..8184102 --- /dev/null +++ b/job-tracker-ui/scripts/run-e2e.mjs @@ -0,0 +1,33 @@ +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const executableName = process.platform === "win32" ? "dotnet.exe" : "dotnet"; +const candidates = [ + process.env.DOTNET_HOST_PATH, + process.env.DOTNET_ROOT ? path.join(process.env.DOTNET_ROOT, executableName) : undefined, + path.join(os.homedir(), ".dotnet", executableName), +].filter(Boolean); + +const dotnet = candidates.find((candidate) => existsSync(candidate)) ?? "dotnet"; +const dotnetDirectory = path.dirname(dotnet); +const childEnv = { + ...process.env, + DOTNET_HOST_PATH: dotnet, + PATH: dotnet === "dotnet" + ? process.env.PATH + : `${dotnetDirectory}${path.delimiter}${process.env.PATH ?? ""}`, +}; + +function run(command, args) { + const result = spawnSync(command, args, { cwd: process.cwd(), env: childEnv, stdio: "inherit", shell: false }); + if (result.error) { + console.error(`Unable to start ${command}: ${result.error.message}`); + process.exit(1); + } + if (result.status !== 0) process.exit(result.status ?? 1); +} + +run(dotnet, ["build", "../JobTrackerApi/JobTrackerApi.csproj", "--configuration", "Release"]); +run(process.execPath, [path.resolve("node_modules/@playwright/test/cli.js"), "test", ...process.argv.slice(2)]); diff --git a/job-tracker-ui/src/application-checklist.test.tsx b/job-tracker-ui/src/application-checklist.test.tsx index db86d15..4c6cf29 100644 --- a/job-tracker-ui/src/application-checklist.test.tsx +++ b/job-tracker-ui/src/application-checklist.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import ApplicationChecklist from "./components/ApplicationChecklist"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; jest.mock("./api", () => ({ api: { @@ -19,6 +20,8 @@ jest.mock("./api", () => ({ const mockedApi = api as jest.Mocked; +const renderChecklist = () => render(); + const item = (over: Partial = {}) => ({ id: 1, systemKey: "prepare-cv", @@ -46,6 +49,7 @@ const checklist = (items: any[]) => ({ beforeEach(() => { jest.clearAllMocks(); + window.localStorage.setItem("uiLanguage", "en"); mockedApi.get.mockResolvedValue({ data: checklist([ item(), @@ -55,8 +59,17 @@ beforeEach(() => { } as any); }); +test("localizes system checklist content without changing user-authored tasks", async () => { + window.localStorage.setItem("uiLanguage", "nb"); + renderChecklist(); + + expect(await screen.findByText("Forbered en CV for denne stillingen")).toBeInTheDocument(); + expect(screen.getByText("Dine egne oppgaver")).toBeInTheDocument(); + expect(screen.getByText("Ask Sara for a referral")).toBeInTheDocument(); +}); + test("renders the checklist grouped by category with progress", async () => { - render(); + renderChecklist(); expect(await screen.findByText("Prepare a CV for this role")).toBeInTheDocument(); expect(screen.getByText("Before applying")).toBeInTheDocument(); @@ -69,7 +82,7 @@ test("renders the checklist grouped by category with progress", async () => { test("completing an item patches its status", async () => { mockedApi.patch.mockResolvedValue({ data: item({ status: "done" }) } as any); - render(); + renderChecklist(); fireEvent.click(await screen.findByRole("checkbox", { name: "Prepare a CV for this role" })); await waitFor(() => @@ -79,7 +92,7 @@ test("completing an item patches its status", async () => { test("un-ticking a completed item sends it back to pending", async () => { mockedApi.patch.mockResolvedValue({ data: item({ id: 2, status: "pending" }) } as any); - render(); + renderChecklist(); fireEvent.click(await screen.findByRole("checkbox", { name: "Create a cover letter" })); await waitFor(() => @@ -89,7 +102,7 @@ test("un-ticking a completed item sends it back to pending", async () => { test("adding a custom task posts the title", async () => { mockedApi.post.mockResolvedValue({ data: item({ id: 9, systemKey: null, isSystemGenerated: false }) } as any); - render(); + renderChecklist(); fireEvent.change(await screen.findByLabelText(/Add your own task/i), { target: { value: "Email the hiring manager" } }); fireEvent.click(screen.getByRole("button", { name: "Add" })); @@ -102,7 +115,7 @@ test("adding a custom task posts the title", async () => { test("reordering sends the new id order", async () => { mockedApi.put.mockResolvedValue({ data: checklist([]) } as any); - render(); + renderChecklist(); fireEvent.click(await screen.findByRole("button", { name: "Move down: Prepare a CV for this role" })); await waitFor(() => @@ -112,7 +125,7 @@ test("reordering sends the new id order", async () => { test("removing an item calls delete", async () => { mockedApi.delete.mockResolvedValue({ data: undefined } as any); - render(); + renderChecklist(); fireEvent.click(await screen.findByRole("button", { name: "Remove: Ask Sara for a referral" })); await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/checklist/3")); diff --git a/job-tracker-ui/src/application-intelligence.test.tsx b/job-tracker-ui/src/application-intelligence.test.tsx index 4cf3136..4593178 100644 --- a/job-tracker-ui/src/application-intelligence.test.tsx +++ b/job-tracker-ui/src/application-intelligence.test.tsx @@ -64,6 +64,10 @@ const match = { hasCareerProfile: true, hasSelectedCv: true, selectedCvName: "Backend CV", + jobLanguage: "en", + cvLanguage: "en", + languageMismatch: false, + usedTranslatedJobDescription: false, matchedSkills: ["C#", "SQL"], missingSkills: ["Kubernetes"], relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }], @@ -183,3 +187,19 @@ test("match warns when the advert is too short to score", async () => { expect(await screen.findByText(/too short to score reliably/i)).toBeInTheDocument(); }); + +test("match explains when a saved translation is used for a cross-language comparison", async () => { + mockedApi.get.mockResolvedValue({ + data: { + ...match, + jobLanguage: "nb", + cvLanguage: "en", + languageMismatch: true, + usedTranslatedJobDescription: true, + }, + } as any); + + render(); + + expect(await screen.findByText(/uses the saved English translation/i)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/application-workflow-assist.test.tsx b/job-tracker-ui/src/application-workflow-assist.test.tsx index 63befec..44a4c92 100644 --- a/job-tracker-ui/src/application-workflow-assist.test.tsx +++ b/job-tracker-ui/src/application-workflow-assist.test.tsx @@ -21,7 +21,7 @@ test('recruiter status suggestions require an explicit apply action', async () = const applied = jest.fn(); mockedApi.get.mockResolvedValue({ data: { hasSuggestion: true, currentStatus: 'Applied', suggestedStatus: 'Interview' } } as any); mockedApi.patch.mockResolvedValue({ data: {} } as any); - render(); + render(); const button = await screen.findByRole('button', { name: 'Apply Interview' }); expect(mockedApi.patch).not.toHaveBeenCalled(); diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 958daa3..3f94138 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -164,6 +164,10 @@ export type CareerMatch = { aiSuggestionCount: number; hasSelectedCv: boolean; selectedCvName: string | null; + jobLanguage: "en" | "nb"; + cvLanguage: "en" | "nb" | null; + languageMismatch: boolean; + usedTranslatedJobDescription: boolean; }; export const TIMELINE_CATEGORY_LABELS: Record = { diff --git a/job-tracker-ui/src/career-workspace-page.test.tsx b/job-tracker-ui/src/career-workspace-page.test.tsx index 1d6f61c..b6be7d2 100644 --- a/job-tracker-ui/src/career-workspace-page.test.tsx +++ b/job-tracker-ui/src/career-workspace-page.test.tsx @@ -50,11 +50,11 @@ afterEach(() => { test("first-run workspace presents concise profile, import, general CV and job-specific actions", async () => { renderOverview(); - expect(screen.getByRole("heading", { name: "Career Workspace", level: 1 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Overview", level: 2 })).toBeInTheDocument(); expect(await screen.findByText(/start by adding career information manually or importing a cv/i)).toBeInTheDocument(); expect(screen.queryByText(/job-specific CVs stay separate and never overwrite/i)).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Edit career profile" })).toHaveAttribute("href", "#career-profile-editor"); - expect(screen.getAllByRole("link", { name: "Import a CV" })[0]).toHaveAttribute("href", "#career-cv-import"); + expect(screen.getByRole("link", { name: "Edit career profile" })).toHaveAttribute("href", "/career?section=profile"); + expect(screen.getAllByRole("link", { name: "Import a CV" })[0]).toHaveAttribute("href", "/career?section=import"); expect(screen.getByRole("link", { name: "Create a general CV" })).toHaveAttribute("href", "/career/builder"); expect(screen.getByRole("link", { name: "Choose a job" })).toHaveAttribute("href", "/jobs"); expect(await screen.findByText("No CV documents yet.")).toBeInTheDocument(); @@ -64,7 +64,7 @@ test("career workspace overview and import state render in Bokmål", async () => window.localStorage.setItem("uiLanguage", "nb"); renderOverview({ runs: [{ id: 7, status: "pending_review" }] }); - expect(screen.getByRole("heading", { name: "Karriereområde", level: 1 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Oversikt", level: 2 })).toBeInTheDocument(); expect(screen.getAllByText("Se gjennom importert CV", { selector: "a" })).toHaveLength(2); expect(screen.getByText("Én import venter på godkjenning.")).toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Handlinger i karriereområdet" })).toBeInTheDocument(); @@ -94,7 +94,7 @@ test.each([ ])("workspace exposes the %s import state as a resumable action", async (status, label, message) => { renderOverview({ runs: [{ id: 13, status }] }); - expect(screen.getAllByRole("link", { name: label })[0]).toHaveAttribute("href", "#career-cv-import"); + expect(screen.getAllByRole("link", { name: label })[0]).toHaveAttribute("href", "/career?section=import"); expect(screen.getByText(message)).toBeInTheDocument(); await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1)); }); diff --git a/job-tracker-ui/src/components/ApplicationChecklist.tsx b/job-tracker-ui/src/components/ApplicationChecklist.tsx index 9be3887..679fae0 100644 --- a/job-tracker-ui/src/components/ApplicationChecklist.tsx +++ b/job-tracker-ui/src/components/ApplicationChecklist.tsx @@ -9,6 +9,7 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import { getApiErrorMessage } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; import { CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, } from "../applicationWorkspace"; @@ -20,6 +21,7 @@ import { // scheduled...). Everything here is the user's to tick, add to, reorder or dismiss. // docs/architecture/application-workspace.md. export default function ApplicationChecklist({ jobId, onChanged }: { jobId: number; onChanged?: () => void }) { + const { t } = useI18n(); const [checklist, setChecklist] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -30,9 +32,9 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb setChecklist(await applicationChecklistApi.get(jobId)); setError(null); } catch (err) { - setError(getApiErrorMessage(err, "Could not load the checklist.")); + setError(getApiErrorMessage(err, t("checklistLoadFailed"))); } - }, [jobId]); + }, [jobId, t]); useEffect(() => { load(); @@ -47,11 +49,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb await load(); onChanged?.(); } catch (err) { - setError(getApiErrorMessage(err, "Could not update the checklist.")); + setError(getApiErrorMessage(err, t("checklistUpdateFailed"))); } finally { setBusy(false); } - }, [load, onChanged]); + }, [load, onChanged, t]); const toggle = (item: ChecklistItem) => mutate(() => applicationChecklistApi.update(jobId, item.id, { @@ -98,15 +100,15 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb {progress && ( - Application checklist + {t("checklistTitle")} - {progress.completed} of {progress.total} done + {t("checklistProgress", { completed: progress.completed, total: progress.total })} @@ -114,9 +116,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb {grouped.map((group) => ( - {group.label} + {checklistCategoryLabel(t, group.key)} - {group.items.map((item) => ( + {group.items.map((item) => { + const display = checklistItemDisplay(t, item); + return ( toggle(item)} - inputProps={{ "aria-label": item.title }} + inputProps={{ "aria-label": display.title }} sx={{ mt: -0.25 }} /> @@ -142,40 +146,41 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb color: item.status === "done" ? "text.disabled" : "text.primary", }} > - {item.title} + {display.title} - {!item.isSystemGenerated && } - {item.isAutoCompleted && } + {!item.isSystemGenerated && } + {item.isAutoCompleted && } - {item.description && ( - {item.description} + {display.description && ( + {display.description} )} - + - move(item, -1)}> + move(item, -1)}> - + - move(item, 1)}> + move(item, 1)}> - + - remove(item)}> + remove(item)}> - ))} + ); + })} ))} @@ -185,14 +190,34 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb setDraft(e.target.value)} /> - + ); } + +function checklistCategoryLabel(t: (key: any, vars?: Record) => string, category: string) { + const keys: Record = { + preparation: "checklistCategoryPreparation", + submission: "checklistCategorySubmission", + "follow-up": "checklistCategoryFollowUp", + interview: "checklistCategoryInterview", + custom: "checklistCategoryCustom", + }; + return keys[category] ? t(keys[category]) : category; +} + +function checklistItemDisplay(t: (key: any, vars?: Record) => string, item: ChecklistItem) { + if (!item.systemKey || item.systemKey.startsWith("learning:")) return { title: item.title, description: item.description }; + const token = item.systemKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + return { + title: t(`checklistItem_${token}`), + description: t(`checklistItem_${token}Description`), + }; +} diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx index f6d1cb1..e48950c 100644 --- a/job-tracker-ui/src/components/ApplicationIntelligence.tsx +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -302,6 +302,14 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { )} + {data?.languageMismatch && ( + + {t(data.usedTranslatedJobDescription + ? "intelligenceMatchUsedTranslation" + : "intelligenceMatchLanguageMismatch")} + + )} + diff --git a/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx b/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx index 3471890..1abe9b3 100644 --- a/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx +++ b/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx @@ -4,6 +4,8 @@ import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from " import { api, getApiErrorMessage } from "../api"; import { useAccountPlan } from "../accountPlan"; +import { useI18n } from "../i18n/I18nProvider"; +import { statusLabel } from "../pipeline"; import type { FocusPlanResponse, StatusSuggestion, StrategySnapshotOperationResponse, UserOperation } from "../types"; import { useToast } from "../toast"; import { DraftCard, ListCard, TwoColumnSection } from "./JobDetailsPanels"; @@ -14,6 +16,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe const [suggestion, setSuggestion] = useState(null); const [busy, setBusy] = useState(false); const { toast } = useToast(); + const { t } = useI18n(); useEffect(() => { let active = true; @@ -31,9 +34,9 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe await api.patch(`/jobapplications/${jobId}/status`, { status: suggestion.suggestedStatus }); setSuggestion(null); onApplied(); - toast("Application status updated from the latest message.", "success"); + toast(t("workspaceStatusSuggestionApplied"), "success"); } catch (error) { - toast(getApiErrorMessage(error, "Could not apply the suggested status."), "error"); + toast(getApiErrorMessage(error, t("workspaceStatusSuggestionFailed")), "error"); } finally { setBusy(false); } @@ -42,9 +45,12 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe return ( void apply()}>Apply {suggestion.suggestedStatus}} + action={} > - A recent recruiter message suggests moving this application from {suggestion.currentStatus || "its current stage"} to {suggestion.suggestedStatus}. + {t("workspaceStatusSuggestionBody", { + current: suggestion.currentStatus ? statusLabel(t, suggestion.currentStatus) : t("workspaceStatusSuggestionCurrent"), + suggested: statusLabel(t, suggestion.suggestedStatus), + })} ); } @@ -52,6 +58,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { const { canUseAi } = useAccountPlan(); const { toast } = useToast(); + const { t } = useI18n(); const [plan, setPlan] = useState(null); const [operation, setOperation] = useState(null); const [loading, setLoading] = useState(true); @@ -95,10 +102,10 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { announced.current = key; if (operation.status === "succeeded") { void loadPlan(); - toast("Strategy snapshot completed.", "success"); - } else if (operation.status === "failed") toast("Strategy snapshot failed. You can retry safely.", "error"); - else toast("Strategy snapshot cancelled.", "info"); - }, [loadPlan, operation, toast]); + toast(t("workspaceStrategyCompleted"), "success"); + } else if (operation.status === "failed") toast(t("workspaceStrategyFailed"), "error"); + else toast(t("workspaceStrategyCancelled"), "info"); + }, [loadPlan, operation, t, toast]); const generate = async () => { setLoading(true); @@ -106,9 +113,9 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { const { data } = await api.post(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: null }); announced.current = null; setOperation(data.operation); - toast(data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info"); + toast(data.created ? t("workspaceStrategyQueued") : t("workspaceStrategyAlreadyQueued"), "info"); } catch (error) { - toast(getApiErrorMessage(error, "Could not queue the strategy snapshot."), "error"); + toast(getApiErrorMessage(error, t("workspaceStrategyQueueFailed")), "error"); } finally { setLoading(false); } @@ -121,7 +128,7 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { const { data } = await api.post(`/operations/${operation.id}/${action}`); setOperation(data); } catch (error) { - toast(getApiErrorMessage(error, `Could not ${action} the strategy snapshot.`), "error"); + toast(getApiErrorMessage(error, action === "cancel" ? t("workspaceStrategyCancelFailed") : t("workspaceStrategyRetryFailed")), "error"); } }; @@ -130,29 +137,29 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { - Strategy snapshot - An on-demand plan grounded in this advert and your saved career data. + {t("workspaceStrategyTitle")} + {t("workspaceStrategySubtitle")} {operation && operation.status !== "succeeded" ? ( - {operation.canCancel ? : null} - {operation.canRetry ? : null} + {operation.canCancel ? : null} + {operation.canRetry ? : null} }> {operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""} ) : null} {loading && !plan ? : plan ? ( - - - - + + + + - ) : No strategy snapshot yet. Generate one when you want AI-assisted planning.} + ) : {t("workspaceStrategyEmpty")}} ); } diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index e10600d..47b33b5 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -104,7 +104,7 @@ function buildLinePath(values: number[], width: number, height: number) { // theme.ts (see 02-dashboard.png). function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) { return ( - + {children} ); @@ -208,7 +208,7 @@ export default function DashboardView() { const trendsView = useMemo(() => { const appliedValues = analytics.map((x) => x.applied); const responseValues = analytics.map((x) => x.responses); - const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860; + const chartWidth = 860; const chartHeight = isMobile ? 210 : 250; const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0); const totalResponses = responseValues.reduce((sum, value) => sum + value, 0); @@ -280,29 +280,80 @@ export default function DashboardView() { }, [navigate]); const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1; + const hasJobs = (stats?.total ?? 0) > 0; + const hasActivityData = analytics.some((item) => item.applied > 0 || item.responses > 0); + const funnelItems = (overview?.funnel ?? []).filter((item) => item.count > 0); + const hasStageData = Boolean(overview?.timeInStage?.length); + const hasCompanies = Boolean(overview?.topCompanies?.length); + const hasSkills = tags.length > 0; + + const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? ( + + {t("dashboardTodayTitle")} + {t("dashboardTodayBody")} + {summaryView.priorityJobs.length === 0 ? ( + {t("remindersNothing")} + ) : ( + + {summaryView.priorityJobs.map((job) => { + const action = getReminderAction(job); + const tone = reminderTone(job); + const toneColor = theme.palette[tone].main; + const urgent = tone === "error"; + return ( + + + + + {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle} + {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")} + + + + + ); + })} + + )} + + + + + ) : null; return ( 0} /> - - - - {t("dashboardHeroLabel")} - - - {t("dashboardOverviewTitle")} - - - {t("dashboardOverviewBody")} - - - + {hasJobs ? + - - + - + : null} - {!summaryResource.loading && !summaryResource.error && prefs.cards ? ( + {priorityPanel ? {priorityPanel} : null} + + {!summaryResource.loading && !summaryResource.error && hasJobs && prefs.cards ? ( {metricCards.map((card) => ( - {!summaryResource.loading && !summaryResource.error && prefs.activity ? ( + {!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? ( @@ -412,9 +465,9 @@ export default function DashboardView() { - - - + + + {[0.2, 0.4, 0.6, 0.8].map((tick) => ( ) : null} - {!summaryResource.loading && !summaryResource.error ? ( + {!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? ( - {t("dashboardTimeInStageTitle")} - {overview?.timeInStage?.length ? ( + {overview?.timeInStage?.length ? (<> + {t("dashboardTimeInStageTitle")} {overview.timeInStage.map((item, index) => ( @@ -470,9 +523,7 @@ export default function DashboardView() { ))} - ) : ( - {t("dashboardNoTagsYet")} - )} + ) : null} {tags.length ? ( @@ -490,9 +541,10 @@ export default function DashboardView() { ) : null} - {t("dashboardConversionFunnelTitle")} + {funnelItems.length > 0 ? <> + {t("dashboardConversionFunnelTitle")} - {(overview?.funnel ?? []).map((item) => { + {funnelItems.map((item) => { const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0; return ( @@ -517,14 +569,15 @@ export default function DashboardView() { ); })} + : null} - + {summaryView.topSource ? {summaryView.topSource?.label ?? t("dashboardResponseSources")} {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"} {summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")} - + : null} ) : null} @@ -551,65 +604,8 @@ export default function DashboardView() { ) : null} - - {!summaryResource.loading && !summaryResource.error ? ( - - {t("remindersTitle")} - {t("remindersSubtitle")} - {summaryView.priorityJobs.length === 0 ? ( - {t("remindersNothing")} - ) : ( - - {summaryView.priorityJobs.map((job) => { - const action = getReminderAction(job); - const tone = reminderTone(job); - const toneColor = theme.palette[tone].main; - const urgent = tone === "error"; - return ( - - - - - {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle} - {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")} - - - - - ); - })} - - )} - - - - - ) : null} - - {!summaryResource.loading && !summaryResource.error && prefs.companies ? ( + + {!summaryResource.loading && !summaryResource.error && hasCompanies && prefs.companies ? ( {t("dashboardTopCompaniesByActivity")} @@ -628,13 +624,10 @@ export default function DashboardView() { ) : null} - {!trendsResource.loading && !trendsResource.error && prefs.skills ? ( + {!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? ( {t("dashboardTopSkills")} - {tags.length === 0 ? ( - {t("dashboardNoTagsYet")} - ) : ( - + {tags.slice(0, 8).map((tag, index) => { const max = Math.max(...tags.map((item) => item.count), 1); const width = (tag.count / max) * 100; @@ -650,8 +643,7 @@ export default function DashboardView() { ); })} - - )} + {t("dashboardSkillTrends")} {!tagTrends || tagTrends.series.length === 0 ? ( diff --git a/job-tracker-ui/src/components/OnboardingChecklist.tsx b/job-tracker-ui/src/components/OnboardingChecklist.tsx index e033770..1051d1b 100644 --- a/job-tracker-ui/src/components/OnboardingChecklist.tsx +++ b/job-tracker-ui/src/components/OnboardingChecklist.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Box, Button, LinearProgress, Paper, Stack, Typography } from "@mui/material"; +import { Box, Button, IconButton, LinearProgress, Paper, Stack, Typography } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import CloseIcon from "@mui/icons-material/Close"; import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked"; import { alpha, useTheme } from "@mui/material/styles"; import { api } from "../api"; import { useI18n } from "../i18n/I18nProvider"; +import { getUserKeyFromToken } from "../themePrefs"; type MeResponse = { email?: string | null; @@ -35,6 +37,8 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) { const theme = useTheme(); const navigate = useNavigate(); const { t } = useI18n(); + const dismissalKey = `onboardingDismissed:${getUserKeyFromToken()}`; + const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissalKey) === "true"); const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null); useEffect(() => { @@ -68,7 +72,7 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) { return () => { active = false; }; }, []); - if (!status) return null; + if (!status || dismissed) return null; const steps = [ { done: true, label: t("onboardingStepSignup"), actionLabel: "" }, @@ -83,7 +87,19 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) { return ( - {t("onboardingTitle")} + + {t("onboardingTitle")} + { + window.localStorage.setItem(dismissalKey, "true"); + setDismissed(true); + }} + > + + + {t("onboardingBody")} {completed} / {steps.length} diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index e66d186..fcf75d9 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -47,7 +47,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { return ( - + {title} {subtitle ? {subtitle} : } {children} @@ -97,15 +97,23 @@ export default function SettingsView({ }; return ( - - - {t("settingsTitle")} - - - {t("settingsSubtitle")} - + + + {t("settingsNavigation")} + + - setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1 }}> + setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}> @@ -197,8 +205,8 @@ export default function SettingsView({ - - + + @@ -215,8 +223,8 @@ export default function SettingsView({ control={ setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />} label={t("settingsNotificationsFollowUpReminders")} /> - Disabling this prevents the background reminder worker from sending follow-up email to your account. In-app reminders remain available. - + {t("settingsNotificationsEmailBody")} + } {t("settingsNotificationsDelivery")} diff --git a/job-tracker-ui/src/daily-control-loop.test.tsx b/job-tracker-ui/src/daily-control-loop.test.tsx index 5094c4b..c5035bf 100644 --- a/job-tracker-ui/src/daily-control-loop.test.tsx +++ b/job-tracker-ui/src/daily-control-loop.test.tsx @@ -191,13 +191,39 @@ afterEach(() => { test('dashboard attention card opens follow-up workspace', async () => { renderLoop('/dashboard'); - expect(await screen.findByText(/needs follow-up/i)).toBeInTheDocument(); + expect(await screen.findByText(/^today$/i)).toBeInTheDocument(); fireEvent.click(await screen.findByRole('button', { name: /follow up/i })); await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/42?section=communication&followMode=waiting-update')); expect(screen.getByTestId('application-workspace')).toBeInTheDocument(); }); +test('empty dashboard prioritizes onboarding and suppresses meaningless analytics', async () => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: 0, active: 0, deleted: 0, byStatus: {}, appliedLast30Days: 0, averageDaysSinceApplied: 0 } } as any); + if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: Array.from({ length: 10 }, (_, index) => ({ label: `Stage ${index}`, count: 0 })), responseRateBySource: [], topCompanies: [], totalResponses: 0, totalActive: 0, timeInStage: [] } } as any); + if (url === '/jobapplications/reminders' || url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any); + if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any); + return Promise.resolve({ data: {} } as any); + }); + + const view = renderLoop('/dashboard'); + + expect(await screen.findByText(/get set up/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(/application activity/i)).not.toBeInTheDocument()); + expect(screen.queryByText(/conversion funnel/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/median time in stage/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/top companies by activity/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^top skills$/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /dismiss setup checklist/i })); + expect(screen.queryByText(/get set up/i)).not.toBeInTheDocument(); + view.unmount(); + + renderLoop('/dashboard'); + await waitFor(() => expect(screen.queryByText(/get set up/i)).not.toBeInTheDocument()); +}); + test('reminders open action routes tailored-cv gaps into the tailored cv workspace', async () => { renderLoop('/reminders'); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index d1a7a81..806d8b7 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -11,6 +11,9 @@ export const translations = { companies: "Companies", trash: "Trash", settings: "Settings", + openNavigation: "Open navigation", + expandSidebar: "Expand sidebar", + collapseSidebar: "Collapse sidebar", addJob: "Add Job", addJobApplication: "Add Job Application", company: "Company", @@ -159,6 +162,8 @@ export const translations = { intelligenceCvProfileEmpty: "The linked CV cannot be matched until its career profile contains content.", intelligenceMatchScore: "Career match score", intelligenceAdvertTooShort: "The advert is too short to score reliably. Add the full text for a meaningful match.", + intelligenceMatchUsedTranslation: "The advert and CV use different languages. This match uses the saved English translation of the advert so equivalent wording is compared fairly.", + intelligenceMatchLanguageMismatch: "The advert and CV use different languages, but no saved translation is available. The score prioritises language-independent technical skills and may understate transferable experience.", intelligenceMatched: "Matched", intelligenceMissing: "Missing", intelligenceRelevantExperience: "Relevant experience", @@ -458,6 +463,10 @@ export const translations = { cvDocumentAiNoChanges: "No wording changes were suggested.", cvDocumentAiApplied: "The reviewed CV suggestions were applied.", careerOverviewTitle: "Career Workspace", + careerWorkspaceNavigation: "Career workspace section", + careerWorkspaceOverviewTab: "Overview", + careerWorkspaceProfileTab: "Career profile", + careerWorkspaceImportTab: "Import review", careerOverviewSubtitle: "Choose what you want to work on next.", careerOverviewOpenBuilder: "Open CV Builder", careerOverviewFirstRun: "Start by adding career information manually or importing a CV.", @@ -655,10 +664,16 @@ export const translations = { settingsBillingUnavailable: "Billing is temporarily unavailable.", settingsBillingNotConfigured: "Pro upgrades are not available in this deployment yet.", settingsSubtitle: "Preferences and admin tools.", + settingsNavigation: "Settings section", settingsTabGeneral: "General", settingsTabFollowUps: "Follow-ups", settingsTabNotifications: "Notifications", settingsTabBackup: "Backup", + settingsConnectedAccountsBody: "Manage inbox connections separately from account and security settings.", + settingsManageConnectedAccounts: "Manage connected accounts", + settingsNotificationsEmailBody: "Turning this off stops follow-up reminder emails. In-app reminders remain available.", + settingsNotificationsSave: "Save notification settings", + settingsSaving: "Saving…", settingsAppearance: "Appearance", settingsTheme: "Theme", settingsThemeSystem: "System", @@ -914,6 +929,7 @@ export const translations = { dashboardHeroLabel: "Job search overview", onboardingTitle: "Get set up", onboardingBody: "A few steps to get the most out of Jobbjakt.", + onboardingDismiss: "Dismiss setup checklist", onboardingStepSignup: "Create your account", onboardingStepVerify: "Verify your email", onboardingStepProfile: "Complete your profile", @@ -925,6 +941,12 @@ export const translations = { onboardingStepEmail: "Connect your email", onboardingStepEmailAction: "Connect email", dashboardResponseRate: "{rate}% response rate", + dashboardTodayTitle: "Today", + dashboardTodayBody: "Your highest-priority application actions, ordered by urgency.", + dashboardSummaryUnavailableTitle: "Unable to load dashboard summary", + dashboardSummaryUnavailableBody: "The dashboard summary is unavailable right now.", + dashboardTrendsUnavailableTitle: "Unable to load dashboard trends", + dashboardTrendsUnavailableBody: "Charts and trend panels could not reach the API.", dashboardMonthsShort: "{count} mo", dashboardAppliedCount: "{count} applied", dashboardResponsesCount: "{count} responses", @@ -1242,7 +1264,7 @@ export const translations = { adminSystemCpuMode: "CPU mode", adminSystemNoSmtpHost: "No SMTP host configured", googleAccountTitle: "Google account", - googleSetupHint: "Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.", + googleSetupHint: "Google sign-in is not available in this deployment.", googleLinked: "Linked", googleAvailableToLink: "Available to link", googleLinkedDate: "Linked {date}", @@ -1679,6 +1701,55 @@ export const translations = { workspaceNoActivity: "No activity recorded yet.", workspaceJobDetails: "Job details", workspaceChecklist: "Next actions and checklist", + checklistTitle: "Application checklist", + checklistProgress: "{completed} of {total} done", + checklistCompletion: "Checklist completion", + checklistLoadFailed: "Could not load the checklist.", + checklistUpdateFailed: "Could not update the checklist.", + checklistCategoryPreparation: "Before applying", + checklistCategorySubmission: "Submitting", + checklistCategoryFollowUp: "Follow-up", + checklistCategoryInterview: "Interview", + checklistCategoryCustom: "Your own tasks", + checklistYours: "Yours", + checklistDetected: "Detected", + checklistMoveUp: "Move up", + checklistMoveDown: "Move down", + checklistMoveUpItem: "Move up: {title}", + checklistMoveDownItem: "Move down: {title}", + checklistNotRelevant: "Not relevant for this role", + checklistRemoveItem: "Remove: {title}", + checklistAddTask: "Add your own task", + checklistAdd: "Add", + deleteAction: "Delete", + checklistItem_reviewJobDetails: "Review job details", + checklistItem_reviewJobDetailsDescription: "Save the advert text so analysis and matching have something to work with.", + checklistItem_completeCareerProfile: "Complete your career profile", + checklistItem_completeCareerProfileDescription: "The master profile is what every CV variant is built from.", + checklistItem_prepareCv: "Prepare a CV for this role", + checklistItem_prepareCvDescription: "Attach a CV variant tailored to this application.", + checklistItem_reviewCvMatch: "Review the CV match", + checklistItem_reviewCvMatchDescription: "Check that the CV answers the advert before sending it.", + checklistItem_createCoverLetter: "Create a cover letter", + checklistItem_createCoverLetterDescription: "Prepare a tailored letter for this application.", + checklistItem_attachPortfolio: "Attach a portfolio example", + checklistItem_attachPortfolioDescription: "Add relevant work samples where the role rewards them.", + checklistItem_attachSupportingDocuments: "Attach supporting documents", + checklistItem_attachSupportingDocumentsDescription: "Add certificates, references, or transcripts where relevant.", + checklistItem_saveApplicationAnswers: "Save application answers for this role", + checklistItem_saveApplicationAnswersDescription: "Reuse them in the application form and interview preparation.", + checklistItem_captureRecruiterContact: "Capture recruiter contact details", + checklistItem_captureRecruiterContactDescription: "A named contact makes a focused follow-up possible.", + checklistItem_confirmSubmitted: "Confirm the application was submitted", + checklistItem_confirmSubmittedDescription: "Move it out of the prospect stage and record the application date.", + checklistItem_addFollowUpReminder: "Add a follow-up reminder", + checklistItem_addFollowUpReminderDescription: "Choose when you want to follow up on this application.", + checklistItem_setNextAction: "Write the next action", + checklistItem_setNextActionDescription: "Keep the application moving deliberately rather than drifting.", + checklistItem_prepareInterviewNotes: "Prepare interview notes", + checklistItem_prepareInterviewNotesDescription: "Record talking points and likely questions before the interview.", + checklistItem_researchCompany: "Research the company", + checklistItem_researchCompanyDescription: "Learn enough about its product, people, and recent work to ask useful questions.", workspaceActivityHistory: "Activity history", workspaceDocuments: "Documents", workspaceCommunication: "Communication", @@ -1686,6 +1757,25 @@ export const translations = { workspaceSelectCvMatch: "Choose the CV intended for this application before comparing it with the advert.", workspaceComparingCv: "{name} compared with this job advert.", workspaceSelectCvFirst: "Select a CV on the CV tab first. The application will only analyse the document you explicitly link.", + workspaceStatusSuggestionApply: "Apply {status}", + workspaceStatusSuggestionBody: "A recent recruiter message suggests moving this application from {current} to {suggested}.", + workspaceStatusSuggestionCurrent: "its current stage", + workspaceStatusSuggestionApplied: "Application status updated from the latest message.", + workspaceStatusSuggestionFailed: "Could not apply the suggested status.", + workspaceStrategyTitle: "Strategy snapshot", + workspaceStrategySubtitle: "An on-demand plan grounded in this advert and your saved career data.", + workspaceStrategyProRequired: "Pro required", + workspaceStrategyGenerate: "Generate", + workspaceStrategyRegenerate: "Regenerate", + workspaceStrategyCompleted: "Strategy snapshot completed.", + workspaceStrategyFailed: "Strategy snapshot failed. You can retry safely.", + workspaceStrategyCancelled: "Strategy snapshot cancelled.", + workspaceStrategyQueued: "Strategy snapshot queued.", + workspaceStrategyAlreadyQueued: "Strategy snapshot is already queued.", + workspaceStrategyQueueFailed: "Could not queue the strategy snapshot.", + workspaceStrategyCancelFailed: "Could not cancel the strategy snapshot.", + workspaceStrategyRetryFailed: "Could not retry the strategy snapshot.", + workspaceStrategyEmpty: "No strategy snapshot yet. Generate one when you want AI-assisted planning.", coverAiTitle: "AI writing assistant", coverAiSubtitle: "Uses this job and its linked CV. Suggestions never overwrite your document.", coverAiSelectCv: "Select a CV before generating a tailored cover letter.", @@ -1707,6 +1797,9 @@ export const translations = { companies: "Selskaper", trash: "Papirkurv", settings: "Innstillinger", + openNavigation: "Åpne navigasjon", + expandSidebar: "Utvid sidemenyen", + collapseSidebar: "Skjul sidemenyen", addJob: "Legg til jobb", addJobApplication: "Legg til jobbsøknad", company: "Selskap", @@ -1855,6 +1948,8 @@ export const translations = { intelligenceCvProfileEmpty: "Den tilknyttede CV-en kan ikke sammenlignes før karriereprofilen inneholder data.", intelligenceMatchScore: "Samsvar mellom CV og jobb", intelligenceAdvertTooShort: "Annonsen er for kort til å gi en pålitelig vurdering. Legg til hele teksten for et meningsfylt samsvar.", + intelligenceMatchUsedTranslation: "Annonsen og CV-en bruker ulike språk. Sammenligningen bruker den lagrede engelske oversettelsen av annonsen, slik at tilsvarende formuleringer vurderes rettferdig.", + intelligenceMatchLanguageMismatch: "Annonsen og CV-en bruker ulike språk, men ingen lagret oversettelse er tilgjengelig. Vurderingen prioriterer språkuavhengige tekniske ferdigheter og kan undervurdere overførbar erfaring.", intelligenceMatched: "Samsvarer", intelligenceMissing: "Mangler", intelligenceRelevantExperience: "Relevant erfaring", @@ -2154,6 +2249,10 @@ export const translations = { cvDocumentAiNoChanges: "Ingen tekstendringer ble foreslått.", cvDocumentAiApplied: "De gjennomgåtte CV-forslagene ble tatt i bruk.", careerOverviewTitle: "Karriereområde", + careerWorkspaceNavigation: "Område i karrierearbeidsområdet", + careerWorkspaceOverviewTab: "Oversikt", + careerWorkspaceProfileTab: "Karriereprofil", + careerWorkspaceImportTab: "Importgjennomgang", careerOverviewSubtitle: "Velg hva du vil arbeide med videre.", careerOverviewOpenBuilder: "Åpne CV-bygger", careerOverviewFirstRun: "Start med å legge til karriereinformasjon manuelt eller importere en CV.", @@ -2351,10 +2450,16 @@ export const translations = { settingsBillingUnavailable: "Betaling er midlertidig utilgjengelig.", settingsBillingNotConfigured: "Pro-oppgraderinger er ikke tilgjengelige i denne installasjonen ennå.", settingsSubtitle: "Preferanser og adminverktøy.", + settingsNavigation: "Innstillingsområde", settingsTabGeneral: "Generelt", settingsTabFollowUps: "Oppfølging", settingsTabNotifications: "Varsler", settingsTabBackup: "Sikkerhetskopi", + settingsConnectedAccountsBody: "Administrer innbokstilkoblinger separat fra konto- og sikkerhetsinnstillingene.", + settingsManageConnectedAccounts: "Administrer tilkoblede kontoer", + settingsNotificationsEmailBody: "Hvis dette slås av, stoppes e-postpåminnelser om oppfølging. Påminnelser i appen forblir tilgjengelige.", + settingsNotificationsSave: "Lagre varslingsinnstillinger", + settingsSaving: "Lagrer…", settingsAppearance: "Utseende", settingsTheme: "Tema", settingsThemeSystem: "System", @@ -2610,6 +2715,7 @@ export const translations = { dashboardHeroLabel: "Oversikt over jobbsøket", onboardingTitle: "Kom i gang", onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.", + onboardingDismiss: "Skjul oppstartslisten", onboardingStepSignup: "Opprett kontoen din", onboardingStepVerify: "Bekreft e-postadressen din", onboardingStepProfile: "Fullfør profilen din", @@ -2621,6 +2727,12 @@ export const translations = { onboardingStepEmail: "Koble til e-post", onboardingStepEmailAction: "Koble til e-post", dashboardResponseRate: "{rate}% svarrate", + dashboardTodayTitle: "I dag", + dashboardTodayBody: "De viktigste handlingene for søknadene dine, sortert etter hvor mye de haster.", + dashboardSummaryUnavailableTitle: "Kunne ikke laste dashboard-oppsummeringen", + dashboardSummaryUnavailableBody: "Dashboard-oppsummeringen er ikke tilgjengelig akkurat nå.", + dashboardTrendsUnavailableTitle: "Kunne ikke laste dashboard-trender", + dashboardTrendsUnavailableBody: "Grafer og trendpaneler fikk ikke kontakt med API-et.", dashboardMonthsShort: "{count} md", dashboardAppliedCount: "{count} søkt", dashboardResponsesCount: "{count} svar", @@ -2938,7 +3050,7 @@ export const translations = { adminSystemCpuMode: "CPU-modus", adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert", googleAccountTitle: "Google-konto", - googleSetupHint: "Sett `NEXT_PUBLIC_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.", + googleSetupHint: "Google-innlogging er ikke tilgjengelig i denne installasjonen.", googleLinked: "Koblet", googleAvailableToLink: "Tilgjengelig for kobling", googleLinkedDate: "Koblet {date}", @@ -3375,6 +3487,55 @@ export const translations = { workspaceNoActivity: "Ingen aktivitet er registrert ennå.", workspaceJobDetails: "Stillingsdetaljer", workspaceChecklist: "Neste handlinger og sjekkliste", + checklistTitle: "Sjekkliste for søknaden", + checklistProgress: "{completed} av {total} fullført", + checklistCompletion: "Fullføring av sjekklisten", + checklistLoadFailed: "Kunne ikke laste sjekklisten.", + checklistUpdateFailed: "Kunne ikke oppdatere sjekklisten.", + checklistCategoryPreparation: "Før du søker", + checklistCategorySubmission: "Innsending", + checklistCategoryFollowUp: "Oppfølging", + checklistCategoryInterview: "Intervju", + checklistCategoryCustom: "Dine egne oppgaver", + checklistYours: "Din", + checklistDetected: "Oppdaget", + checklistMoveUp: "Flytt opp", + checklistMoveDown: "Flytt ned", + checklistMoveUpItem: "Flytt opp: {title}", + checklistMoveDownItem: "Flytt ned: {title}", + checklistNotRelevant: "Ikke relevant for denne stillingen", + checklistRemoveItem: "Fjern: {title}", + checklistAddTask: "Legg til din egen oppgave", + checklistAdd: "Legg til", + deleteAction: "Slett", + checklistItem_reviewJobDetails: "Se gjennom stillingsdetaljene", + checklistItem_reviewJobDetailsDescription: "Lagre annonseteksten slik at analyse og sammenligning har et godt grunnlag.", + checklistItem_completeCareerProfile: "Fullfør karriereprofilen din", + checklistItem_completeCareerProfileDescription: "Hovedprofilen er grunnlaget for alle CV-varianter.", + checklistItem_prepareCv: "Forbered en CV for denne stillingen", + checklistItem_prepareCvDescription: "Koble til en CV-variant som er tilpasset denne søknaden.", + checklistItem_reviewCvMatch: "Se gjennom CV-samsvaret", + checklistItem_reviewCvMatchDescription: "Kontroller at CV-en svarer på annonsen før du sender den.", + checklistItem_createCoverLetter: "Opprett et søknadsbrev", + checklistItem_createCoverLetterDescription: "Forbered et tilpasset brev for denne søknaden.", + checklistItem_attachPortfolio: "Legg ved et porteføljeeksempel", + checklistItem_attachPortfolioDescription: "Legg til relevante arbeidseksempler når stillingen verdsetter dem.", + checklistItem_attachSupportingDocuments: "Legg ved støttedokumenter", + checklistItem_attachSupportingDocumentsDescription: "Legg til attester, referanser eller vitnemål der det er relevant.", + checklistItem_saveApplicationAnswers: "Lagre søknadssvar for denne stillingen", + checklistItem_saveApplicationAnswersDescription: "Gjenbruk dem i søknadsskjemaet og intervjuforberedelsen.", + checklistItem_captureRecruiterContact: "Lagre kontaktinformasjon til rekruttereren", + checklistItem_captureRecruiterContactDescription: "En navngitt kontakt gjør målrettet oppfølging mulig.", + checklistItem_confirmSubmitted: "Bekreft at søknaden er sendt", + checklistItem_confirmSubmittedDescription: "Flytt den ut av interessentstadiet og registrer søknadsdatoen.", + checklistItem_addFollowUpReminder: "Legg til en oppfølgingspåminnelse", + checklistItem_addFollowUpReminderDescription: "Velg når du vil følge opp denne søknaden.", + checklistItem_setNextAction: "Skriv neste handling", + checklistItem_setNextActionDescription: "Hold søknaden i bevegelse med en tydelig plan.", + checklistItem_prepareInterviewNotes: "Forbered intervjunotater", + checklistItem_prepareInterviewNotesDescription: "Noter samtalepunkter og sannsynlige spørsmål før intervjuet.", + checklistItem_researchCompany: "Undersøk selskapet", + checklistItem_researchCompanyDescription: "Lær nok om produktet, menneskene og nylig arbeid til å stille nyttige spørsmål.", workspaceActivityHistory: "Aktivitetshistorikk", workspaceDocuments: "Dokumenter", workspaceCommunication: "Kommunikasjon", @@ -3382,6 +3543,25 @@ export const translations = { workspaceSelectCvMatch: "Velg CV-en som skal brukes i søknaden før den sammenlignes med annonsen.", workspaceComparingCv: "{name} sammenlignes med denne stillingsannonsen.", workspaceSelectCvFirst: "Velg først en CV under CV-fanen. Søknaden analyserer bare dokumentet du kobler til eksplisitt.", + workspaceStatusSuggestionApply: "Bruk {status}", + workspaceStatusSuggestionBody: "En nylig melding fra en rekrutterer foreslår å flytte denne søknaden fra {current} til {suggested}.", + workspaceStatusSuggestionCurrent: "nåværende stadium", + workspaceStatusSuggestionApplied: "Søknadsstatusen ble oppdatert fra den siste meldingen.", + workspaceStatusSuggestionFailed: "Kunne ikke bruke den foreslåtte statusen.", + workspaceStrategyTitle: "Strategioversikt", + workspaceStrategySubtitle: "En behovsstyrt plan basert på denne annonsen og dine lagrede karrieredata.", + workspaceStrategyProRequired: "Krever Pro", + workspaceStrategyGenerate: "Generer", + workspaceStrategyRegenerate: "Generer på nytt", + workspaceStrategyCompleted: "Strategioversikten er ferdig.", + workspaceStrategyFailed: "Strategioversikten mislyktes. Du kan trygt prøve igjen.", + workspaceStrategyCancelled: "Strategioversikten ble avbrutt.", + workspaceStrategyQueued: "Strategioversikten er satt i kø.", + workspaceStrategyAlreadyQueued: "Strategioversikten står allerede i kø.", + workspaceStrategyQueueFailed: "Kunne ikke sette strategioversikten i kø.", + workspaceStrategyCancelFailed: "Kunne ikke avbryte strategioversikten.", + workspaceStrategyRetryFailed: "Kunne ikke prøve strategioversikten på nytt.", + workspaceStrategyEmpty: "Ingen strategioversikt ennå. Generer en når du ønsker AI-støttet planlegging.", coverAiTitle: "AI-skriveassistent", coverAiSubtitle: "Bruker denne stillingen og den tilknyttede CV-en. Forslag overskriver aldri dokumentet ditt.", coverAiSelectCv: "Velg en CV før du genererer et skreddersydd søknadsbrev.", diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index f1b082c..2cf0b73 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -295,7 +295,7 @@ export default function AppShell({ edge="start" size="small" color="secondary" - aria-label="Open navigation" + aria-label={t("openNavigation")} onClick={() => onToggleDrawer(true)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }} > @@ -371,8 +371,8 @@ export default function AppShell({ size="small" color="secondary" onClick={() => setDesktopNavCollapsed((value) => !value)} - title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"} - aria-label={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"} + title={desktopNavCollapsed ? t("expandSidebar") : t("collapseSidebar")} + aria-label={desktopNavCollapsed ? t("expandSidebar") : t("collapseSidebar")} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }} > @@ -521,7 +521,7 @@ export default function AppShell({ component="main" sx={{ flex: 1, - p: { xs: 2, sm: 3 }, + p: "var(--app-page-gutter)", bgcolor: "background.default", minHeight: "100vh", minWidth: 0, @@ -529,7 +529,7 @@ export default function AppShell({ > - + {breadcrumbs.map((c) => ( diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 5dbf539..4f7f3d0 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -78,7 +78,8 @@ function renderWith(Component: React.ComponentType) { // These exercise the master-CV editing surface, which lives on /career (CareerProfilePage) after // the Phase 2.2 split. ProfilePage no longer carries it. -function renderPage() { +function renderPage(section: 'profile' | 'import' = 'profile') { + window.history.replaceState({}, '', `/career?section=${section}`); return renderWith(CareerProfilePage); } void ProfilePage; @@ -184,6 +185,7 @@ beforeEach(() => { }); mockedApi.put.mockResolvedValue({ data: {} } as any); window.localStorage.clear(); + window.history.replaceState({}, '', '/career'); }); afterEach(() => { @@ -196,18 +198,19 @@ test('profile page loads persisted structured cv and can re-parse it', async () expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); expect(screen.getByText(/profile sections/i)).toBeInTheDocument(); expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0); + expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'); + expect(screen.getByText(/high 92%/i)).toBeInTheDocument(); + expect(screen.getByText(/block-1/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: /import review/i })); expect(screen.getByText(/extraction history/i)).toBeInTheDocument(); expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument(); expect(screen.getByText(/current run/i)).toBeInTheDocument(); expect(screen.queryByText(/template-driven cv builder/i)).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: /open cv builder/i })).toHaveAttribute('href', '/career/builder'); expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0); const originalExtractionToggle = screen.getByRole('button', { name: /original import/i }); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false'); expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0); - expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'); - expect(screen.getByText(/high 92%/i)).toBeInTheDocument(); - expect(screen.getByText(/block-1/i)).toBeInTheDocument(); fireEvent.click(originalExtractionToggle); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true'); @@ -273,7 +276,7 @@ test('editing a field in an extracted section updates parent state and flows int }); test('profile page can reprocess from stored artifact history', async () => { - renderPage(); + renderPage('import'); expect(await screen.findByText(/extraction history/i)).toBeInTheDocument(); const reprocessButton = screen.getByRole('button', { name: /reprocess cv/i }); @@ -354,7 +357,7 @@ test('profile page shows durable CV operation state and retries a failed run', a canRetry: true, }, }]; - renderPage(); + renderPage('import'); expect(await screen.findByText('failed')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /retry processing/i })); @@ -365,10 +368,9 @@ test('profile page shows durable CV operation state and retries a failed run', a }); test('profile page keeps raw extraction collapsed until expanded', async () => { - renderPage(); + renderPage('import'); - expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); - expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument(); + expect(await screen.findByText(/your career information stays front and center/i)).toBeInTheDocument(); const originalExtractionToggle = screen.getByRole('button', { name: /original import/i }); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false'); @@ -385,7 +387,7 @@ test('saving the master profile (career) persists the structured profile via /ca // Phase 3: /career saves the master profile through the relational API. The payload carries the // structured profile object (not the legacy flat blob) and never identity fields. mockedApi.put.mockResolvedValue({ data: { profile: structuredCv, completeness: { percent: 70, missing: [], sections: [] }, cvText: '' } } as any); - renderWith(CareerProfilePage); + renderPage(); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); const fullNameInput = screen.getByLabelText(/full name/i); @@ -411,7 +413,25 @@ test('saving the master profile (career) persists the structured profile via /ca test('/career shows the profile completeness overview', async () => { renderWith(CareerProfilePage); expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument(); - expect(screen.getByText(/70%/)).toBeInTheDocument(); + expect(await screen.findByText(/70%/)).toBeInTheDocument(); +}); + +test('/career separates overview, profile and import while preserving unsaved profile edits', async () => { + renderWith(CareerProfilePage); + await screen.findByText(/70%/); + + fireEvent.click(screen.getByRole('link', { name: /edit career profile/i })); + const nameField = await screen.findByLabelText(/full name/i); + fireEvent.change(nameField, { target: { value: 'Unsaved Section Switch' } }); + + fireEvent.click(screen.getByRole('tab', { name: /import review/i })); + expect(screen.getByRole('heading', { name: /master cv/i })).toBeInTheDocument(); + expect(window.location.search).toBe('?section=import'); + + fireEvent.click(screen.getByRole('tab', { name: /career profile/i })); + expect(await screen.findByLabelText(/full name/i)).toHaveValue('Unsaved Section Switch'); + expect(screen.getByText('Unsaved changes')).toBeInTheDocument(); + expect(window.location.search).toBe('?section=profile'); }); test('pending CV extraction can be reviewed and applied', async () => { @@ -426,7 +446,7 @@ test('pending CV extraction can be reviewed and applied', async () => { normalizerVersion: 'm005-s01', llmPromptVersion: 'm005-s01', }]; - renderPage(); + renderPage('import'); expect(await screen.findByText(/3 additions/i)).toBeInTheDocument(); const lowConfidence = screen.getByRole('checkbox', { name: /include low-confidence languages: french/i }); diff --git a/job-tracker-ui/src/settings-view.test.tsx b/job-tracker-ui/src/settings-view.test.tsx index 5294f7c..faf91cf 100644 --- a/job-tracker-ui/src/settings-view.test.tsx +++ b/job-tracker-ui/src/settings-view.test.tsx @@ -105,6 +105,7 @@ test('settings view has no accent picker and uses one follow-up section, one not renderView(); expect(screen.queryByText(/accent/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/settings section/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument(); diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts index 9a0a7a3..f3f3752 100644 --- a/job-tracker-ui/src/theme.ts +++ b/job-tracker-ui/src/theme.ts @@ -250,9 +250,24 @@ export const getTheme = (_mode: "light" | "dark") => { theme.components = { MuiCssBaseline: { styleOverrides: { + ":root": { + "--app-page-gutter": "16px", + "--app-section-gap": "24px", + "--app-card-padding": "16px", + }, + "@media (min-width: 768px)": { + ":root": { + "--app-page-gutter": "24px", + "--app-card-padding": "20px", + }, + }, + "@media (min-width: 1440px)": { + ":root": { "--app-page-gutter": "32px" }, + }, body: { WebkitFontSmoothing: "antialiased", MozOsxFontSmoothing: "grayscale", + overflowX: "hidden", }, // A global floor under every animation added this session (card hover-lift, // MUI's own Dialog/Menu/Collapse transitions, ripple) -- users who set this OS/ diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index f032a89..d883740 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -333,7 +333,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: { )} - + {stats.map((s) => ( onGo(s.go)} diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 8c18b12..46d9398 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, Divider, FormControlLabel, LinearProgress, Paper, TextField, Typography } from "@mui/material"; +import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Paper, Select, Tab, Tabs, TextField, Typography } from "@mui/material"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; @@ -137,6 +137,13 @@ type CareerSectionStatus = { key: string; label: string; complete: boolean; coun type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] }; type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null }; type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean }; +type CareerWorkspaceSection = "overview" | "profile" | "import"; + +function initialWorkspaceSection(): CareerWorkspaceSection { + if (typeof window === "undefined") return "overview"; + const section = new URLSearchParams(window.location.search).get("section"); + return section === "profile" || section === "import" ? section : "overview"; +} // CareerProfilePage backs /career: the master career profile — the single editable source of // truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the @@ -169,6 +176,16 @@ export default function CareerProfilePage() { const [versions, setVersions] = useState([]); // The raw import/section parser remains available as an advanced recovery tool. const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false); + const [workspaceSection, setWorkspaceSection] = useState(initialWorkspaceSection); + + const navigateWorkspace = useCallback((next: CareerWorkspaceSection) => { + setWorkspaceSection(next); + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (next === "overview") url.searchParams.delete("section"); + else url.searchParams.set("section", next); + window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`); + }, []); const loadVersions = useCallback(async () => { try { @@ -303,16 +320,51 @@ export default function CareerProfilePage() { const latestRun = extractionRuns[0]; return ( - - - + + {t("careerWorkspaceNavigation")} + + + navigateWorkspace(value)} + aria-label={t("careerWorkspaceNavigation")} + sx={{ display: { xs: "none", sm: "flex" }, px: 1 }} + > + + + + + + + {workspaceSection === "overview" ? ( + + ) : null} + + {workspaceSection !== "overview" ? + {workspaceSection === "profile" ? void restoreVersion(version)} showSummary={false} - /> - : null} + {workspaceSection === "profile" ? { @@ -338,7 +390,7 @@ export default function CareerProfilePage() { setUploadingAvatar(false); } }} - /> + /> : null} {loadError ? ( void loadProfile()}>Retry}> @@ -347,7 +399,7 @@ export default function CareerProfilePage() { ) : null} - + {workspaceSection === "profile" ? {initials} @@ -395,7 +447,7 @@ export default function CareerProfilePage() { - {careerOnly ? "Career profile" : t("profileTitle")} + {careerOnly ? t("careerWorkspaceProfileTab") : t("profileTitle")} {me?.userName || me?.displayName || fullName || me?.email || "-"} {headline || t("profileHeadlinePlaceholder")} @@ -406,13 +458,14 @@ export default function CareerProfilePage() { - + : null} - + + {!canUseAi && ( @@ -710,7 +763,8 @@ export default function CareerProfilePage() { {t("profileCvStructureEmpty")} )} - + + {t("profileCvStructuredEditor")} {t("profileCvStructuredEditorHelp")} @@ -738,7 +792,7 @@ export default function CareerProfilePage() { editStructuredCv((prev) => ({ ...prev, otherSections: next }))} /> - + {cvWordCount} words @@ -748,7 +802,7 @@ export default function CareerProfilePage() { - + {profileDirty ? : null} + ); } -export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError }: { +export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate }: { completeness: CareerWorkspaceCompleteness | null; runs: CareerWorkspaceImportRun[]; loading: boolean; loadError: string | null; + onNavigate?: (section: "profile" | "import") => void; }) { const { language, t } = useI18n(); const [recentCvs, setRecentCvs] = useState([]); @@ -93,11 +102,11 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l const isFirstRun = !loading && !recentLoading && !loadError && (completeness?.percent ?? 0) === 0 && runs.length === 0 && recentCvs.length === 0; return ( - + - {t("careerOverviewTitle")} + {t("careerWorkspaceOverviewTab")} {t("careerOverviewSubtitle")} @@ -116,20 +125,20 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l {!loading && completeness?.missing.length ? ( {t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).join(", ") })} ) : null} - + {t("careerOverviewCvImport")} {importState.message} - + - } /> - } /> + onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={} /> + onNavigate("import") : undefined} label={importState.label} icon={} /> } /> } />