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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user