feat(ui): instant match-score panel on the Candidate Fit tab

Adds a MatchScoreCard at the top of the Candidate Fit tab that loads the
deterministic /match-score endpoint independently of the slow AI
narrative, so users see a reproducible score, matched/missing keyword
chips, and per-section coverage immediately.

- MatchScore types + cached, attachment-independent load effect
- graceful 'not enough signal' state
- EN/NB translations
- frontend panel test (matched/missing/section + degraded state)
- backend integration tests for GetMatchScore (happy path + missing CV)
- README endpoint reference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-03 03:24:53 +02:00
parent 3fad43a9e2
commit 209528c8b5
6 changed files with 306 additions and 1 deletions
@@ -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<FollowUpDraft | null>();
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
const matchScoreCache = useWorkspaceTabCache<MatchScore | null>();
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
@@ -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<CandidateFit | null>(null);
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(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<MatchScore>(`/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 && (
<Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
@@ -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 (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={18} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
</Box>
);
}
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 (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
</Box>
{score.hasEnoughSignal ? (
<LinearProgress
variant="determinate"
value={score.score}
color={color === "inherit" ? "primary" : color}
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
/>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
)}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
</Box>
</Box>
<Box>
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
</Box>
</Box>
</Box>
{score.sectionCoverage.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
</Box>
</Box>
) : null}
</Box>
);
}
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
const { t } = useI18n();
+28
View File
@@ -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.",
@@ -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<typeof api>;
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(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={5} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
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();
});
+17
View File
@@ -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;