diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index 16f74c0..0592b72 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -56,6 +56,60 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Contains("Profile page", badRequest.Value?.ToString()); } + [Fact] + public async Task Match_score_scores_job_against_profile_cv() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser + { + Id = "user-1", + UserName = "u", + Email = "u@example.com", + ProfileCvText = "Backend engineer skilled in C#, .NET, SQL and Docker. Built REST APIs.", + }); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Senior C# Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Description = "We need strong C#, .NET, SQL, Docker and REST API experience.", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetMatchScore(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.HasEnoughSignal); + Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}"); + Assert.Contains("C#", dto.MatchedKeywords); + } + + [Fact] + public async Task Match_score_requires_profile_cv() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" }); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "C# .NET" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetMatchScore(job.Id, CancellationToken.None); + + Assert.IsType(result.Result); + } + [Fact] public async Task Create_normalizes_structured_salary() { diff --git a/README.md b/README.md index cd27fae..127e26a 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ Authentication: - Returns a unified timeline combining job events, correspondence, and attachments. - `GET /api/jobapplications/stats` - Returns totals, counts by status, applied-last-30-days, and average days since applied. +- `GET /api/jobapplications/{id}/match-score` + - Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.) - `DELETE /api/jobapplications/{id}` - Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event. - `POST /api/jobapplications/{id}/restore` diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 1b5d144..b7b791f 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -10,6 +10,7 @@ import { DialogTitle, FormControl, InputLabel, + LinearProgress, MenuItem, Select, Tab, @@ -19,7 +20,7 @@ import { } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; -import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types"; +import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, TailoredCvDraft } from "../types"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft"; @@ -130,6 +131,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const { confirmAction } = useDialogActions(); const followUpCache = useWorkspaceTabCache(); const candidateFitCache = useWorkspaceTabCache(); + const matchScoreCache = useWorkspaceTabCache(); const focusPlanCache = useWorkspaceTabCache(); const interviewPrepCache = useWorkspaceTabCache(); const readinessCache = useWorkspaceTabCache(); @@ -168,6 +170,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [sendingDraft, setSendingDraft] = useState(false); const [refreshingAi, setRefreshingAi] = useState(false); const [candidateFit, setCandidateFit] = useState(null); + const [matchScore, setMatchScore] = useState(null); + const [loadingMatchScore, setLoadingMatchScore] = useState(false); const [focusPlan, setFocusPlan] = useState(null); const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); const [loadingFocusPlan, setLoadingFocusPlan] = useState(false); @@ -200,6 +204,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, if (!open || !jobId) return; setFollowUpDraft(null); setCandidateFit(null); + setMatchScore(null); setFocusPlan(null); setInterviewPrep(null); setReadiness(null); @@ -280,6 +285,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, }).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); }, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]); + // Match score is deterministic and cheap: load it on the Candidate Fit tab + // independently of the slow AI narrative so users see the number instantly. + useEffect(() => { + if (!open || !jobId || tab !== 5 || matchScore) return; + const cacheKey = `${jobId}:match-score`; + const cached = matchScoreCache.getCached(cacheKey); + if (cached) { + setMatchScore(cached); + return; + } + + setLoadingMatchScore(true); + api.get(`/jobapplications/${jobId}/match-score`).then((r) => { + matchScoreCache.setCached(cacheKey, r.data); + setMatchScore(r.data); + }).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false)); + }, [open, jobId, tab, matchScore, matchScoreCache]); + useEffect(() => { if (!open || !jobId || tab !== 6 || focusPlan) return; const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`; @@ -1058,6 +1081,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {tab === 5 && ( + {loadingCandidateFit ? : candidateFit ? ( @@ -1136,6 +1160,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, ); } +function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) { + const { t } = useI18n(); + + if (loading && !score) { + return ( + + + {t("matchScoreLoading")} + + ); + } + + if (!score) return null; + + const color: "success" | "warning" | "error" | "inherit" = + !score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error"; + const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band; + + return ( + + + + {score.hasEnoughSignal ? `${score.score}%` : "—"} + {t("matchScoreTitle")} + + + + + + + {score.hasEnoughSignal ? ( + + ) : ( + {t("matchScoreNoSignal")} + )} + {t("matchScoreDeterministicHint")} + + + {t("matchScoreMatched")} + + {score.matchedKeywords.length ? score.matchedKeywords.map((k) => ) : {t("matchScoreNoneYet")}} + + + + {t("matchScoreMissing")} + + {score.missingKeywords.length ? score.missingKeywords.map((k) => ) : {t("matchScoreAllCovered")}} + + + + {score.sectionCoverage.length ? ( + + {t("matchScoreSectionCoverage")} + + {score.sectionCoverage.map((s) => )} + + + ) : null} + + ); +} + function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) { const { t } = useI18n(); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index fdf618f..797b0c6 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -867,6 +867,20 @@ export const translations = { jobDetailsFollowUpSent: "Follow-up sent and logged.", jobDetailsFollowUpSendFailed: "Failed to send follow-up.", jobDetailsHowYouMatch: "How you match", + matchScoreTitle: "Match score", + matchScoreLoading: "Scoring your CV against this role…", + matchScoreBand_Strong: "Strong match", + matchScoreBand_Partial: "Partial match", + matchScoreBand_Low: "Low match", + matchScoreBand_Unknown: "Not enough signal", + matchScoreKeywordsCovered: "{matched}/{total} keywords", + matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.", + matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.", + matchScoreMatched: "Matched keywords", + matchScoreMissing: "Missing keywords", + matchScoreNoneYet: "No matches found yet.", + matchScoreAllCovered: "Every keyword is covered.", + matchScoreSectionCoverage: "Where your CV covers this role", jobDetailsStrategySnapshot: "Strategy snapshot", jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot", jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.", @@ -1784,6 +1798,20 @@ export const translations = { jobDetailsFollowUpSent: "Oppfølging sendt og loggført.", jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.", jobDetailsHowYouMatch: "Slik matcher du", + matchScoreTitle: "Match-score", + matchScoreLoading: "Vurderer CV-en mot denne stillingen…", + matchScoreBand_Strong: "Sterk match", + matchScoreBand_Partial: "Delvis match", + matchScoreBand_Low: "Lav match", + matchScoreBand_Unknown: "For lite grunnlag", + matchScoreKeywordsCovered: "{matched}/{total} nøkkelord", + matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.", + matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.", + matchScoreMatched: "Treff på nøkkelord", + matchScoreMissing: "Manglende nøkkelord", + matchScoreNoneYet: "Ingen treff ennå.", + matchScoreAllCovered: "Alle nøkkelord er dekket.", + matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen", jobDetailsStrategySnapshot: "Strategioversikt", jobDetailsGenerateStrategySnapshot: "Generer strategioversikt", jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.", diff --git a/job-tracker-ui/src/match-score-panel.test.tsx b/job-tracker-ui/src/match-score-panel.test.tsx new file mode 100644 index 0000000..f369a50 --- /dev/null +++ b/job-tracker-ui/src/match-score-panel.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ConfirmProvider } from './confirm'; +import { PromptProvider } from './prompt'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import JobDetailsDialog from './components/JobDetailsDialog'; +import { api } from './api'; + +jest.setTimeout(15000); + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + delete: jest.fn(() => Promise.resolve({ data: {} })), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, +})); + +const mockedApi = api as jest.Mocked; + +const matchScore = { + score: 82, + band: 'Strong', + matchedCount: 4, + totalKeywords: 6, + matchedKeywords: ['C#', '.NET', 'SQL', 'Docker'], + missingKeywords: ['Kubernetes', 'GraphQL'], + sectionCoverage: [ + { section: 'Skills', matched: 4, total: 6 }, + { section: 'Experience', matched: 3, total: 6 }, + ], + hasEnoughSignal: true, +}; + +function renderDialog() { + return render( + + + + + {}} initialTab={5} /> + + + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/match-score') { + return Promise.resolve({ data: matchScore } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + // Candidate-fit AI narrative: leave pending-ish/empty so we only assert on the fast panel. + if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any); + return Promise.resolve({ data: {} } as any); + }); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('match score panel shows the score, matched and missing keywords', async () => { + renderDialog(); + + expect(await screen.findByText('82%')).toBeInTheDocument(); + expect(await screen.findByText(/strong match/i)).toBeInTheDocument(); + expect(await screen.findByText('4/6 keywords')).toBeInTheDocument(); + + // Matched keyword chips + expect(await screen.findByText('C#')).toBeInTheDocument(); + expect(await screen.findByText('Docker')).toBeInTheDocument(); + + // Missing keyword chips + expect(await screen.findByText('Kubernetes')).toBeInTheDocument(); + expect(await screen.findByText('GraphQL')).toBeInTheDocument(); + + // Section coverage + expect(await screen.findByText('Skills: 4/6')).toBeInTheDocument(); +}); + +test('match score panel degrades gracefully when there is not enough signal', async () => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/match-score') { + return Promise.resolve({ data: { ...matchScore, score: 0, band: 'Unknown', matchedCount: 0, matchedKeywords: [], missingKeywords: [], sectionCoverage: [], hasEnoughSignal: false } } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any); + return Promise.resolve({ data: {} } as any); + }); + + renderDialog(); + + expect(await screen.findByText('—')).toBeInTheDocument(); + expect(await screen.findByText(/not enough signal/i)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index fd70fc3..133d417 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -132,6 +132,23 @@ export interface CandidateFitChannelGuidance { recruiterMessage: string[]; } +export interface MatchScoreSectionCoverage { + section: string; + matched: number; + total: number; +} + +export interface MatchScore { + score: number; + band: string; + matchedCount: number; + totalKeywords: number; + matchedKeywords: string[]; + missingKeywords: string[]; + sectionCoverage: MatchScoreSectionCoverage[]; + hasEnoughSignal: boolean; +} + export interface CandidateFit { matchSummary: string; fitLevel: string;