feat(workspace): add application intelligence
Phase 5.3. Three read-only reads that answer "how suitable is this job", "how
does my experience match", "what am I missing", "what happened previously".
Timeline (GET /{id}/timeline) is an interpretation layer over JobEvent, which
stays the source of historical truth. Each row gains a readable summary, a
category and a milestone flag; events group by day. Milestones are returned
unfiltered, because narrowing the detail must not hide what actually happened.
Job analysis (GET /{id}/analysis) extracts role, company, location, employment
type, seniority, salary, technologies, skills, responsibilities and keywords
from the advert, reusing the existing SkillTagger so the vocabulary matches the
job importer. It also reports what the advert does NOT say, which is usually the
more useful half.
Career matching (GET /{id}/match) feeds the master CareerProfile into the same
JobCvMatchService the CV builder uses, so one application scores identically
whichever surface asks. It returns the score, matched and missing skills, and
which experience and project entries are the evidence for each match.
All three are deterministic and own no data — no new table, no new column, and
nothing writes to the CareerProfile, a CvVariant, or the JobApplication. The AI
narrative stays where it already was, in AiWorkspaceService's job-analysis and
career-match modules, generated only when the user asks and versioned by the
append-only AiInteraction history. Opening a section costs nothing and changes
nothing.
Frontend adds Timeline, Analysis and Match sections to the workspace, sharing
one loader so loading, empty and error states are consistent. The deterministic
answer renders first, with the AI panel below it.
345 backend tests, 104 frontend tests, type check, production build all pass
locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "./components/ApplicationIntelligence";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const timeline = {
|
||||
days: [
|
||||
{
|
||||
date: "2026-07-19",
|
||||
label: "Today",
|
||||
events: [
|
||||
{ id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" },
|
||||
{ id: 3, type: "AiRefreshed", category: "ai", summary: "AI suggestions refreshed", detail: null, isMilestone: false, at: "2026-07-19T08:00:00Z" },
|
||||
],
|
||||
},
|
||||
],
|
||||
milestones: [
|
||||
{ id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" },
|
||||
],
|
||||
categories: ["ai", "stage"],
|
||||
totalEvents: 2,
|
||||
};
|
||||
|
||||
const analysis = {
|
||||
role: "Senior Backend Developer",
|
||||
company: "Acme",
|
||||
location: "Oslo",
|
||||
employmentType: "Full-time",
|
||||
seniority: "Senior",
|
||||
salary: null,
|
||||
technologies: ["C#", ".NET"],
|
||||
skills: ["Collaboration"],
|
||||
responsibilities: ["Build and operate REST APIs"],
|
||||
keywords: ["C#", ".NET", "Collaboration"],
|
||||
summary: "Senior Backend Developer at Acme · Oslo.",
|
||||
importantRequirements: ["Strong experience with C# and .NET"],
|
||||
interviewTopics: ["C#", ".NET"],
|
||||
missingInformation: ["Salary or compensation range"],
|
||||
hasJobDescription: true,
|
||||
aiSuggestionCount: 0,
|
||||
};
|
||||
|
||||
const match = {
|
||||
score: 72,
|
||||
band: "Good",
|
||||
hasEnoughSignal: true,
|
||||
hasCareerProfile: true,
|
||||
matchedSkills: ["C#", "SQL"],
|
||||
missingSkills: ["Kubernetes"],
|
||||
relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }],
|
||||
relevantProjects: [],
|
||||
suggestions: ["Solid match. Lead with the matched skills."],
|
||||
aiSuggestionCount: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ---------- Timeline ----------
|
||||
|
||||
test("timeline renders grouped days, milestones and readable summaries", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: timeline } as any);
|
||||
|
||||
render(<ApplicationTimeline jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("Milestones")).toBeInTheDocument();
|
||||
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Moved from Applied to Interview").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("AI suggestions refreshed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("timeline filters by category", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: timeline } as any);
|
||||
|
||||
render(<ApplicationTimeline jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "AI" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith(
|
||||
"/jobapplications/7/timeline",
|
||||
{ params: { category: "ai", milestonesOnly: undefined } },
|
||||
));
|
||||
});
|
||||
|
||||
test("timeline shows an empty state when nothing has happened", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { days: [], milestones: [], categories: [], totalEvents: 0 } } as any);
|
||||
|
||||
render(<ApplicationTimeline jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Nothing has happened yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("timeline surfaces an error instead of rendering nothing", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||||
|
||||
render(<ApplicationTimeline jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------- Analysis ----------
|
||||
|
||||
test("analysis renders the extracted structure", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: analysis } as any);
|
||||
|
||||
render(<ApplicationAnalysis jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("Senior Backend Developer at Acme · Oslo.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Full-time")).toBeInTheDocument();
|
||||
expect(screen.getByText("Strong experience with C# and .NET")).toBeInTheDocument();
|
||||
expect(screen.getByText("Salary or compensation range")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("analysis prompts for the advert when there is none", async () => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: { ...analysis, hasJobDescription: false, technologies: [], responsibilities: [], importantRequirements: [] },
|
||||
} as any);
|
||||
|
||||
render(<ApplicationAnalysis jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/No advert text saved yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("analysis shows a loading state before the data arrives", () => {
|
||||
mockedApi.get.mockReturnValue(new Promise(() => {}) as any);
|
||||
|
||||
const { container } = render(<ApplicationAnalysis jobId={7} />);
|
||||
|
||||
expect(container.querySelectorAll(".MuiSkeleton-root").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ---------- Match ----------
|
||||
|
||||
test("match renders the score, evidence and suggestions", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: match } as any);
|
||||
|
||||
render(<ApplicationMatch jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("72%")).toBeInTheDocument();
|
||||
expect(screen.getByText("Good")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend Developer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kubernetes")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Solid match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("match asks for a career profile before showing a score", async () => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: {
|
||||
...match, score: 0, band: "No profile", hasCareerProfile: false,
|
||||
matchedSkills: [], missingSkills: [], relevantExperience: [],
|
||||
suggestions: ["Build your career profile first."],
|
||||
},
|
||||
} as any);
|
||||
|
||||
render(<ApplicationMatch jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/No career profile yet/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("0%")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("match warns when the advert is too short to score", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { ...match, hasEnoughSignal: false } } as any);
|
||||
|
||||
render(<ApplicationMatch jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/too short to score reliably/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -84,7 +84,7 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile
|
||||
{ key: "portfolio", label: "Portfolio", milestone: 8 },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "interview", label: "Interview Prep" },
|
||||
{ key: "timeline", label: "Timeline", milestone: 3 },
|
||||
{ key: "timeline", label: "Timeline" },
|
||||
{ key: "notes", label: "Notes", milestone: 3 },
|
||||
{ key: "communication", label: "Communication" },
|
||||
];
|
||||
@@ -94,6 +94,81 @@ export const applicationWorkspaceApi = {
|
||||
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Phase 5.3 — Application Intelligence. All three reads are deterministic and read-only; the AI
|
||||
// narrative stays on the existing /ai routes, which require the user to ask for it.
|
||||
export type TimelineEvent = {
|
||||
id: number;
|
||||
type: string;
|
||||
category: string;
|
||||
summary: string;
|
||||
detail: string | null;
|
||||
isMilestone: boolean;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type TimelineDay = { date: string; label: string; events: TimelineEvent[] };
|
||||
|
||||
export type Timeline = {
|
||||
days: TimelineDay[];
|
||||
milestones: TimelineEvent[];
|
||||
categories: string[];
|
||||
totalEvents: number;
|
||||
};
|
||||
|
||||
export type JobAnalysis = {
|
||||
role: string | null;
|
||||
company: string | null;
|
||||
location: string | null;
|
||||
employmentType: string | null;
|
||||
seniority: string | null;
|
||||
salary: string | null;
|
||||
technologies: string[];
|
||||
skills: string[];
|
||||
responsibilities: string[];
|
||||
keywords: string[];
|
||||
summary: string;
|
||||
importantRequirements: string[];
|
||||
interviewTopics: string[];
|
||||
missingInformation: string[];
|
||||
hasJobDescription: boolean;
|
||||
aiSuggestionCount: number;
|
||||
};
|
||||
|
||||
export type MatchEvidence = { title: string; subtitle: string | null; matched: string[] };
|
||||
|
||||
export type CareerMatch = {
|
||||
score: number;
|
||||
band: string;
|
||||
hasEnoughSignal: boolean;
|
||||
hasCareerProfile: boolean;
|
||||
matchedSkills: string[];
|
||||
missingSkills: string[];
|
||||
relevantExperience: MatchEvidence[];
|
||||
relevantProjects: MatchEvidence[];
|
||||
suggestions: string[];
|
||||
aiSuggestionCount: number;
|
||||
};
|
||||
|
||||
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
|
||||
lifecycle: "Lifecycle",
|
||||
stage: "Stage",
|
||||
"follow-up": "Follow-up",
|
||||
communication: "Communication",
|
||||
ai: "AI",
|
||||
};
|
||||
|
||||
export const applicationIntelligenceApi = {
|
||||
timeline: (jobId: number, category?: string, milestonesOnly?: boolean) =>
|
||||
api
|
||||
.get<Timeline>(`/jobapplications/${jobId}/timeline`, {
|
||||
params: { category: category || undefined, milestonesOnly: milestonesOnly || undefined },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
analysis: (jobId: number) =>
|
||||
api.get<JobAnalysis>(`/jobapplications/${jobId}/analysis`).then((r) => r.data),
|
||||
match: (jobId: number) => api.get<CareerMatch>(`/jobapplications/${jobId}/match`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const applicationChecklistApi = {
|
||||
get: (jobId: number) =>
|
||||
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert, Box, Chip, Divider, LinearProgress, Paper, Skeleton, Stack, ToggleButton,
|
||||
ToggleButtonGroup, Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import {
|
||||
CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi,
|
||||
} from "../applicationWorkspace";
|
||||
|
||||
// Phase 5.3 — Application Intelligence sections for the workspace.
|
||||
//
|
||||
// Everything here renders a deterministic, read-only backend answer. No component triggers an AI
|
||||
// generation: that stays an explicit user action in AiWorkspacePanel, so nothing on this page can
|
||||
// silently spend a token or change the user's data.
|
||||
// docs/architecture/application-workspace.md.
|
||||
|
||||
// One loader for all three sections: same fetch/loading/empty/error shape, so the sections stay
|
||||
// consistent and each one is just its own rendering.
|
||||
function useIntelligence<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const run = useCallback(load, deps);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
run()
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setData(result);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section."));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run]);
|
||||
|
||||
return { data, error, loading };
|
||||
}
|
||||
|
||||
function SectionShell({ title, subtitle, loading, error, empty, emptyText, children }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
empty?: boolean;
|
||||
emptyText?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
|
||||
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
{loading ? (
|
||||
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={40} />)}</Stack>
|
||||
) : error ? (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
) : empty ? (
|
||||
<Typography variant="body2" color="text.secondary">{emptyText}</Typography>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function Chips({ label, values, color }: { label: string; values: string[]; color?: "success" | "warning" | "default" }) {
|
||||
if (values.length === 0) return null;
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{label}</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.5 }}>
|
||||
{values.map((v) => (
|
||||
<Chip key={v} size="small" label={v} variant="outlined"
|
||||
color={color === "default" ? undefined : color} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Bullets({ label, values }: { label: string; values: string[] }) {
|
||||
if (values.length === 0) return null;
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{label}</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ mt: 0.5, pl: 2.5, mb: 0 }}>
|
||||
{values.map((v, i) => (
|
||||
<Typography key={i} component="li" variant="body2">{v}</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Timeline ----------
|
||||
|
||||
export function ApplicationTimeline({ jobId }: { jobId: number }) {
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const { data, error, loading } = useIntelligence<Timeline>(
|
||||
() => applicationIntelligenceApi.timeline(jobId, category || undefined),
|
||||
[jobId, category],
|
||||
);
|
||||
|
||||
const hasEvents = (data?.totalEvents ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{hasEvents && (data?.milestones.length ?? 0) > 0 && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Milestones</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{(data?.milestones ?? []).map((m) => (
|
||||
<Stack key={m.id} direction="row" spacing={1} justifyContent="space-between" alignItems="baseline">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{m.summary}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(m.at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<SectionShell
|
||||
title="Timeline"
|
||||
subtitle="Everything recorded for this application, newest first."
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={!hasEvents}
|
||||
emptyText="Nothing has happened yet. Activity appears here as you move the application along."
|
||||
>
|
||||
{/* SectionShell takes children as a prop, so this JSX is built before it decides whether to
|
||||
render it — every access has to tolerate a null `data`. */}
|
||||
<Stack spacing={2}>
|
||||
{(data?.categories.length ?? 0) > 1 && (
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={category}
|
||||
onChange={(_e, next) => setCategory(next ?? "")}
|
||||
aria-label="Filter timeline by category"
|
||||
sx={{ flexWrap: "wrap" }}
|
||||
>
|
||||
<ToggleButton value="" aria-label="All events">All</ToggleButton>
|
||||
{(data?.categories ?? []).map((c) => (
|
||||
<ToggleButton key={c} value={c} aria-label={TIMELINE_CATEGORY_LABELS[c] ?? c}>
|
||||
{TIMELINE_CATEGORY_LABELS[c] ?? c}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
|
||||
{(data?.days.length ?? 0) === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No events in this category.</Typography>
|
||||
) : (
|
||||
(data?.days ?? []).map((day) => (
|
||||
<Box key={day.date}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
|
||||
{day.label}
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.75, pl: 1.5, borderLeft: "2px solid", borderColor: "divider" }}>
|
||||
{day.events.map((e) => (
|
||||
<Box key={e.id} sx={{ pl: 1.5 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="body2" sx={{ fontWeight: e.isMilestone ? 700 : 500 }}>
|
||||
{e.summary}
|
||||
</Typography>
|
||||
{e.isMilestone && <Chip size="small" label="Milestone" color="primary" variant="outlined" />}
|
||||
</Stack>
|
||||
{e.detail && (
|
||||
<Typography variant="caption" color="text.secondary">{e.detail}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionShell>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Analysis ----------
|
||||
|
||||
export function ApplicationAnalysis({ jobId }: { jobId: number }) {
|
||||
const { data, error, loading } = useIntelligence<JobAnalysis>(
|
||||
() => applicationIntelligenceApi.analysis(jobId),
|
||||
[jobId],
|
||||
);
|
||||
|
||||
const facts: [string, string | null][] = data
|
||||
? [
|
||||
["Role", data.role],
|
||||
["Company", data.company],
|
||||
["Location", data.location],
|
||||
["Employment type", data.employmentType],
|
||||
["Seniority", data.seniority],
|
||||
["Salary", data.salary],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Analysis"
|
||||
subtitle="Read straight from the advert — no AI, same answer every time."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{data && !data.hasJobDescription && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
No advert text saved yet. Paste it into the application to get requirements, technologies
|
||||
and interview topics.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2">{data?.summary}</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "160px 1fr" }, rowGap: 0.75, columnGap: 2 }}>
|
||||
{facts.map(([k, v]) => (
|
||||
<React.Fragment key={k}>
|
||||
<Typography variant="body2" color="text.secondary">{k}</Typography>
|
||||
<Typography variant="body2">{v ?? "—"}</Typography>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Chips label="Technologies" values={data?.technologies ?? []} />
|
||||
<Chips label="Skills" values={data?.skills ?? []} />
|
||||
<Bullets label="Important requirements" values={data?.importantRequirements ?? []} />
|
||||
<Bullets label="Responsibilities" values={data?.responsibilities ?? []} />
|
||||
<Chips label="Likely interview topics" values={data?.interviewTopics ?? []} />
|
||||
<Bullets label="Not stated in the advert" values={data?.missingInformation ?? []} />
|
||||
</Stack>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Match ----------
|
||||
|
||||
export function ApplicationMatch({ jobId }: { jobId: number }) {
|
||||
const { data, error, loading } = useIntelligence<CareerMatch>(
|
||||
() => applicationIntelligenceApi.match(jobId),
|
||||
[jobId],
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Match"
|
||||
subtitle="Your master career profile against this advert. Reads your profile; never changes it."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{data && !data.hasCareerProfile ? (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
No career profile yet. Matching compares the advert against your master profile — build it
|
||||
once and every application scores against it.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline">
|
||||
<Typography variant="h4" sx={{ fontWeight: 900 }}>{data?.score}%</Typography>
|
||||
<Chip size="small" label={data?.band} variant="outlined" />
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={data?.score ?? 0}
|
||||
aria-label="Career match score"
|
||||
sx={{ mt: 1, height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{data && !data.hasEnoughSignal && (
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
The advert is too short to score reliably. Paste the full text for a real match.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Chips label="Matched" values={data?.matchedSkills ?? []} color="success" />
|
||||
<Chips label="Missing" values={data?.missingSkills ?? []} color="warning" />
|
||||
|
||||
{(data?.relevantExperience.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
||||
Relevant experience
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.5 }}>
|
||||
{(data?.relevantExperience ?? []).map((e, i) => (
|
||||
<Box key={i}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{e.title}</Typography>
|
||||
{e.subtitle && <Typography variant="caption" color="text.secondary">{e.subtitle}</Typography>}
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.5 }}>
|
||||
{e.matched.map((k) => <Chip key={k} size="small" label={k} variant="outlined" />)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(data?.relevantProjects.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
||||
Relevant projects
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.5 }}>
|
||||
{(data?.relevantProjects ?? []).map((p, i) => (
|
||||
<Box key={i}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
|
||||
{p.subtitle && <Typography variant="caption" color="text.secondary">{p.subtitle}</Typography>}
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.5 }}>
|
||||
{p.matched.map((k) => <Chip key={k} size="small" label={k} variant="outlined" />)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Bullets label="Suggestions" values={data?.suggestions ?? []} />
|
||||
</Stack>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import Attachments from "../components/Attachments";
|
||||
import Correspondence from "../components/Correspondence";
|
||||
import AiWorkspacePanel from "../components/AiWorkspacePanel";
|
||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "../components/ApplicationIntelligence";
|
||||
import {
|
||||
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
|
||||
} from "../applicationWorkspace";
|
||||
@@ -92,6 +95,10 @@ export default function ApplicationWorkspacePage() {
|
||||
<WorkspaceHeader overview={overview} />
|
||||
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} />}
|
||||
{section === "job-details" && <JobDetailsSection overview={overview} />}
|
||||
{/* Deterministic answer first, then the AI panel below it — the page never generates on load. */}
|
||||
{section === "analysis" && jobId > 0 && <ApplicationAnalysis jobId={jobId} />}
|
||||
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />}
|
||||
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
|
||||
{(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<AiWorkspacePanel jobId={jobId} />
|
||||
@@ -106,7 +113,7 @@ export default function ApplicationWorkspacePage() {
|
||||
{section === "checklist" && jobId > 0 && (
|
||||
<ApplicationChecklist jobId={jobId} onChanged={load} />
|
||||
)}
|
||||
{["cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && (
|
||||
{["cv", "cover-letter", "portfolio", "notes"].includes(section) && (
|
||||
<ComingInMilestone section={section} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user