feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history
Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -111,7 +111,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
setStatus(j.status ?? "Applied");
|
||||
setInitialStatus(j.status ?? "Applied");
|
||||
setStatusChangedAt(new Date().toISOString().slice(0, 10));
|
||||
setDateApplied(toDateInputValue(j.dateApplied));
|
||||
setDateApplied(toDateInputValue(j.dateApplied ?? undefined));
|
||||
setLocation(j.location ?? "");
|
||||
setSalary(j.salary ?? "");
|
||||
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
|
||||
|
||||
@@ -7,7 +7,9 @@ import { Company, JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type ImportJob = Omit<JobApplication, "id" | "company"> & {
|
||||
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
|
||||
// row has no business supplying one.
|
||||
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
|
||||
company: Pick<Company, "name" | "location" | "source">;
|
||||
};
|
||||
|
||||
|
||||
@@ -723,8 +723,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("jobDetailsStrategySnapshotEmpty")}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job?.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? "—"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsSalary")}</Typography><Typography>{job?.salary ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsNextAction")}</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box>
|
||||
|
||||
@@ -72,7 +72,9 @@ export default function JobFlowBar({ job, history = [] }: { job: JobApplication
|
||||
const items = useMemo(() => {
|
||||
if (!job) return [] as FlowItem[];
|
||||
|
||||
const appliedAt = new Date(job.dateApplied);
|
||||
// Null for a job that has not been applied to yet: there is no Applied milestone to anchor
|
||||
// the flow on, so the bar simply starts later.
|
||||
const appliedAt = job.dateApplied ? new Date(job.dateApplied) : null;
|
||||
const replyAt = firstResponse(history, job);
|
||||
const interviewAt = firstStatusChange(history, "Interview") || (normalizeStatus(job.status) === "Interview" ? replyAt ?? appliedAt : null);
|
||||
const offerAt = firstStatusChange(history, "Offer") || (normalizeStatus(job.status) === "Offer" ? replyAt ?? interviewAt ?? appliedAt : null);
|
||||
@@ -81,15 +83,17 @@ export default function JobFlowBar({ job, history = [] }: { job: JobApplication
|
||||
|| firstStatusChange(history, "Ghosted")
|
||||
|| ((normalizeStatus(job.status) === "Rejected" || normalizeStatus(job.status) === "Ghosted") ? replyAt ?? appliedAt : null);
|
||||
|
||||
const next: FlowItem[] = [
|
||||
{
|
||||
const next: FlowItem[] = [];
|
||||
|
||||
if (appliedAt) {
|
||||
next.push({
|
||||
key: "applied",
|
||||
label: "Applied",
|
||||
at: appliedAt,
|
||||
color: theme.palette.info.main,
|
||||
icon: <WorkOutlineIcon fontSize="small" />,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (replyAt) {
|
||||
next.push({
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
tags: parseTags(job.tags),
|
||||
actionSignals: actionSignal ? [actionSignal] : [],
|
||||
primaryAction: actionSignal,
|
||||
appliedDateLabel: new Date(job.dateApplied).toLocaleDateString(),
|
||||
appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—",
|
||||
isSelected: selectedIdSet.has(job.id),
|
||||
isExpanded: expanded.includes(job.id),
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
ListSubheader,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
@@ -19,11 +20,25 @@ import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
|
||||
import {
|
||||
GROUP_ENTRY_STATUS,
|
||||
PIPELINE_GROUPS,
|
||||
PIPELINE_STATUSES,
|
||||
PipelineGroup,
|
||||
PipelineStatus,
|
||||
groupOf,
|
||||
normalizeStatus,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
} from "../pipeline";
|
||||
|
||||
const STATUSES = PIPELINE_STATUSES;
|
||||
type Status = PipelineStatus;
|
||||
|
||||
// The board shows one column per group rather than one per stage: ten columns is unreadable, and
|
||||
// the stage detail is preserved on each card's chip. "Other" collects custom statuses, which
|
||||
// belong to no group, and only appears when something is actually in it.
|
||||
type ColumnKey = PipelineGroup | "Other";
|
||||
|
||||
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
||||
error: (theme) => theme.palette.error.main,
|
||||
warning: (theme) => theme.palette.warning.main,
|
||||
@@ -89,22 +104,32 @@ export default function KanbanBoard() {
|
||||
const jobs = jobsResource.data;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, JobApplication[]>();
|
||||
STATUSES.forEach((s) => map.set(s, []));
|
||||
const map = new Map<ColumnKey, JobApplication[]>();
|
||||
PIPELINE_GROUPS.forEach((g) => map.set(g.key, []));
|
||||
map.set("Other", []);
|
||||
for (const j of jobs) {
|
||||
const key = normalizeStatus(j.status);
|
||||
map.get(key)!.push(j);
|
||||
map.get(groupOf(j.status) ?? "Other")!.push(j);
|
||||
}
|
||||
map.forEach((arr, k) => {
|
||||
arr.sort((a, b) => +new Date(b.dateApplied) - +new Date(a.dateApplied));
|
||||
// Sort by stage first so a column reads in pipeline order (Saved before Preparing), then by
|
||||
// recency. Fall back to savedAt: unapplied jobs have no dateApplied, and treating that as
|
||||
// the epoch would sink them to the bottom.
|
||||
arr.sort((a, b) => {
|
||||
const stageDelta = PIPELINE_STATUSES.indexOf(normalizeStatus(a.status) as Status)
|
||||
- PIPELINE_STATUSES.indexOf(normalizeStatus(b.status) as Status);
|
||||
if (stageDelta !== 0) return stageDelta;
|
||||
return +new Date(b.dateApplied ?? b.savedAt) - +new Date(a.dateApplied ?? a.savedAt);
|
||||
});
|
||||
map.set(k, arr);
|
||||
});
|
||||
return map;
|
||||
}, [jobs]);
|
||||
|
||||
const onDropTo = async (status: Status) => {
|
||||
// A group is not a status, so dropping onto a column applies that group's entry stage. Precise
|
||||
// stages stay on the card menu.
|
||||
const onDropTo = async (group: PipelineGroup) => {
|
||||
if (!dragJobId) return;
|
||||
const status = GROUP_ENTRY_STATUS[group];
|
||||
setDragJobId(null);
|
||||
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
|
||||
@@ -117,6 +142,20 @@ export default function KanbanBoard() {
|
||||
|
||||
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
||||
|
||||
// "Other" (custom statuses) only earns a column when something is in it, and is not a drop
|
||||
// target — there is no single custom status to assign.
|
||||
const columns = useMemo(() => {
|
||||
const base: { key: ColumnKey; label: string; droppable: boolean }[] = PIPELINE_GROUPS.map((g) => ({
|
||||
key: g.key,
|
||||
label: t(g.labelKey as any),
|
||||
droppable: true,
|
||||
}));
|
||||
if ((groups.get("Other") ?? []).length > 0) {
|
||||
base.push({ key: "Other", label: t("kanbanGroupOther"), droppable: false });
|
||||
}
|
||||
return base;
|
||||
}, [groups, t]);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
||||
@@ -135,7 +174,7 @@ export default function KanbanBoard() {
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "flex", md: "grid" },
|
||||
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
|
||||
gridTemplateColumns: { md: "repeat(3, 1fr)" },
|
||||
gap: 2,
|
||||
alignItems: "start",
|
||||
overflowX: { xs: "auto", md: "visible" },
|
||||
@@ -144,14 +183,15 @@ export default function KanbanBoard() {
|
||||
"-webkit-overflow-scrolling": "touch",
|
||||
}}
|
||||
>
|
||||
{STATUSES.map((status) => {
|
||||
const c = toneColor(theme, status);
|
||||
const list = groups.get(status) ?? [];
|
||||
{columns.map(({ key, label, droppable }) => {
|
||||
const list = groups.get(key) ?? [];
|
||||
// Colour the column by its entry stage so the header dot still carries meaning.
|
||||
const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : theme.palette.grey[500];
|
||||
return (
|
||||
<Paper
|
||||
key={status}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => void onDropTo(status)}
|
||||
key={key}
|
||||
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
|
||||
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
@@ -166,7 +206,7 @@ export default function KanbanBoard() {
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
{statusLabel(t, status)}
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -189,6 +229,10 @@ export default function KanbanBoard() {
|
||||
{list.map((j) => {
|
||||
const pill = cardPill(j, t);
|
||||
const tags = parseTags(j.tags);
|
||||
// The column is a group, so each card names its own stage — grouping collapses
|
||||
// the columns, not the information.
|
||||
const cardStatus = normalizeStatus(j.status);
|
||||
const cardColor = toneColor(theme, cardStatus);
|
||||
return (
|
||||
<Card
|
||||
key={j.id}
|
||||
@@ -198,7 +242,7 @@ export default function KanbanBoard() {
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
borderRadius: 2.5,
|
||||
borderLeft: `4px solid ${c}`,
|
||||
borderLeft: `4px solid ${cardColor}`,
|
||||
transition: "box-shadow .15s, transform .15s",
|
||||
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
|
||||
"&:active": { cursor: "grabbing" },
|
||||
@@ -224,6 +268,19 @@ export default function KanbanBoard() {
|
||||
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={statusLabel(t, j.status)}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontWeight: 700,
|
||||
backgroundColor: alpha(cardColor, 0.14),
|
||||
color: cardColor,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.75 }}>
|
||||
{tags.map((tag) => (
|
||||
@@ -252,9 +309,11 @@ export default function KanbanBoard() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||
</Typography>
|
||||
{j.daysSince != null ? (
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -271,14 +330,22 @@ export default function KanbanBoard() {
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */}
|
||||
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
|
||||
.filter((s) => s !== currentMenuStatus)
|
||||
.map((s) => (
|
||||
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
|
||||
</MenuItem>
|
||||
))}
|
||||
{PIPELINE_GROUPS.flatMap((g) => {
|
||||
const options = g.statuses.filter((s) => s !== currentMenuStatus);
|
||||
if (options.length === 0) return [];
|
||||
return [
|
||||
<ListSubheader key={`${g.key}-header`} sx={{ lineHeight: "32px", backgroundColor: "transparent" }}>
|
||||
{t(g.labelKey as any)}
|
||||
</ListSubheader>,
|
||||
...options.map((s) => (
|
||||
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
|
||||
</MenuItem>
|
||||
)),
|
||||
];
|
||||
})}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -123,12 +123,19 @@ export const translations = {
|
||||
addJobModalJobCreatedUploadFailed: "Job created, but file upload failed.",
|
||||
addJobModalJobCreatedFilesNotAttached: "Job created. Files could not be attached automatically.",
|
||||
addJobModalFailedAddJob: "Failed to add job.",
|
||||
statusSaved: "Saved",
|
||||
statusInterested: "Interested",
|
||||
statusPreparing: "Preparing",
|
||||
statusApplied: "Applied",
|
||||
statusWaiting: "Waiting",
|
||||
statusInterview: "Interview",
|
||||
statusOffer: "Offer",
|
||||
statusRejected: "Rejected",
|
||||
statusGhosted: "Ghosted",
|
||||
statusWithdrawn: "Withdrawn",
|
||||
pipelineGroupNotApplied: "Not Applied",
|
||||
pipelineGroupActive: "Active",
|
||||
pipelineGroupClosed: "Closed",
|
||||
settingsTitle: "Settings",
|
||||
settingsSubtitle: "Preferences and admin tools.",
|
||||
settingsTabGeneral: "General",
|
||||
@@ -479,8 +486,9 @@ export const translations = {
|
||||
adminUsersDeleteConfirmBody: "Delete this user?",
|
||||
adminUsersDeleteConfirmNamed: "Delete user {name}?",
|
||||
adminUsersPassword: "Password",
|
||||
kanbanHint: "Drag cards between columns to update status.",
|
||||
kanbanHint: "Drag cards between columns to move a job forward. Use the card menu to set an exact stage.",
|
||||
kanbanDropHere: "Drop here",
|
||||
kanbanGroupOther: "Other",
|
||||
kanbanAppliedAgo: "Applied {days}d ago",
|
||||
kanbanFollowUpNow: "Follow up now",
|
||||
kanbanReplyDueIn: "Reply due in {days}d",
|
||||
@@ -1185,12 +1193,19 @@ export const translations = {
|
||||
addJobModalJobCreatedUploadFailed: "Jobben ble opprettet, men filopplasting mislyktes.",
|
||||
addJobModalJobCreatedFilesNotAttached: "Jobben ble opprettet. Filene kunne ikke knyttes automatisk.",
|
||||
addJobModalFailedAddJob: "Kunne ikke legge til jobb.",
|
||||
statusSaved: "Lagret",
|
||||
statusInterested: "Interessert",
|
||||
statusPreparing: "Forbereder",
|
||||
statusApplied: "Søkt",
|
||||
statusWaiting: "Venter",
|
||||
statusInterview: "Intervju",
|
||||
statusOffer: "Tilbud",
|
||||
statusRejected: "Avslått",
|
||||
statusGhosted: "Ghostet",
|
||||
statusWithdrawn: "Trukket",
|
||||
pipelineGroupNotApplied: "Ikke søkt",
|
||||
pipelineGroupActive: "Aktive",
|
||||
pipelineGroupClosed: "Avsluttet",
|
||||
settingsTitle: "Innstillinger",
|
||||
settingsSubtitle: "Preferanser og adminverktøy.",
|
||||
settingsTabGeneral: "Generelt",
|
||||
@@ -1541,8 +1556,9 @@ export const translations = {
|
||||
adminUsersDeleteConfirmBody: "Slette denne brukeren?",
|
||||
adminUsersDeleteConfirmNamed: "Slette bruker {name}?",
|
||||
adminUsersPassword: "Passord",
|
||||
kanbanHint: "Dra kort mellom kolonnene for å oppdatere status.",
|
||||
kanbanHint: "Dra kort mellom kolonnene for å flytte en jobb videre. Bruk kortmenyen for å sette et eksakt trinn.",
|
||||
kanbanDropHere: "Slipp her",
|
||||
kanbanGroupOther: "Andre",
|
||||
kanbanAppliedAgo: "Søkt for {days}d siden",
|
||||
kanbanFollowUpNow: "Følg opp nå",
|
||||
kanbanReplyDueIn: "Svar forfaller om {days}d",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
import { JobApplication } from './types';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import KanbanBoard from './components/KanbanBoard';
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function job(id: number, jobTitle: string, status: string, overrides: Partial<JobApplication> = {}): JobApplication {
|
||||
return {
|
||||
id,
|
||||
jobTitle,
|
||||
company: { id: 1, name: 'Acme' },
|
||||
companyId: 1,
|
||||
status,
|
||||
// Pre-application stages carry no applied date — that is the whole point of the split.
|
||||
dateApplied: null,
|
||||
savedAt: new Date('2026-07-01T00:00:00Z').toISOString(),
|
||||
daysSince: null,
|
||||
...overrides,
|
||||
} as JobApplication;
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<KanbanBoard />
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('the board collapses ten stages into the three agreed groups', async () => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: [
|
||||
job(1, 'Saved Role', 'Saved'),
|
||||
job(2, 'Preparing Role', 'Preparing'),
|
||||
job(3, 'Applied Role', 'Applied', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
|
||||
job(4, 'Offer Role', 'Offer', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
|
||||
job(5, 'Withdrawn Role', 'Withdrawn', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
|
||||
],
|
||||
} as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
await screen.findByText('Not Applied');
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Closed')).toBeInTheDocument();
|
||||
|
||||
// Only the three groups — no "Other" column, because no custom statuses are present.
|
||||
expect(screen.queryByText('Other')).not.toBeInTheDocument();
|
||||
|
||||
// Every job is placed, and grouping does not hide which stage it is actually in.
|
||||
expect(screen.getByText('Saved Role')).toBeInTheDocument();
|
||||
expect(screen.getByText('Withdrawn Role')).toBeInTheDocument();
|
||||
expect(screen.getByText('Withdrawn')).toBeInTheDocument();
|
||||
expect(screen.getByText('Preparing')).toBeInTheDocument();
|
||||
// Offer groups under Active even though it is a Success category on the backend.
|
||||
expect(screen.getByText('Offer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a custom status gets an Other column that is not a drop target', async () => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: [job(1, 'Odd Role', 'Take-home assignment')],
|
||||
} as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
await screen.findByText('Other');
|
||||
// Custom statuses survive rather than being coerced into a canonical stage.
|
||||
expect(screen.getByText('Take-home assignment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('dropping onto a group applies that group entry stage', async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [job(7, 'Saved Role', 'Saved')] } as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
const card = await screen.findByText('Saved Role');
|
||||
const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!;
|
||||
|
||||
fireEvent.dragStart(card.closest('.MuiCard-root')!);
|
||||
fireEvent.dragOver(column);
|
||||
fireEvent.drop(column);
|
||||
|
||||
// A group is not a status, so a coarse drag applies the group's entry stage.
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/7/status', { status: 'Applied' }));
|
||||
});
|
||||
|
||||
test('the card menu offers precise stages grouped by section', async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
await screen.findByText('Saved Role');
|
||||
fireEvent.click(screen.getByRole('button', { name: '' }) ?? screen.getAllByRole('button')[0]);
|
||||
|
||||
const menu = await screen.findByRole('menu');
|
||||
// Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all.
|
||||
expect(within(menu).getByText(/Ghosted/)).toBeInTheDocument();
|
||||
expect(within(menu).getByText(/Withdrawn/)).toBeInTheDocument();
|
||||
// The job's own current stage is not offered as a target.
|
||||
expect(within(menu).queryByText(/Set status: Saved/)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline';
|
||||
import {
|
||||
GROUP_ENTRY_STATUS,
|
||||
PIPELINE_GROUPS,
|
||||
PIPELINE_STATUSES,
|
||||
groupOf,
|
||||
isProspect,
|
||||
normalizeStatus,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
} from './pipeline';
|
||||
|
||||
describe('pipeline', () => {
|
||||
test('normalizeStatus canonicalizes casing and synonyms', () => {
|
||||
@@ -32,6 +41,69 @@ describe('pipeline', () => {
|
||||
});
|
||||
|
||||
test('canonical stage list is stable and ordered', () => {
|
||||
expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']);
|
||||
// Mirrors the backend JobPipeline.Stages. Waiting and Ghosted are retained deliberately:
|
||||
// Ghosted is where the rules engine parks an unanswered job, and Waiting has its own
|
||||
// follow-up rule.
|
||||
expect(PIPELINE_STATUSES).toEqual([
|
||||
'Saved',
|
||||
'Interested',
|
||||
'Preparing',
|
||||
'Applied',
|
||||
'Waiting',
|
||||
'Interview',
|
||||
'Offer',
|
||||
'Rejected',
|
||||
'Ghosted',
|
||||
'Withdrawn',
|
||||
]);
|
||||
});
|
||||
|
||||
test('normalizeStatus canonicalizes the new stage synonyms', () => {
|
||||
expect(normalizeStatus('bookmarked')).toBe('Saved');
|
||||
expect(normalizeStatus('shortlisted')).toBe('Interested');
|
||||
expect(normalizeStatus('drafting')).toBe('Preparing');
|
||||
expect(normalizeStatus('withdrew')).toBe('Withdrawn');
|
||||
expect(normalizeStatus('cancelled')).toBe('Withdrawn');
|
||||
// Opposite directions: the employer declined you vs you pulled out.
|
||||
expect(normalizeStatus('declined')).toBe('Rejected');
|
||||
});
|
||||
|
||||
test('board groups match the agreed layout', () => {
|
||||
expect(PIPELINE_GROUPS.map((g) => g.key)).toEqual(['NotApplied', 'Active', 'Closed']);
|
||||
expect(PIPELINE_GROUPS[0].statuses).toEqual(['Saved', 'Interested', 'Preparing']);
|
||||
expect(PIPELINE_GROUPS[1].statuses).toEqual(['Applied', 'Waiting', 'Interview', 'Offer']);
|
||||
expect(PIPELINE_GROUPS[2].statuses).toEqual(['Rejected', 'Ghosted', 'Withdrawn']);
|
||||
});
|
||||
|
||||
test('every stage belongs to exactly one group, and groups agree with the backend', () => {
|
||||
const grouped = PIPELINE_GROUPS.flatMap((g) => g.statuses);
|
||||
expect(grouped.slice().sort()).toEqual(PIPELINE_STATUSES.slice().sort());
|
||||
expect(new Set(grouped).size).toBe(grouped.length);
|
||||
});
|
||||
|
||||
test('groupOf and isProspect classify stages', () => {
|
||||
expect(groupOf('Saved')).toBe('NotApplied');
|
||||
expect(groupOf('Offer')).toBe('Active');
|
||||
expect(groupOf('Withdrawn')).toBe('Closed');
|
||||
expect(groupOf('Take-home assignment')).toBeNull();
|
||||
|
||||
expect(isProspect('Preparing')).toBe(true);
|
||||
expect(isProspect('Applied')).toBe(false);
|
||||
// Custom statuses predate the split and have always counted as applied.
|
||||
expect(isProspect('Take-home assignment')).toBe(false);
|
||||
});
|
||||
|
||||
test('drag entry stages never infer Ghosted or Withdrawn', () => {
|
||||
// A coarse drag must not claim the rules engine's conclusion (Ghosted) or the user's own
|
||||
// action (Withdrawn).
|
||||
expect(GROUP_ENTRY_STATUS).toEqual({ NotApplied: 'Saved', Active: 'Applied', Closed: 'Rejected' });
|
||||
});
|
||||
|
||||
test('statusTone covers every stage', () => {
|
||||
expect(statusTone('Saved')).toBe('default');
|
||||
expect(statusTone('Withdrawn')).toBe('error');
|
||||
for (const s of PIPELINE_STATUSES) {
|
||||
expect(typeof statusTone(s)).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,98 @@
|
||||
// Single frontend source of truth for the canonical job pipeline.
|
||||
// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync.
|
||||
// The backend serves the same shape from GET /jobapplications/pipeline (key/order/category/group).
|
||||
|
||||
export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
/**
|
||||
* Detailed internal stages, in pipeline order. The board does not show ten columns — it groups
|
||||
* these via PIPELINE_GROUPS below.
|
||||
*
|
||||
* Waiting and Ghosted are retained deliberately: Ghosted is where the rules engine parks a job
|
||||
* that was never answered (neither Rejected nor Withdrawn), and Waiting carries its own follow-up
|
||||
* rule and reminder wording.
|
||||
*/
|
||||
export const PIPELINE_STATUSES = [
|
||||
"Saved",
|
||||
"Interested",
|
||||
"Preparing",
|
||||
"Applied",
|
||||
"Waiting",
|
||||
"Interview",
|
||||
"Offer",
|
||||
"Rejected",
|
||||
"Ghosted",
|
||||
"Withdrawn",
|
||||
] as const;
|
||||
|
||||
export type PipelineStatus = (typeof PIPELINE_STATUSES)[number];
|
||||
|
||||
export type PipelineGroup = "NotApplied" | "Active" | "Closed";
|
||||
|
||||
/**
|
||||
* How the board collapses stages. A different axis from the backend's analytics "category":
|
||||
* Offer is category Success there, but groups under Active here because it is still being worked.
|
||||
*/
|
||||
export const PIPELINE_GROUPS: { key: PipelineGroup; labelKey: string; statuses: PipelineStatus[] }[] = [
|
||||
{ key: "NotApplied", labelKey: "pipelineGroupNotApplied", statuses: ["Saved", "Interested", "Preparing"] },
|
||||
{ key: "Active", labelKey: "pipelineGroupActive", statuses: ["Applied", "Waiting", "Interview", "Offer"] },
|
||||
{ key: "Closed", labelKey: "pipelineGroupClosed", statuses: ["Rejected", "Ghosted", "Withdrawn"] },
|
||||
];
|
||||
|
||||
const GROUP_BY_STATUS = new Map<PipelineStatus, PipelineGroup>(
|
||||
PIPELINE_GROUPS.flatMap((g) => g.statuses.map((s) => [s, g.key] as const)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Stage a job lands on when dragged onto a grouped column. A group is not itself a status, so a
|
||||
* coarse drag has to pick one: the group's entry stage. Precise stages stay available on the
|
||||
* card menu.
|
||||
*
|
||||
* Closed deliberately enters on Rejected rather than Ghosted or Withdrawn: Ghosted is something the
|
||||
* rules engine concludes, and Withdrawn is a specific claim about the user's own action. Neither
|
||||
* should be inferred from a drag.
|
||||
*/
|
||||
export const GROUP_ENTRY_STATUS: Record<PipelineGroup, PipelineStatus> = {
|
||||
NotApplied: "Saved",
|
||||
Active: "Applied",
|
||||
Closed: "Rejected",
|
||||
};
|
||||
|
||||
/** The board group a status belongs to. Unknown/custom statuses have no group. */
|
||||
export function groupOf(status?: string | null): PipelineGroup | null {
|
||||
const normalized = normalizeStatus(status);
|
||||
return normalized === "Other" ? null : GROUP_BY_STATUS.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
/** True for pre-application stages — nothing has been submitted yet. */
|
||||
export function isProspect(status?: string | null): boolean {
|
||||
return groupOf(status) === "NotApplied";
|
||||
}
|
||||
|
||||
export type StatusTone = "primary" | "info" | "success" | "warning" | "error" | "default";
|
||||
|
||||
// Legacy/synonym spellings collapse onto a canonical stage (matches the backend alias map).
|
||||
const ALIASES: Record<string, PipelineStatus> = {
|
||||
bookmarked: "Saved",
|
||||
wishlist: "Saved",
|
||||
"to apply": "Saved",
|
||||
shortlisted: "Interested",
|
||||
considering: "Interested",
|
||||
"in preparation": "Preparing",
|
||||
"preparing application": "Preparing",
|
||||
drafting: "Preparing",
|
||||
interviewing: "Interview",
|
||||
interviews: "Interview",
|
||||
interviewed: "Interview",
|
||||
"in interview": "Interview",
|
||||
declined: "Rejected",
|
||||
"no response": "Ghosted",
|
||||
"no reply": "Ghosted",
|
||||
pending: "Waiting",
|
||||
awaiting: "Waiting",
|
||||
"in progress": "Waiting",
|
||||
"awaiting response": "Waiting",
|
||||
withdrew: "Withdrawn",
|
||||
cancelled: "Withdrawn",
|
||||
canceled: "Withdrawn",
|
||||
};
|
||||
|
||||
/** Canonical status for a raw value, or "Other" for unknown/custom statuses. */
|
||||
@@ -35,6 +111,7 @@ export function statusTone(status?: string | null): StatusTone {
|
||||
case "Offer":
|
||||
return "success";
|
||||
case "Rejected":
|
||||
case "Withdrawn":
|
||||
return "error";
|
||||
case "Waiting":
|
||||
case "Ghosted":
|
||||
@@ -43,18 +120,27 @@ export function statusTone(status?: string | null): StatusTone {
|
||||
return "info";
|
||||
case "Applied":
|
||||
return "primary";
|
||||
// Pre-application stages read as neutral: nothing is in flight, so nothing needs attention.
|
||||
case "Saved":
|
||||
case "Interested":
|
||||
case "Preparing":
|
||||
return "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
const LABEL_KEYS: Record<PipelineStatus, string> = {
|
||||
Saved: "statusSaved",
|
||||
Interested: "statusInterested",
|
||||
Preparing: "statusPreparing",
|
||||
Applied: "statusApplied",
|
||||
Waiting: "statusWaiting",
|
||||
Interview: "statusInterview",
|
||||
Offer: "statusOffer",
|
||||
Rejected: "statusRejected",
|
||||
Ghosted: "statusGhosted",
|
||||
Withdrawn: "statusWithdrawn",
|
||||
};
|
||||
|
||||
/** Localized label for a status, falling back to the raw value for custom statuses. */
|
||||
|
||||
@@ -86,7 +86,12 @@ export interface JobApplication {
|
||||
company: Company;
|
||||
companyId?: number;
|
||||
status: string;
|
||||
dateApplied: string;
|
||||
// Null while the job sits in a pre-application stage (Saved/Interested/Preparing): nothing has
|
||||
// been submitted, so there is no applied date. Render "—", never a fabricated date.
|
||||
dateApplied: string | null;
|
||||
// When the user captured the job. Always set — use it wherever a job needs a date to sort or
|
||||
// show regardless of whether it has been applied to.
|
||||
savedAt: string;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
salaryMin?: number | null;
|
||||
@@ -118,7 +123,8 @@ export interface JobApplication {
|
||||
hasPortfolio?: boolean;
|
||||
hasOtherAttachment?: boolean;
|
||||
|
||||
daysSince: number;
|
||||
// Null when dateApplied is null — there is no elapsed time to report before applying.
|
||||
daysSince: number | null;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
needsFollowUp?: boolean;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Alert, Box, Paper, Typography } from "@mui/material";
|
||||
import { Alert, Box, Paper, Tab, Tabs, Typography } from "@mui/material";
|
||||
|
||||
import ProfilePage from "./ProfilePage";
|
||||
|
||||
export default function CareerWorkspacePage() {
|
||||
const [tab, setTab] = useState<"master" | "builder">("master");
|
||||
const [hasMasterCv, setHasMasterCv] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
@@ -16,7 +19,18 @@ export default function CareerWorkspacePage() {
|
||||
<Alert severity="info" sx={{ borderRadius: 3 }}>
|
||||
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
|
||||
</Alert>
|
||||
<ProfilePage careerOnly />
|
||||
<Paper sx={{ borderRadius: 4, overflow: "hidden", boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Tabs value={tab} onChange={(_, value) => setTab(value)} variant="scrollable" allowScrollButtonsMobile sx={{ px: 1.5, pt: 1 }}>
|
||||
<Tab value="master" label="Master CV" />
|
||||
<Tab value="builder" label="CV Builder" disabled={!hasMasterCv} />
|
||||
</Tabs>
|
||||
{!hasMasterCv ? <Alert severity="info" sx={{ mx: 2.5, mb: 0, borderRadius: 3 }}>
|
||||
Create your Master CV first. Upload an existing CV or add your career history manually; the builder will unlock when the profile has content.
|
||||
</Alert> : null}
|
||||
<Box sx={{ p: { xs: 1.5, md: 2.5 } }}>
|
||||
<ProfilePage careerOnly careerView={tab} onMasterCvAvailabilityChange={setHasMasterCv} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,7 +226,15 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata })
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage({ careerOnly = false }: { careerOnly?: boolean }) {
|
||||
export default function ProfilePage({
|
||||
careerOnly = false,
|
||||
careerView = "master",
|
||||
onMasterCvAvailabilityChange,
|
||||
}: {
|
||||
careerOnly?: boolean;
|
||||
careerView?: "master" | "builder";
|
||||
onMasterCvAvailabilityChange?: (hasMasterCv: boolean) => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -22,6 +22,7 @@ function buildJob(overrides: Partial<JobApplication>): JobApplication {
|
||||
companyId: 1,
|
||||
status: 'Waiting',
|
||||
dateApplied: new Date('2026-03-01T00:00:00Z').toISOString(),
|
||||
savedAt: new Date('2026-03-01T00:00:00Z').toISOString(),
|
||||
location: 'Oslo',
|
||||
salary: undefined,
|
||||
nextAction: undefined,
|
||||
|
||||
Reference in New Issue
Block a user