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:
cesnimda
2026-07-17 17:05:25 +02:00
parent b176a44627
commit eac34705e3
36 changed files with 3060 additions and 96 deletions
@@ -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>
+9 -5
View File
@@ -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({
+1 -1
View File
@@ -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),
};
+94 -27
View File
@@ -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>
);
+18 -2
View File
@@ -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();
});
+74 -2
View File
@@ -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');
}
});
});
+87 -1
View File
@@ -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. */
+8 -2
View File
@@ -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>
);
}
+9 -1
View File
@@ -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,