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.
This commit is contained in:
cesnimda
2026-08-29 16:44:44 +02:00
parent 4d4af47651
commit 9edcbfc5de
29 changed files with 827 additions and 236 deletions
@@ -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));
}
}
@@ -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[]
{
@@ -51,7 +51,11 @@ public sealed record CareerMatchDto(
IReadOnlyList<string> 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";
}
}
+3 -1
View File
@@ -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",
+65 -1
View File
@@ -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 1620px 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.
+21 -2
View File
@@ -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);
+3 -3
View File
@@ -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": [
+8 -3
View File
@@ -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,
+33
View File
@@ -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)]);
@@ -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<typeof api>;
const renderChecklist = () => render(<I18nProvider><ApplicationChecklist jobId={7} /></I18nProvider>);
const item = (over: Partial<any> = {}) => ({
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(<ApplicationChecklist jobId={7} />);
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(<ApplicationChecklist jobId={7} />);
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(<ApplicationChecklist jobId={7} />);
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(<ApplicationChecklist jobId={7} />);
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(<ApplicationChecklist jobId={7} />);
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(<ApplicationChecklist jobId={7} />);
renderChecklist();
fireEvent.click(await screen.findByRole("button", { name: "Remove: Ask Sara for a referral" }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/checklist/3"));
@@ -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(<ApplicationMatch jobId={7} />);
expect(await screen.findByText(/uses the saved English translation/i)).toBeInTheDocument();
});
@@ -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(<ToastProvider><ApplicationStatusSuggestion jobId={42} onApplied={applied} /></ToastProvider>);
render(<I18nProvider><ToastProvider><ApplicationStatusSuggestion jobId={42} onApplied={applied} /></ToastProvider></I18nProvider>);
const button = await screen.findByRole('button', { name: 'Apply Interview' });
expect(mockedApi.patch).not.toHaveBeenCalled();
@@ -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<string, string> = {
@@ -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));
});
@@ -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<Checklist | null>(null);
const [error, setError] = useState<string | null>(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 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Application checklist</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("checklistTitle")}</Typography>
<Typography variant="body2" color="text.secondary">
{progress.completed} of {progress.total} done
{t("checklistProgress", { completed: progress.completed, total: progress.total })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress.percent}
aria-label="Checklist completion"
aria-label={t("checklistCompletion")}
sx={{ height: 8, borderRadius: 4 }}
/>
</Paper>
@@ -114,9 +116,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
{grouped.map((group) => (
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" color="text.secondary">{group.label}</Typography>
<Typography variant="overline" color="text.secondary">{checklistCategoryLabel(t, group.key)}</Typography>
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => (
{group.items.map((item) => {
const display = checklistItemDisplay(t, item);
return (
<Stack
key={item.id}
direction="row"
@@ -129,7 +133,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
checked={item.status === "done"}
disabled={busy}
onChange={() => toggle(item)}
inputProps={{ "aria-label": item.title }}
inputProps={{ "aria-label": display.title }}
sx={{ mt: -0.25 }}
/>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
@@ -142,40 +146,41 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
color: item.status === "done" ? "text.disabled" : "text.primary",
}}
>
{item.title}
{display.title}
</Typography>
{!item.isSystemGenerated && <Chip size="small" label="Yours" variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label="Detected" color="success" variant="outlined" />}
{!item.isSystemGenerated && <Chip size="small" label={t("checklistYours")} variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label={t("checklistDetected")} color="success" variant="outlined" />}
</Stack>
{item.description && (
<Typography variant="caption" color="text.secondary">{item.description}</Typography>
{display.description && (
<Typography variant="caption" color="text.secondary">{display.description}</Typography>
)}
</Box>
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
<Tooltip title="Move up">
<Tooltip title={t("checklistMoveUp")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Move up: ${item.title}`} onClick={() => move(item, -1)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveUpItem", { title: display.title })} onClick={() => move(item, -1)}>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<Tooltip title={t("checklistMoveDown")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Move down: ${item.title}`} onClick={() => move(item, 1)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveDownItem", { title: display.title })} onClick={() => move(item, 1)}>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.isSystemGenerated ? "Not relevant for this role" : "Delete"}>
<Tooltip title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Remove: ${item.title}`} onClick={() => remove(item)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistRemoveItem", { title: display.title })} onClick={() => remove(item)}>
<DeleteOutlineIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
))}
);
})}
</Stack>
</Paper>
))}
@@ -185,14 +190,34 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
<TextField
fullWidth
size="small"
label="Add your own task"
label={t("checklistAddTask")}
value={draft}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>Add</Button>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>{t("checklistAdd")}</Button>
</Stack>
</Paper>
</Stack>
);
}
function checklistCategoryLabel(t: (key: any, vars?: Record<string, string | number>) => string, category: string) {
const keys: Record<string, string> = {
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, string | number>) => 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`),
};
}
@@ -302,6 +302,14 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
</Alert>
)}
{data?.languageMismatch && (
<Alert severity={data.usedTranslatedJobDescription ? "info" : "warning"} sx={{ borderRadius: 2 }}>
{t(data.usedTranslatedJobDescription
? "intelligenceMatchUsedTranslation"
: "intelligenceMatchLanguageMismatch")}
</Alert>
)}
<Chips label={t("intelligenceMatched")} values={data?.matchedSkills ?? []} color="success" />
<Chips label={t("intelligenceMissing")} values={data?.missingSkills ?? []} color="warning" />
@@ -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<StatusSuggestion | null>(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 (
<Alert
severity="info"
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>Apply {suggestion.suggestedStatus}</Button>}
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>{t("workspaceStatusSuggestionApply", { status: statusLabel(t, suggestion.suggestedStatus) })}</Button>}
>
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),
})}
</Alert>
);
}
@@ -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<FocusPlanResponse | null>(null);
const [operation, setOperation] = useState<UserOperation | null>(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<StrategySnapshotOperationResponse>(`/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<UserOperation>(`/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 }) {
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1} sx={{ mb: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Strategy snapshot</Typography>
<Typography variant="caption" color="text.secondary">An on-demand plan grounded in this advert and your saved career data.</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
<Typography variant="caption" color="text.secondary">{t("workspaceStrategySubtitle")}</Typography>
</Box>
<Button variant="outlined" size="small" disabled={!canUseAi || loading || working} onClick={() => void generate()}>
{!canUseAi ? "Pro required" : plan ? "Regenerate" : "Generate"}
{!canUseAi ? t("workspaceStrategyProRequired") : plan ? t("workspaceStrategyRegenerate") : t("workspaceStrategyGenerate")}
</Button>
</Stack>
{operation && operation.status !== "succeeded" ? (
<Alert severity={operation.status === "failed" ? "error" : operation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }} action={<>
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>Cancel</Button> : null}
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>Retry</Button> : null}
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>{t("cancel")}</Button> : null}
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>{t("retry")}</Button> : null}
</>}>
{operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""}
</Alert>
) : null}
{loading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : plan ? (
<Stack spacing={2}>
<DraftCard title="Strategic summary" content={plan.strategicSummary} />
<TwoColumnSection leftTitle="Immediate priorities" leftItems={plan.immediatePriorities} rightTitle="Proof points" rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle="CV bullet ideas" leftItems={plan.cvBulletIdeas} rightTitle="Cover letter angles" rightItems={plan.coverLetterAngles} />
<ListCard title="Follow-up approach" items={plan.followUpApproach} />
<DraftCard title={t("jobDetailsFocusSummary")} content={plan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={plan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={plan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={plan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={plan.followUpApproach} />
</Stack>
) : <Typography color="text.secondary">No strategy snapshot yet. Generate one when you want AI-assisted planning.</Typography>}
) : <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography>}
</Paper>
);
}
+274 -282
View File
@@ -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 (
<Card sx={{ p: { xs: 1.5, sm: 2.25 }, ...sx }}>
<Card sx={{ p: "var(--app-card-padding)", ...sx }}>
{children}
</Card>
);
@@ -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,282 +280,17 @@ 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;
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ maxWidth: 760 }}>
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
{t("dashboardHeroLabel")}
</Typography>
<Typography variant="h3" sx={{ mt: 0.5, color: "text.primary", overflowWrap: "anywhere" }}>
{t("dashboardOverviewTitle")}
</Typography>
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1 }}>
{t("dashboardOverviewBody")}
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2, flexWrap: "wrap" }}>
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
</Stack>
</Box>
<Box
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
alignItems: "center",
width: { xs: "100%", sm: "auto" },
'& .MuiButton-root': {
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
},
}}
>
{([6, 12, 24] as const).map((m) => (
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
{t("dashboardMonthsShort", { count: m })}
</Button>
))}
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
{t("dashboardCustomize")}
</Button>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{[
["cards", t("dashboardSummaryCards")],
["activity", t("dashboardActivityChart")],
["funnel", t("dashboardConversionFunnel")],
["companies", t("dashboardTopCompanies")],
["skills", t("dashboardSkillsInsights")],
].map(([key, label]) => (
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
<Checkbox checked={prefs[key as keyof Prefs]} />
{label}
</MenuItem>
))}
</Menu>
</Box>
</Box>
<ViewStateNotice
loading={summaryResource.loading}
error={summaryResource.error}
title="Unable to load dashboard summary"
description="The dashboard summary is unavailable right now."
onRetry={summaryResource.reload}
/>
<ViewStateNotice
loading={trendsResource.loading}
error={trendsResource.error}
title="Unable to load dashboard trends"
description="Charts and trend panels could not reach the API."
onRetry={trendsResource.reload}
compact
/>
{!summaryResource.loading && !summaryResource.error && prefs.cards ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
{metricCards.map((card) => (
<SectionCard
key={card.label}
sx={{
transition: "box-shadow .2s, transform .2s",
"&:hover": { boxShadow: 6, transform: "translateY(-2px)" },
}}
>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
<Typography variant="h3" sx={{ mt: 0.5 }}>{card.value}</Typography>
{card.trend || card.caption ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 0.75 }}>
{card.trend ? (
<>
{card.trend.tone === "success" ? (
<ArrowUpwardIcon sx={{ fontSize: 14 }} color="success" />
) : (
<ArrowDownwardIcon sx={{ fontSize: 14 }} color="error" />
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: card.trend.tone === "success" ? "success.main" : "error.main" }}>
{card.trend.text}
</Typography>
</>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{card.caption}</Typography>
)}
</Box>
) : null}
</SectionCard>
))}
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && prefs.activity ? (
const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
<Typography variant="h6">{t("dashboardApplicationActivity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
</Box>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.primary.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardAppliedCount", { count: trendsView.totalApplied })}</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.success.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardResponsesCount", { count: trendsView.totalResponses })}</Typography>
</Stack>
</Stack>
</Box>
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
<Box sx={{ minWidth: trendsView.chartWidth }}>
<svg width={trendsView.chartWidth} height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`}>
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
<line
key={tick}
x1="0"
x2={trendsView.chartWidth}
y1={Math.round(trendsView.chartHeight * tick)}
y2={Math.round(trendsView.chartHeight * tick)}
stroke={alpha(theme.palette.text.primary, 0.08)}
strokeDasharray="6 6"
/>
))}
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={theme.palette.success.main} strokeWidth="3" strokeLinecap="round" /> : null}
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={theme.palette.primary.main} strokeWidth="3" strokeLinecap="round" /> : null}
</svg>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
{analytics.map((point) => (
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
{point.month.slice(5)}
</Typography>
))}
</Box>
</Box>
</Box>
</SectionCard>
) : null}
{!summaryResource.loading && !summaryResource.error ? (
<SectionCard>
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
{overview?.timeInStage?.length ? (
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
{overview.timeInStage.map((item, index) => (
<Box key={item.stage}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={clamp((item.medianDays / timeInStageMax) * 100, 4, 100)}
sx={{
height: 8,
borderRadius: 999,
backgroundColor: alpha(theme.palette.text.primary, 0.06),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
backgroundColor: tagColors[index % tagColors.length],
},
}}
/>
</Box>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{t("dashboardNoTagsYet")}</Typography>
)}
{tags.length ? (
<Box sx={{ mt: 2.25 }}>
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
{tags.slice(0, 4).map((tag) => (
<Chip
key={tag.tag}
size="small"
label={`${tag.tag} · ${tag.count}`}
sx={{ backgroundColor: alpha(theme.palette.primary.main, 0.1), color: "primary.main", fontWeight: 700 }}
/>
))}
</Box>
</Box>
) : null}
<Typography variant="h6" sx={{ mt: 2.25 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Stack spacing={1.2} sx={{ mt: 1 }}>
{(overview?.funnel ?? []).map((item) => {
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
return (
<Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={width}
sx={{
height: 10,
borderRadius: 999,
backgroundColor: alpha(theme.palette.primary.main, 0.08),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
},
}}
/>
</Box>
);
})}
</Stack>
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
<Typography variant="h5" sx={{ mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
</Typography>
</Box>
</SectionCard>
) : null}
</Box>
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
<Box sx={{ mt: 2 }}>
<SectionCard>
<Typography variant="h6">{t("dashboardSalaryInsights")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>{t("dashboardSalaryInsightsBody")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
{overview!.salaryInsights!.map((item) => {
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return (
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="caption" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })}</Typography>
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
</Box>
);
})}
</Box>
</SectionCard>
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
<Typography variant="h6" sx={{ mb: 0.5 }}>{t("dashboardTodayTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardTodayBody")}</Typography>
{summaryView.priorityJobs.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
) : (
@@ -607,9 +342,270 @@ export default function DashboardView() {
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
) : null;
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
{hasJobs ? <Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ flexWrap: "wrap" }}>
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
</Stack>
<Box
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
alignItems: "center",
width: { xs: "100%", sm: "auto" },
'& .MuiButton-root': {
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
},
}}
>
{([6, 12, 24] as const).map((m) => (
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
{t("dashboardMonthsShort", { count: m })}
</Button>
))}
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
{t("dashboardCustomize")}
</Button>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{[
["cards", t("dashboardSummaryCards")],
["activity", t("dashboardActivityChart")],
["funnel", t("dashboardConversionFunnel")],
["companies", t("dashboardTopCompanies")],
["skills", t("dashboardSkillsInsights")],
].map(([key, label]) => (
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
<Checkbox checked={prefs[key as keyof Prefs]} />
{label}
</MenuItem>
))}
</Menu>
</Box>
</Box> : null}
<ViewStateNotice
loading={summaryResource.loading}
error={summaryResource.error}
title={t("dashboardSummaryUnavailableTitle")}
description={t("dashboardSummaryUnavailableBody")}
onRetry={summaryResource.reload}
/>
<ViewStateNotice
loading={trendsResource.loading}
error={trendsResource.error}
title={t("dashboardTrendsUnavailableTitle")}
description={t("dashboardTrendsUnavailableBody")}
onRetry={trendsResource.reload}
compact
/>
{priorityPanel ? <Box sx={{ mt: 2 }}>{priorityPanel}</Box> : null}
{!summaryResource.loading && !summaryResource.error && hasJobs && prefs.cards ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
{metricCards.map((card) => (
<SectionCard
key={card.label}
sx={{
transition: "box-shadow .2s, transform .2s",
"&:hover": { boxShadow: 6, transform: "translateY(-2px)" },
}}
>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
<Typography variant="h3" sx={{ mt: 0.5 }}>{card.value}</Typography>
{card.trend || card.caption ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 0.75 }}>
{card.trend ? (
<>
{card.trend.tone === "success" ? (
<ArrowUpwardIcon sx={{ fontSize: 14 }} color="success" />
) : (
<ArrowDownwardIcon sx={{ fontSize: 14 }} color="error" />
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: card.trend.tone === "success" ? "success.main" : "error.main" }}>
{card.trend.text}
</Typography>
</>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{card.caption}</Typography>
)}
</Box>
) : null}
</SectionCard>
))}
</Box>
) : null}
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
<Typography variant="h6">{t("dashboardApplicationActivity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
</Box>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.primary.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardAppliedCount", { count: trendsView.totalApplied })}</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.success.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardResponsesCount", { count: trendsView.totalResponses })}</Typography>
</Stack>
</Stack>
</Box>
<Box sx={{ mt: 2, minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<svg width="100%" height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`} preserveAspectRatio="none" style={{ display: "block", maxWidth: "100%" }}>
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
<line
key={tick}
x1="0"
x2={trendsView.chartWidth}
y1={Math.round(trendsView.chartHeight * tick)}
y2={Math.round(trendsView.chartHeight * tick)}
stroke={alpha(theme.palette.text.primary, 0.08)}
strokeDasharray="6 6"
/>
))}
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={theme.palette.success.main} strokeWidth="3" strokeLinecap="round" /> : null}
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={theme.palette.primary.main} strokeWidth="3" strokeLinecap="round" /> : null}
</svg>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
{analytics.map((point) => (
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
{point.month.slice(5)}
</Typography>
))}
</Box>
</Box>
</Box>
</SectionCard>
) : null}
{!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? (
<SectionCard>
{overview?.timeInStage?.length ? (<>
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
{overview.timeInStage.map((item, index) => (
<Box key={item.stage}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={clamp((item.medianDays / timeInStageMax) * 100, 4, 100)}
sx={{
height: 8,
borderRadius: 999,
backgroundColor: alpha(theme.palette.text.primary, 0.06),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
backgroundColor: tagColors[index % tagColors.length],
},
}}
/>
</Box>
))}
</Stack>
</>) : null}
{tags.length ? (
<Box sx={{ mt: 2.25 }}>
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
{tags.slice(0, 4).map((tag) => (
<Chip
key={tag.tag}
size="small"
label={`${tag.tag} · ${tag.count}`}
sx={{ backgroundColor: alpha(theme.palette.primary.main, 0.1), color: "primary.main", fontWeight: 700 }}
/>
))}
</Box>
</Box>
) : null}
{funnelItems.length > 0 ? <>
<Typography variant="h6" sx={{ mt: hasStageData || hasSkills ? 2.25 : 0 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Stack spacing={1.2} sx={{ mt: 1 }}>
{funnelItems.map((item) => {
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
return (
<Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={width}
sx={{
height: 10,
borderRadius: 999,
backgroundColor: alpha(theme.palette.primary.main, 0.08),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
},
}}
/>
</Box>
);
})}
</Stack>
</> : null}
{summaryView.topSource ? <Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
<Typography variant="h5" sx={{ mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
</Typography>
</Box> : null}
</SectionCard>
) : null}
</Box>
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
<Box sx={{ mt: 2 }}>
<SectionCard>
<Typography variant="h6">{t("dashboardSalaryInsights")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>{t("dashboardSalaryInsightsBody")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
{overview!.salaryInsights!.map((item) => {
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return (
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="caption" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })}</Typography>
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
</Box>
);
})}
</Box>
</SectionCard>
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1fr 1fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && hasCompanies && prefs.companies ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
<Stack spacing={1.25}>
@@ -628,12 +624,9 @@ export default function DashboardView() {
</SectionCard>
) : null}
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
{!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopSkills")}</Typography>
{tags.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
) : (
<Stack spacing={1.15}>
{tags.slice(0, 8).map((tag, index) => {
const max = Math.max(...tags.map((item) => item.count), 1);
@@ -651,7 +644,6 @@ export default function DashboardView() {
);
})}
</Stack>
)}
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
{!tagTrends || tagTrends.series.length === 0 ? (
@@ -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 (
<Paper sx={{ p: 2.25, mb: 2, borderRadius: 4, border: "1px solid", borderColor: alpha(theme.palette.primary.main, 0.25), background: alpha(theme.palette.primary.main, 0.04) }}>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<IconButton
size="small"
aria-label={t("onboardingDismiss")}
onClick={() => {
window.localStorage.setItem(dismissalKey, "true");
setDismissed(true);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("onboardingBody")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{completed} / {steps.length}</Typography>
<LinearProgress variant="determinate" value={(completed / steps.length) * 100} sx={{ my: 1.5, borderRadius: 2 }} />
+21 -13
View File
@@ -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 (
<Paper sx={{ p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Paper sx={{ p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
{children}
@@ -97,15 +97,23 @@ export default function SettingsView({
};
return (
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
{t("settingsTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsSubtitle")}
</Typography>
<Paper sx={{ mt: 0, p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", maxWidth: 960, mx: "auto" }}>
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
<InputLabel id="settings-section-label">{t("settingsNavigation")}</InputLabel>
<Select
labelId="settings-section-label"
value={tab}
label={t("settingsNavigation")}
onChange={(event) => setTab(Number(event.target.value))}
>
<MenuItem value={0}>{t("settingsTabGeneral")}</MenuItem>
<MenuItem value={1}>{t("settingsTabFollowUps")}</MenuItem>
<MenuItem value={2}>{t("settingsTabNotifications")}</MenuItem>
<MenuItem value={3}>{t("settingsTabBackup")}</MenuItem>
</Select>
</FormControl>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
@@ -197,8 +205,8 @@ export default function SettingsView({
<AiPrivacySettingsCard />
<AiUsageCard />
<QuickCaptureCard />
<SectionCard title="Connected accounts" subtitle="Manage inbox connections separately from your account and security settings.">
<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>Manage connected accounts</Button>
<SectionCard title={t("connectedAccounts")} subtitle={t("settingsConnectedAccountsBody")}>
<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>{t("settingsManageConnectedAccounts")}</Button>
</SectionCard>
</Box>
</TabPanel>
@@ -215,8 +223,8 @@ export default function SettingsView({
control={<Checkbox checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
label={t("settingsNotificationsFollowUpReminders")}
/>
<Typography variant="caption" color="text.secondary">Disabling this prevents the background reminder worker from sending follow-up email to your account. In-app reminders remain available.</Typography>
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? "Saving" : "Save notification settings"}</Button></Box>
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button></Box>
</Box>}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
{t("settingsNotificationsDelivery")}
+27 -1
View File
@@ -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');
+182 -2
View File
@@ -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.",
+5 -5
View File
@@ -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 }}
>
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
@@ -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({
>
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: 2, minWidth: 0 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
{breadcrumbs.map((c) => (
+33 -13
View File
@@ -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 });
@@ -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();
+15
View File
@@ -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/
@@ -333,7 +333,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
</Alert>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1.5 }}>
{stats.map((s) => (
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
onClick={() => onGo(s.go)}
+69 -15
View File
@@ -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<CareerVersion[]>([]);
// The raw import/section parser remains available as an advanced recovery tool.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
const [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(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 (
<Box sx={{ display: "grid", gap: 2 }}>
<CareerWorkspaceOverview completeness={completeness} runs={extractionRuns} loading={loading} loadError={loadError} />
<Paper id="career-profile-editor" sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
<ProfileCompleteness
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, p: 1.25 }}>
<InputLabel id="career-workspace-section-label" sx={{ ml: 1.25 }}>{t("careerWorkspaceNavigation")}</InputLabel>
<Select
labelId="career-workspace-section-label"
value={workspaceSection}
label={t("careerWorkspaceNavigation")}
onChange={(event) => navigateWorkspace(event.target.value as CareerWorkspaceSection)}
>
<MenuItem value="overview">{t("careerWorkspaceOverviewTab")}</MenuItem>
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
</Select>
</FormControl>
<Tabs
value={workspaceSection}
onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)}
aria-label={t("careerWorkspaceNavigation")}
sx={{ display: { xs: "none", sm: "flex" }, px: 1 }}
>
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
<Tab value="import" label={t("careerWorkspaceImportTab")} />
</Tabs>
</Paper>
{workspaceSection === "overview" ? (
<CareerWorkspaceOverview
completeness={completeness}
runs={extractionRuns}
loading={loading}
loadError={loadError}
onNavigate={navigateWorkspace}
/>
) : null}
{workspaceSection !== "overview" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
{workspaceSection === "profile" ? <ProfileCompleteness
completeness={completeness}
versions={versions}
loading={loading}
onRestore={(version) => void restoreVersion(version)}
showSummary={false}
/>
<CropImageDialog
/> : null}
{workspaceSection === "profile" ? <CropImageDialog
open={cropOpen}
file={avatarFile}
onClose={() => {
@@ -338,7 +390,7 @@ export default function CareerProfilePage() {
setUploadingAvatar(false);
}
}}
/>
/> : null}
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
@@ -347,7 +399,7 @@ export default function CareerProfilePage() {
</Alert>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
@@ -395,7 +447,7 @@ export default function CareerProfilePage() {
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
{careerOnly ? "Career profile" : t("profileTitle")}
{careerOnly ? t("careerWorkspaceProfileTab") : t("profileTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
@@ -406,13 +458,14 @@ export default function CareerProfilePage() {
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
</Box>
</Box>
</Box> : null}
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box id="career-cv-import" sx={{ gridColumn: "1 / -1", p: { xs: 1.5, sm: 2 }, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none", scrollMarginTop: 96 }}>
<Box sx={{ gridColumn: "1 / -1", p: workspaceSection === "import" ? { xs: 1.5, sm: 2 } : 0, borderRadius: 3, border: workspaceSection === "import" ? "1px solid" : "none", borderColor: "divider", backgroundColor: workspaceSection === "import" ? "background.default" : "transparent", display: careerOnly ? "block" : "none" }}>
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
{!canUseAi && (
<Box sx={{ mb: 2 }}>
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
@@ -710,7 +763,8 @@ export default function CareerProfilePage() {
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureEmpty")}</Typography>
)}
</Box>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
</Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
<Box sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
@@ -738,7 +792,7 @@ export default function CareerProfilePage() {
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{cvWordCount} words
</Typography>
@@ -748,7 +802,7 @@ export default function CareerProfilePage() {
</Box>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
<Button
variant="contained"
@@ -779,7 +833,7 @@ export default function CareerProfilePage() {
</Box>
</Paper>
</Paper> : null}
</Box>
);
}
@@ -30,12 +30,13 @@ function isActive(run: CareerWorkspaceImportRun) {
: run.status === "queued" || run.status === "running";
}
function ActionCard({ title, body, href, label, icon }: {
function ActionCard({ title, body, href, label, icon, onNavigate }: {
title: string;
body: string;
href: string;
label: string;
icon: React.ReactNode;
onNavigate?: () => void;
}) {
return (
<Paper variant="outlined" component="article" sx={{ p: 2, borderRadius: 3, display: "flex", flexDirection: "column", gap: 1.25 }}>
@@ -44,16 +45,24 @@ function ActionCard({ title, body, href, label, icon }: {
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{title}</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{body}</Typography>
<Button href={href} variant="text" sx={{ alignSelf: "flex-start", px: 0.5 }}>{label}</Button>
<Button
href={href}
onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate(); } : undefined}
variant="text"
sx={{ alignSelf: "flex-start", px: 0.5 }}
>
{label}
</Button>
</Paper>
);
}
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<CvVariantSummary[]>([]);
@@ -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 (
<Box component="section" aria-labelledby="career-workspace-title" sx={{ display: "grid", gap: 2 }}>
<Box component="section" aria-labelledby="career-workspace-overview-title" sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 4 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography id="career-workspace-title" component="h1" variant="h5" sx={{ fontWeight: 900 }}>{t("careerOverviewTitle")}</Typography>
<Typography id="career-workspace-overview-title" component="h2" variant="h5" sx={{ fontWeight: 900 }}>{t("careerWorkspaceOverviewTab")}</Typography>
<Typography color="text.secondary">{t("careerOverviewSubtitle")}</Typography>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
@@ -116,20 +125,20 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
{!loading && completeness?.missing.length ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).join(", ") })}</Typography>
) : null}
<Button href="#career-profile-editor" size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
<Button href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
</Box>
<Alert severity={importState.severity} sx={{ borderRadius: 3, alignItems: "flex-start" }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerOverviewCvImport")}</Typography>
<Typography variant="body2">{importState.message}</Typography>
<Button href="#career-cv-import" color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
<Button href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
</Alert>
</Box>
</Paper>
<Box component="nav" aria-label={t("careerOverviewActions")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", xl: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="#career-profile-editor" label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="#career-cv-import" label={importState.label} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="/career?section=profile" onNavigate={onNavigate ? () => onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importState.label} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewGeneralCv")} body={t("careerOverviewGeneralCvBody")} href="/career/builder" label={t("careerOverviewCreateGeneralCv")} icon={<DescriptionOutlinedIcon />} />
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
</Box>