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>
);