// 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). /** * 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( 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 = { 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 = { 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. */ export function normalizeStatus(status?: string | null): PipelineStatus | "Other" { const trimmed = (status ?? "").trim(); if (!trimmed) return "Applied"; const exact = PIPELINE_STATUSES.find((s) => s.toLowerCase() === trimmed.toLowerCase()); if (exact) return exact; const alias = ALIASES[trimmed.toLowerCase()]; return alias ?? "Other"; } /** MUI palette key for a status; both chip color and board accent derive from this. */ export function statusTone(status?: string | null): StatusTone { switch (normalizeStatus(status)) { case "Offer": return "success"; case "Rejected": case "Withdrawn": return "error"; case "Waiting": case "Ghosted": return "warning"; case "Interview": 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 = { 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. */ export function statusLabel(t: (key: any, params?: any) => string, status: string): string { const normalized = normalizeStatus(status); return normalized === "Other" ? status : t(LABEL_KEYS[normalized]); }