refactor(workspace): unify application workflow

This commit is contained in:
cesnimda
2026-08-28 12:28:12 +02:00
parent 4da673ff00
commit e3f938cb42
8 changed files with 264 additions and 102 deletions
@@ -89,6 +89,24 @@ public sealed class ApplicationIntelligenceTests
return profile; return profile;
} }
private static async Task<CvVariant> AttachCvAsync(JobTrackerContext db, string owner, int jobId, CvVariantSettings? settings = null)
{
var variant = new CvVariant
{
OwnerUserId = owner,
JobApplicationId = jobId,
Name = "Backend CV",
PublicSlug = Guid.NewGuid().ToString("N"),
SettingsJson = CvVariantSettingsJson.Serialize(settings),
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
db.CvVariants.Add(variant);
await db.SaveChangesAsync();
return variant;
}
// ---------- Milestone 1: timeline ---------- // ---------- Milestone 1: timeline ----------
[Fact] [Fact]
@@ -262,11 +280,14 @@ public sealed class ApplicationIntelligenceTests
await using var _d = db; await using var _d = db;
var job = await SeedJobAsync(db, "user-1"); var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-1"); await SeedProfileAsync(db, "user-1");
await AttachCvAsync(db, "user-1", job.Id);
var m = await intelligence.MatchAsync("user-1", job.Id, default); var m = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.NotNull(m); Assert.NotNull(m);
Assert.True(m!.HasCareerProfile); Assert.True(m!.HasSelectedCv);
Assert.Equal("Backend CV", m.SelectedCvName);
Assert.True(m.HasCareerProfile);
Assert.True(m.Score > 0); Assert.True(m.Score > 0);
Assert.Contains("C#", m.MatchedSkills); Assert.Contains("C#", m.MatchedSkills);
// The relevant-experience list is evidence drawn FROM the profile, not a copy of it. // The relevant-experience list is evidence drawn FROM the profile, not a copy of it.
@@ -282,6 +303,7 @@ public sealed class ApplicationIntelligenceTests
await using var _d = db; await using var _d = db;
var job = await SeedJobAsync(db, "user-1"); var job = await SeedJobAsync(db, "user-1");
var profile = await SeedProfileAsync(db, "user-1"); var profile = await SeedProfileAsync(db, "user-1");
await AttachCvAsync(db, "user-1", job.Id);
var bulletsBefore = profile.Experiences[0].BulletsJson; var bulletsBefore = profile.Experiences[0].BulletsJson;
var experienceCountBefore = profile.Experiences.Count; var experienceCountBefore = profile.Experiences.Count;
var versionBefore = profile.Version; var versionBefore = profile.Version;
@@ -295,7 +317,7 @@ public sealed class ApplicationIntelligenceTests
} }
[Fact] [Fact]
public async Task Match_asks_for_a_profile_before_scoring_anything() public async Task Match_requires_an_explicitly_linked_cv_before_scoring_anything()
{ {
var (db, intelligence, _) = New("user-1"); var (db, intelligence, _) = New("user-1");
await using var _d = db; await using var _d = db;
@@ -304,8 +326,9 @@ public sealed class ApplicationIntelligenceTests
var m = await intelligence.MatchAsync("user-1", job.Id, default); var m = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.False(m!.HasCareerProfile); Assert.False(m!.HasCareerProfile);
Assert.False(m.HasSelectedCv);
Assert.Equal(0, m.Score); Assert.Equal(0, m.Score);
Assert.Contains(m.Suggestions, s => s.Contains("career profile", StringComparison.OrdinalIgnoreCase)); Assert.Contains(m.Suggestions, s => s.Contains("Select the CV", StringComparison.OrdinalIgnoreCase));
} }
[Fact] [Fact]
@@ -315,10 +338,30 @@ public sealed class ApplicationIntelligenceTests
await using var _d = db; await using var _d = db;
var job = await SeedJobAsync(db, "user-1"); var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored
await AttachCvAsync(db, "user-1", job.Id);
var m = await intelligence.MatchAsync("user-1", job.Id, default); var m = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.False(m!.HasCareerProfile); Assert.False(m!.HasCareerProfile);
Assert.True(m.HasSelectedCv);
}
[Fact]
public async Task Match_respects_sections_hidden_in_the_linked_cv()
{
var (db, intelligence, _) = New("user-1");
await using var _d = db;
var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-1");
await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings
{
Sections = { new CvSectionSetting { Key = "experience", Hidden = true } },
});
var match = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.True(match!.HasSelectedCv);
Assert.Empty(match.RelevantExperience);
} }
[Fact] [Fact]
@@ -11,7 +11,7 @@ namespace JobTrackerApi.Services;
// Phase 5.3 Milestones 2 and 3 — job analysis and career matching. // Phase 5.3 Milestones 2 and 3 — job analysis and career matching.
// //
// DETERMINISTIC and READ-ONLY. Both endpoints derive their answer from data the user already owns // DETERMINISTIC and READ-ONLY. Both endpoints derive their answer from data the user already owns
// (the advert on the JobApplication, the master CareerProfile) using the existing SkillTagger and // (the advert on the JobApplication, the explicitly linked CV lens over CareerProfile) using the existing SkillTagger and
// JobCvMatchService. The same input always gives the same number, so the score is something a user // JobCvMatchService. The same input always gives the same number, so the score is something a user
// can trust and re-check. // can trust and re-check.
// //
@@ -49,7 +49,9 @@ public sealed record CareerMatchDto(
IReadOnlyList<MatchEvidenceDto> RelevantExperience, IReadOnlyList<MatchEvidenceDto> RelevantExperience,
IReadOnlyList<MatchEvidenceDto> RelevantProjects, IReadOnlyList<MatchEvidenceDto> RelevantProjects,
IReadOnlyList<string> Suggestions, IReadOnlyList<string> Suggestions,
int AiSuggestionCount); int AiSuggestionCount,
bool HasSelectedCv = false,
string? SelectedCvName = null);
public interface IApplicationIntelligenceService public interface IApplicationIntelligenceService
{ {
@@ -195,55 +197,91 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null; if (job is null) return null;
// READ ONLY. The master profile is the single source of truth and nothing here writes to it. // A job-specific match must never silently pick an arbitrary CV. The attached variant is a
// lens over the master profile, so matching it remains read-only while respecting what the
// user actually plans to send (hidden sections/items and CV-specific wording).
var attachedQuery = _db.CvVariants.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId);
var attached = _db.Database.IsSqlite()
? (await attachedQuery.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc)
: await attachedQuery.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct);
if (attached is null)
{
return new CareerMatchDto(0, "Select a CV", false, false,
Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
new[] { "Select the CV you plan to send before running a job-specific match." },
aiCount, HasSelectedCv: false);
}
var profile = await _db.CareerProfiles.AsNoTracking() var profile = await _db.CareerProfiles.AsNoTracking()
.Include(p => p.Experiences) .Include(p => p.Experiences)
.Include(p => p.Projects) .Include(p => p.Projects)
.Include(p => p.Skills) .Include(p => p.Skills)
.FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct); .FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct);
if (profile is null) if (profile is null)
{ {
return new CareerMatchDto(0, "No profile", false, false, return new CareerMatchDto(0, "No profile", false, false,
Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
new[] { "Build your career profile first — matching compares the advert against it." }, new[] { "Build your career profile first — matching compares the advert against it." },
aiCount); aiCount, HasSelectedCv: true, SelectedCvName: attached.Name);
} }
var settings = CvVariantSettingsJson.Deserialize(attached.SettingsJson);
var experienceVisible = SectionVisible(settings, "experience");
var projectsVisible = SectionVisible(settings, "projects");
var skillsVisible = SectionVisible(settings, "skills");
var experiences = experienceVisible
? profile.Experiences.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList()
: new List<CareerExperience>();
var projects = projectsVisible
? profile.Projects.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList()
: new List<CareerProject>();
var skillItems = settings.Sections.FirstOrDefault(section => string.Equals(section.Key, "skills", StringComparison.OrdinalIgnoreCase))?.Items;
var skillsText = !skillsVisible
? string.Empty
: skillItems is not null
? string.Join("\n", skillItems)
: string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}"));
// Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores // Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores
// identically whichever surface asks. // identically whichever surface asks.
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["Experience"] = string.Join("\n", profile.Experiences.Select(ExperienceText)), ["Experience"] = string.Join("\n", experiences.Select(entry => ExperienceText(entry, Override(settings, entry.ItemKey)))),
["Projects"] = string.Join("\n", profile.Projects.Select(ProjectText)), ["Projects"] = string.Join("\n", projects.Select(entry => ProjectText(entry, Override(settings, entry.ItemKey)))),
["Skills"] = string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}")), ["Skills"] = skillsText,
}; };
var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections); var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections);
var relevantExperience = profile.Experiences var relevantExperience = experiences
.Select(e => new { Entry = e, Hits = HitsFor(ExperienceText(e), result.MatchedKeywords) }) .Select(e => new { Entry = e, Override = Override(settings, e.ItemKey) })
.Select(x => new { x.Entry, x.Override, Hits = HitsFor(ExperienceText(x.Entry, x.Override), result.MatchedKeywords) })
.Where(x => x.Hits.Count > 0) .Where(x => x.Hits.Count > 0)
.OrderByDescending(x => x.Hits.Count) .OrderByDescending(x => x.Hits.Count)
.Take(MaxEvidence) .Take(MaxEvidence)
.Select(x => new MatchEvidenceDto( .Select(x => new MatchEvidenceDto(
x.Entry.Title ?? "Untitled role", x.Override.Title ?? x.Entry.Title ?? "Untitled role",
string.Join(" · ", new[] { x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))), string.Join(" · ", new[] { x.Override.Subtitle ?? x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))),
x.Hits)) x.Hits))
.ToList(); .ToList();
var relevantProjects = profile.Projects var relevantProjects = projects
.Select(p => new { Entry = p, Hits = HitsFor(ProjectText(p), result.MatchedKeywords) }) .Select(p => new { Entry = p, Override = Override(settings, p.ItemKey) })
.Select(x => new { x.Entry, x.Override, Hits = HitsFor(ProjectText(x.Entry, x.Override), result.MatchedKeywords) })
.Where(x => x.Hits.Count > 0) .Where(x => x.Hits.Count > 0)
.OrderByDescending(x => x.Hits.Count) .OrderByDescending(x => x.Hits.Count)
.Take(MaxEvidence) .Take(MaxEvidence)
.Select(x => new MatchEvidenceDto( .Select(x => new MatchEvidenceDto(
x.Entry.Name ?? "Untitled project", x.Override.Title ?? x.Entry.Name ?? "Untitled project",
Blank(x.Entry.Role), Blank(x.Override.Subtitle ?? x.Entry.Role),
x.Hits)) x.Hits))
.ToList(); .ToList();
@@ -257,7 +295,9 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
RelevantExperience: relevantExperience, RelevantExperience: relevantExperience,
RelevantProjects: relevantProjects, RelevantProjects: relevantProjects,
Suggestions: Suggestions(result, relevantExperience.Count), Suggestions: Suggestions(result, relevantExperience.Count),
AiSuggestionCount: aiCount); AiSuggestionCount: aiCount,
HasSelectedCv: true,
SelectedCvName: attached.Name);
} }
// Suggestions describe what the USER could change. They never edit anything themselves. // Suggestions describe what the USER could change. They never edit anything themselves.
@@ -299,11 +339,20 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
// ---------- shared ---------- // ---------- shared ----------
private static string ExperienceText(CareerExperience e) => private static string ExperienceText(CareerExperience e, CvItemOverride? itemOverride = null) =>
$"{e.Title} {e.Company} {e.Location} {ReadJsonArray(e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}"; $"{itemOverride?.Title ?? e.Title} {itemOverride?.Subtitle ?? e.Company} {e.Location} {OverrideBullets(itemOverride, e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}";
private static string ProjectText(CareerProject p) => private static string ProjectText(CareerProject p, CvItemOverride? itemOverride = null) =>
$"{p.Name} {p.Role} {ReadJsonArray(p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}"; $"{itemOverride?.Title ?? p.Name} {itemOverride?.Subtitle ?? p.Role} {OverrideBullets(itemOverride, p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}";
private static string OverrideBullets(CvItemOverride? itemOverride, string fallbackJson) =>
itemOverride?.Bullets is null ? ReadJsonArray(fallbackJson) : string.Join(" ", itemOverride.Bullets);
private static CvItemOverride Override(CvVariantSettings settings, string itemKey) =>
settings.Overrides.TryGetValue(itemKey, out var itemOverride) ? itemOverride : new CvItemOverride();
private static bool SectionVisible(CvVariantSettings settings, string key) =>
settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase))?.Hidden != true;
private static string ReadJsonArray(string? json) private static string ReadJsonArray(string? json)
{ {
@@ -59,6 +59,8 @@ const match = {
band: "Good", band: "Good",
hasEnoughSignal: true, hasEnoughSignal: true,
hasCareerProfile: true, hasCareerProfile: true,
hasSelectedCv: true,
selectedCvName: "Backend CV",
matchedSkills: ["C#", "SQL"], matchedSkills: ["C#", "SQL"],
missingSkills: ["Kubernetes"], missingSkills: ["Kubernetes"],
relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 present", matched: ["C#"] }], relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 present", matched: ["C#"] }],
@@ -167,7 +169,7 @@ test("match asks for a career profile before showing a score", async () => {
render(<ApplicationMatch jobId={7} />); render(<ApplicationMatch jobId={7} />);
expect(await screen.findByText(/No career profile yet/i)).toBeInTheDocument(); expect(await screen.findByText(/linked CV cannot be matched/i)).toBeInTheDocument();
expect(screen.queryByText("0%")).not.toBeInTheDocument(); expect(screen.queryByText("0%")).not.toBeInTheDocument();
}); });
@@ -31,6 +31,10 @@ jest.mock("./components/ApplicationAssets", () => ({
ApplicationPackageDraftsSection: () => <div>Application drafts section</div>, ApplicationPackageDraftsSection: () => <div>Application drafts section</div>,
})); }));
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> })); jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
jest.mock("./components/ApplicationWorkflowAssist", () => ({
ApplicationStatusSuggestion: () => <div>Status suggestion</div>,
ApplicationStrategySnapshot: () => <div>Strategy section</div>,
}));
const mockedApi = api as jest.Mocked<typeof api>; const mockedApi = api as jest.Mocked<typeof api>;
@@ -167,9 +171,10 @@ test("opens the dedicated workspace from the whole row and preserves list state
expect(await screen.findByText("Backend Developer")).toBeInTheDocument(); expect(await screen.findByText("Backend Developer")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42"); expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42");
fireEvent.click(screen.getByRole("button", { name: "Match" })); fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
expect(await screen.findByText("Analysis section")).toBeInTheDocument();
expect(await screen.findByText("Match section")).toBeInTheDocument(); expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match"); expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis");
fireEvent.click(screen.getByRole("button", { name: /back to applications/i })); fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend")); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend"));
@@ -180,7 +185,10 @@ test("opens the dedicated workspace from the whole row and preserves list state
test("opens a direct workspace URL and returns to applications", async () => { test("opens a direct workspace URL and returns to applications", async () => {
renderTable("/jobs/42?section=match"); renderTable("/jobs/42?section=match");
expect(await screen.findByText("Analysis section")).toBeInTheDocument();
expect(await screen.findByText("Match section")).toBeInTheDocument(); expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByLabelText(/application progress: waiting/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /back to applications/i })); fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs")); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs"));
@@ -208,16 +216,16 @@ test("warns before section navigation would discard application edits", async ()
renderTable("/jobs/42?section=cover-letter"); renderTable("/jobs/42?section=cover-letter");
fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" })); fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" }));
fireEvent.click(screen.getByRole("button", { name: "Match" })); fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument(); expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Keep editing" })); fireEvent.click(screen.getByRole("button", { name: "Keep editing" }));
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter"); expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter");
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Match" })); fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" })); fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match")); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis"));
}); });
test("hydrates list filters, sort and page from a shareable URL", async () => { test("hydrates list filters, sort and page from a shareable URL", async () => {
@@ -1,8 +1,10 @@
import { WORKSPACE_SECTIONS, workspaceSection } from "./applicationWorkspace"; import { WORKSPACE_SECTIONS, workspaceSection } from "./applicationWorkspace";
test("workspace navigation rejects removed and unknown sections", () => { test("workspace navigation exposes five purposes and preserves legacy links", () => {
expect(WORKSPACE_SECTIONS.map((section) => section.key)).not.toContain("portfolio"); expect(WORKSPACE_SECTIONS.map((section) => section.key)).toEqual([
expect(WORKSPACE_SECTIONS.map((section) => section.key)).not.toContain("notes"); "overview", "analysis", "cv", "cover-letter", "interview",
expect(workspaceSection("match")).toBe("match"); ]);
expect(workspaceSection("match")).toBe("analysis");
expect(workspaceSection("timeline")).toBe("overview");
expect(workspaceSection("portfolio")).toBe("overview"); expect(workspaceSection("portfolio")).toBe("overview");
}); });
+17 -11
View File
@@ -79,26 +79,30 @@ export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [
// Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands // Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands
// so the workspace is always usable rather than a shell of placeholders. // so the workspace is always usable rather than a shell of placeholders.
export type WorkspaceSectionKey = export type WorkspaceSectionKey = "overview" | "analysis" | "cv" | "cover-letter" | "interview";
| "overview" | "job-details" | "analysis" | "match" | "checklist" | "cv" | "cover-letter"
| "documents" | "interview" | "timeline" | "communication";
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [
{ key: "overview", label: "Overview" }, { key: "overview", label: "Overview" },
{ key: "job-details", label: "Job Details" },
{ key: "analysis", label: "Analysis" }, { key: "analysis", label: "Analysis" },
{ key: "match", label: "Match" },
{ key: "checklist", label: "Checklist" },
{ key: "cv", label: "CV" }, { key: "cv", label: "CV" },
{ key: "cover-letter", label: "Cover Letter" }, { key: "cover-letter", label: "Cover Letter" },
{ key: "documents", label: "Documents" },
{ key: "interview", label: "Interview Prep" }, { key: "interview", label: "Interview Prep" },
{ key: "timeline", label: "Timeline" },
{ key: "communication", label: "Communication" },
]; ];
export const workspaceSection = (value: string | null): WorkspaceSectionKey => const LEGACY_WORKSPACE_SECTIONS: Record<string, WorkspaceSectionKey> = {
WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview"; match: "analysis",
"job-details": "overview",
checklist: "overview",
documents: "overview",
timeline: "overview",
communication: "overview",
};
/** Preserve old shared/bookmarked workspace links while keeping one clear primary navigation. */
export const workspaceSection = (value: string | null): WorkspaceSectionKey => {
if (value && value in LEGACY_WORKSPACE_SECTIONS) return LEGACY_WORKSPACE_SECTIONS[value];
return WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview";
};
export const applicationWorkspaceApi = { export const applicationWorkspaceApi = {
overview: (jobId: number) => overview: (jobId: number) =>
@@ -158,6 +162,8 @@ export type CareerMatch = {
relevantProjects: MatchEvidence[]; relevantProjects: MatchEvidence[];
suggestions: string[]; suggestions: string[];
aiSuggestionCount: number; aiSuggestionCount: number;
hasSelectedCv: boolean;
selectedCvName: string | null;
}; };
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = { export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
@@ -262,16 +262,21 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
return ( return (
<SectionShell <SectionShell
title="Match" title="CV Match"
subtitle="Your master career profile against this advert. Reads your profile; never changes it." subtitle={data?.selectedCvName
? `${data.selectedCvName} compared with this job advert.`
: "Choose the CV intended for this application before comparing it with the advert."}
loading={loading} loading={loading}
error={error} error={error}
> >
<Stack spacing={2}> <Stack spacing={2}>
{data && !data.hasCareerProfile ? ( {data && !data.hasSelectedCv ? (
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 2 }}>
No career profile yet. Matching compares the advert against your master profile build it Select a CV on the CV tab first. The application will only analyse the document you explicitly link.
once and every application scores against it. </Alert>
) : data && !data.hasCareerProfile ? (
<Alert severity="info" sx={{ borderRadius: 2 }}>
The linked CV cannot be matched until its career profile has content.
</Alert> </Alert>
) : ( ) : (
<> <>
@@ -4,8 +4,8 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { import {
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper, Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, Divider, IconButton, Paper,
Skeleton, Stack, Tooltip, Typography, Skeleton, Stack, Tab, Tabs, Tooltip, Typography,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
@@ -16,11 +16,11 @@ import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import ChecklistIcon from "@mui/icons-material/Checklist"; import ChecklistIcon from "@mui/icons-material/Checklist";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { getApiErrorMessage } from "../api"; import { getApiErrorMessage } from "../api";
import Attachments from "../components/Attachments"; import Attachments from "../components/Attachments";
import Correspondence from "../components/Correspondence"; import Correspondence from "../components/Correspondence";
import AiWorkspacePanel from "../components/AiWorkspacePanel";
import ApplicationChecklist from "../components/ApplicationChecklist"; import ApplicationChecklist from "../components/ApplicationChecklist";
import { import {
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
@@ -31,6 +31,8 @@ import {
import { ApplicationInterviewPrep } from "../components/InterviewPrep"; import { ApplicationInterviewPrep } from "../components/InterviewPrep";
import EditJobDialog from "../components/EditJobDialog"; import EditJobDialog from "../components/EditJobDialog";
import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist"; import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist";
import { PIPELINE_STATUSES, normalizeStatus, statusLabel, statusTone } from "../pipeline";
import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import { import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection, WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection,
@@ -149,9 +151,9 @@ export function ApplicationWorkspace({
} }
return ( return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "220px 1fr" }, gap: 2, alignItems: "start" }}> <Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 1, borderRadius: 3, position: { md: "sticky" }, top: 12 }}> <Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1, py: 0.5 }}> <Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
<Tooltip title="Back to applications"> <Tooltip title="Back to applications">
<IconButton size="small" aria-label="Back to applications" onClick={close}> <IconButton size="small" aria-label="Back to applications" onClick={close}>
<ArrowBackIcon fontSize="small" /> <ArrowBackIcon fontSize="small" />
@@ -176,45 +178,30 @@ export function ApplicationWorkspace({
</Tooltip> </Tooltip>
) : null} ) : null}
</Stack> </Stack>
<List dense component="nav" aria-label="Workspace sections"> <WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
{WORKSPACE_SECTIONS.map((s) => ( <Tabs
<ListItemButton key={s.key} selected={section === s.key} onClick={() => go(s.key)} sx={{ borderRadius: 2 }}> value={section}
<ListItemText onChange={(_, value: WorkspaceSectionKey) => go(value)}
primary={s.label} variant="scrollable"
slotProps={{ primary: { fontSize: 14, fontWeight: section === s.key ? 700 : 500 } }} scrollButtons="auto"
/> aria-label="Workspace sections"
</ListItemButton> sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
))} >
</List> {WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={s.label} sx={{ minHeight: 46, fontWeight: 700 }} />)}
</Tabs>
</Paper> </Paper>
<Box sx={{ display: "grid", gap: 2 }}> <Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />} {section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} />} {section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
{section === "job-details" && <JobDetailsSection overview={overview} onEdit={() => setEditOpen(true)} />} {section === "analysis" && jobId > 0 && (
{/* Deterministic answer first, then the AI panel below it — the page never generates on load. */} <Stack spacing={2}>
{section === "analysis" && jobId > 0 && <ApplicationAnalysis jobId={jobId} />} <ApplicationAnalysis jobId={jobId} />
{section === "analysis" && jobId > 0 && <ApplicationStrategySnapshot jobId={jobId} />} <ApplicationMatch jobId={jobId} />
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />} <ApplicationStrategySnapshot jobId={jobId} />
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />} </Stack>
)}
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />} {section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
{(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<AiWorkspacePanel jobId={jobId} />
</Paper>
)}
{section === "documents" && jobId > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}><Attachments jobId={jobId} /></Paper>
)}
{section === "communication" && jobId > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} />
</Paper>
)}
{section === "checklist" && jobId > 0 && (
<ApplicationChecklist jobId={jobId} onChanged={load} />
)}
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />} {section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
{section === "cover-letter" && jobId > 0 && ( {section === "cover-letter" && jobId > 0 && (
<> <>
@@ -226,8 +213,6 @@ export function ApplicationWorkspace({
onSaved={load} onSaved={load}
onDirtyChange={setPackageDraftsDirty} onDirtyChange={setPackageDraftsDirty}
/> />
{/* Generation stays an explicit user action, below the editor the user owns. */}
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
</> </>
)} )}
</Box> </Box>
@@ -241,9 +226,10 @@ export function ApplicationWorkspace({
); );
} }
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
const { t } = useI18n();
if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>; if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>;
return ( return (
<Paper sx={{ p: 2.5, borderRadius: 3 }}> <Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2.5 }}>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}> <Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography> <Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
@@ -257,8 +243,7 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
<EditOutlinedIcon fontSize="small" /> <EditOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Chip size="small" label={overview.status} color="primary" variant="outlined" /> <Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
<Chip size="small" label={overview.stageGroup} />
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null} {overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
{overview.jobUrl && ( {overview.jobUrl && (
<Tooltip title="Open original advert"> <Tooltip title="Open original advert">
@@ -269,21 +254,59 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
)} )}
</Stack> </Stack>
</Stack> </Stack>
</Paper> <ApplicationProgress status={overview.status} />
</Box>
); );
} }
function OverviewSection({ overview, onGo, onReload }: { function ApplicationProgress({ status }: { status: string }) {
const { t } = useI18n();
const normalized = normalizeStatus(status);
const currentOrder = normalized === "Other" ? Number.MAX_SAFE_INTEGER : PIPELINE_STATUSES.indexOf(normalized) + 1;
const isTerminal = normalized === "Rejected" || normalized === "Ghosted" || normalized === "Withdrawn";
const stages = isTerminal
? [...PIPELINE_STATUSES.filter((stage) => !["Offer", "Rejected", "Ghosted", "Withdrawn"].includes(stage)), normalized]
: PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage));
return (
<Box sx={{ mt: 2.5 }} aria-label={`Application progress: ${statusLabel(t, status)}`}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
Application progress
</Typography>
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
{stages.map((stage, index) => {
const order = PIPELINE_STATUSES.indexOf(stage) + 1;
const current = stage === normalized;
const complete = !current && order < currentOrder;
return (
<Box key={stage} sx={{ display: "grid", gridTemplateColumns: index ? "minmax(30px, 1fr) auto" : "auto", alignItems: "center", minWidth: index ? 110 : 72, flex: 1 }}>
{index > 0 && <Box aria-hidden sx={{ height: 2, bgcolor: complete || current ? "primary.main" : "divider" }} />}
<Stack alignItems="center" spacing={0.5} sx={{ minWidth: 72 }}>
<Box aria-hidden sx={{ width: 12, height: 12, borderRadius: "50%", border: 2, borderColor: current || complete ? "primary.main" : "divider", bgcolor: complete ? "primary.main" : "background.paper", boxShadow: current ? (theme) => `0 0 0 4px ${theme.palette.primary.main}26` : "none" }} />
<Typography variant="caption" sx={{ whiteSpace: "nowrap", fontWeight: current ? 800 : 600, color: current ? "text.primary" : "text.secondary" }}>
{statusLabel(t, stage)}
</Typography>
</Stack>
</Box>
);
})}
</Box>
</Box>
);
}
function OverviewSection({ overview, onGo, onReload, onEdit }: {
overview: WorkspaceOverview | null; overview: WorkspaceOverview | null;
onGo: (s: WorkspaceSectionKey) => void; onGo: (s: WorkspaceSectionKey) => void;
onReload: () => void; onReload: () => void;
onEdit: () => void;
}) { }) {
const stats = useMemo(() => overview ? [ const stats = useMemo(() => overview ? [
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const }, { icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const }, { icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
{ icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const }, { icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "overview" as const },
{ icon: <AutoFixHighIcon fontSize="small" />, label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const }, { icon: <AutoFixHighIcon fontSize="small" />, label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const },
{ icon: <ChecklistIcon fontSize="small" />, label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const }, { icon: <ChecklistIcon fontSize="small" />, label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const },
] : [], [overview]); ] : [], [overview]);
if (!overview) { if (!overview) {
@@ -342,10 +365,34 @@ function OverviewSection({ overview, onGo, onReload }: {
</Stack> </Stack>
)} )}
</Paper> </Paper>
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
</Stack> </Stack>
); );
} }
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
const panels = [
{ id: "details", title: "Job details", content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
{ id: "tasks", title: "Next actions and checklist", content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
{ id: "timeline", title: "Activity history", content: <ApplicationTimeline jobId={jobId} /> },
{ id: "documents", title: "Documents", content: <Attachments jobId={jobId} /> },
{ id: "communication", title: "Communication", content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
];
return (
<Box>
{panels.map((panel) => (
<Accordion key={panel.id} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${panel.id}-content`} id={`${panel.id}-header`}>
<Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography>
</AccordionSummary>
<AccordionDetails id={`${panel.id}-content`} sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
</Accordion>
))}
</Box>
);
}
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
if (!overview) return <Skeleton variant="rounded" height={200} />; if (!overview) return <Skeleton variant="rounded" height={200} />;
const rows: [string, string][] = [ const rows: [string, string][] = [