diff --git a/README.md b/README.md index 127e26a..9a9cd89 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,9 @@ Authentication: - Updates an application; records a `StatusChanged` event if the status changed. - `PATCH /api/jobapplications/{id}/status` - Body: `{ "status": "..." }` - - Updates only status; records `StatusChanged` if it changed. + - Updates only status; records `StatusChanged` if it changed. The status is normalized against the canonical pipeline (casing + known synonyms like `Interviewing`→`Interview`); unrecognized values are preserved as custom statuses. +- `GET /api/jobapplications/pipeline` + - Returns the canonical ordered pipeline stages (`Applied, Waiting, Interview, Offer, Rejected, Ghosted`) with display order and category (`Active`/`Success`/`Closed`). The UI renders board columns and status dropdowns from this single source of truth. - `PATCH /api/jobapplications/{id}/followup` - Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`) - Sets/clears follow-up date; records a `FollowUpSet` event. diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index c0668d9..c5592bf 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -30,6 +30,7 @@ import { Company, JobImportResult } from "../types"; import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline"; import TagsInput from "./TagsInput"; interface Props { @@ -60,7 +61,6 @@ type CreatedJobResponse = { type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other"; type AttachmentBuckets = Record; -const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } }; const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; @@ -115,7 +115,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const [dateApplied, setDateApplied] = useState(() => getTodayIso()); const [jobTitle, setJobTitle] = useState(""); - const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied"); + const [status, setStatus] = useState<(typeof PIPELINE_STATUSES)[number]>("Applied"); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); const [salaryMin, setSalaryMin] = useState(""); @@ -350,18 +350,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { })); }; - const statusLabel = (value: typeof STATUS_OPTIONS[number]) => { - const map = { - Applied: t("statusApplied"), - Waiting: t("statusWaiting"), - Interview: t("statusInterview"), - Offer: t("statusOffer"), - Rejected: t("statusRejected"), - Ghosted: t("statusGhosted"), - } as const; - return map[value]; - }; - const filesLabel = (files: File[]) => { if (files.length === 0) return t("addJobModalNoFilesSelected"); if (files.length === 1) return files[0].name; @@ -479,9 +467,9 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { /> setStatus(e.target.value as any)} sx={FIELD_SX}> - {STATUS_OPTIONS.map((s) => ( + {PIPELINE_STATUSES.map((s) => ( - {statusLabel(s)} + {pipelineStatusLabel(t, s)} ))} diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index df0765b..a4f7102 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -24,6 +24,7 @@ import { useToast } from "../toast"; import { useCompanies } from "../hooks/useCompanies"; import TagsInput from "./TagsInput"; import { useI18n } from "../i18n/I18nProvider"; +import { PIPELINE_STATUSES, statusLabel } from "../pipeline"; interface Props { open: boolean; @@ -32,7 +33,6 @@ interface Props { onSaved: () => void; } -const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } }; const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; @@ -207,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) {t("editJobStatusUpdate")} setStatus(e.target.value)} sx={FIELD_SX}> - {STATUS_OPTIONS.map((s) => {s})} + {PIPELINE_STATUSES.map((s) => {statusLabel(t, s)})} setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} /> setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /> diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index d0586ce..08518ce 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -45,6 +45,7 @@ import ViewStateNotice from "./ViewStateNotice"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { formatSalary } from "../salary"; +import { statusLabel, statusTone } from "../pipeline"; import JobDetailsDialog from "./JobDetailsDialog"; import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; @@ -98,10 +99,6 @@ interface Props { mode?: "jobs" | "trash"; } -function normalizeStatus(status: string): string { - return status === "Interviewing" ? "Interview" : status; -} - function parseTags(raw?: string | null): string[] { if (!raw) return []; try { @@ -112,21 +109,6 @@ function parseTags(raw?: string | null): string[] { } } -function statusTone(status: string): string { - switch (normalizeStatus(status)) { - case "Offer": - return "success"; - case "Rejected": - return "error"; - case "Waiting": - case "Ghosted": - return "warning"; - case "Interview": - return "info"; - default: - return "primary"; - } -} function generateOverview(job: JobApplication): string { if (job.fullSummary) return job.fullSummary; @@ -547,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - {columns.status ? : null} + {columns.status ? : null} @@ -695,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ))} - {columns.status ? : null} + {columns.status ? : null} {columns.dateApplied ? {appliedDateLabel} : null} {columns.daysSince ? {job.daysSince} : null} {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index f386278..255ab62 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -19,41 +19,22 @@ 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"; -const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; -type Status = (typeof STATUSES)[number]; +const STATUSES = PIPELINE_STATUSES; +type Status = PipelineStatus; -function normalizeStatus(status: string): Status | "Other" { - if (status === "Interviewing") return "Interview"; - if ((STATUSES as readonly string[]).includes(status)) return status as Status; - return "Other"; -} +const TONE_PALETTE: Record string> = { + error: (theme) => theme.palette.error.main, + warning: (theme) => theme.palette.warning.main, + success: (theme) => theme.palette.success.main, + info: (theme) => alpha(theme.palette.primary.main, 0.95), + primary: (theme) => theme.palette.primary.main, + default: (theme) => theme.palette.primary.main, +}; function toneColor(theme: any, status: Status | "Other"): string { - if (status === "Rejected") return theme.palette.error.main; - if (status === "Waiting" || status === "Ghosted") return theme.palette.warning.main; - if (status === "Offer") return theme.palette.success.main; - if (status === "Interview") return alpha(theme.palette.primary.main, 0.95); - return theme.palette.primary.main; -} - -function statusLabel(t: (key: any, params?: any) => string, status: Status): string { - switch (status) { - case "Applied": - return t("statusApplied"); - case "Waiting": - return t("statusWaiting"); - case "Interview": - return t("statusInterview"); - case "Offer": - return t("statusOffer"); - case "Rejected": - return t("statusRejected"); - case "Ghosted": - return t("statusGhosted"); - default: - return status; - } + return TONE_PALETTE[statusTone(status)](theme); } export default function KanbanBoard() { diff --git a/job-tracker-ui/src/pipeline.test.ts b/job-tracker-ui/src/pipeline.test.ts new file mode 100644 index 0000000..6c6f3f6 --- /dev/null +++ b/job-tracker-ui/src/pipeline.test.ts @@ -0,0 +1,37 @@ +import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline'; + +describe('pipeline', () => { + test('normalizeStatus canonicalizes casing and synonyms', () => { + expect(normalizeStatus('applied')).toBe('Applied'); + expect(normalizeStatus(' OFFER ')).toBe('Offer'); + expect(normalizeStatus('Interviewing')).toBe('Interview'); + expect(normalizeStatus('declined')).toBe('Rejected'); + }); + + test('normalizeStatus preserves unknown as Other and empty as Applied', () => { + expect(normalizeStatus('Take-home')).toBe('Other'); + expect(normalizeStatus('')).toBe('Applied'); + expect(normalizeStatus(null)).toBe('Applied'); + }); + + test('statusTone maps stages to palette keys', () => { + expect(statusTone('Offer')).toBe('success'); + expect(statusTone('Rejected')).toBe('error'); + expect(statusTone('Waiting')).toBe('warning'); + expect(statusTone('Ghosted')).toBe('warning'); + expect(statusTone('Interview')).toBe('info'); + expect(statusTone('Applied')).toBe('primary'); + expect(statusTone('Take-home')).toBe('default'); + }); + + test('statusLabel localizes canonical and passes through custom', () => { + const t = (key: string) => ({ statusApplied: 'Applied', statusOffer: 'Offer' } as Record)[key] ?? key; + expect(statusLabel(t, 'Applied')).toBe('Applied'); + expect(statusLabel(t, 'Interviewing')).toBe('statusInterview'); // maps to canonical key + expect(statusLabel(t, 'Take-home assignment')).toBe('Take-home assignment'); + }); + + test('canonical stage list is stable and ordered', () => { + expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']); + }); +}); diff --git a/job-tracker-ui/src/pipeline.ts b/job-tracker-ui/src/pipeline.ts new file mode 100644 index 0000000..9e61fc4 --- /dev/null +++ b/job-tracker-ui/src/pipeline.ts @@ -0,0 +1,64 @@ +// Single frontend source of truth for the canonical job pipeline. +// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync. + +export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; + +export type PipelineStatus = (typeof PIPELINE_STATUSES)[number]; + +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 = { + interviewing: "Interview", + interviews: "Interview", + interviewed: "Interview", + declined: "Rejected", + "no response": "Ghosted", + "no reply": "Ghosted", + pending: "Waiting", + "awaiting response": "Waiting", +}; + +/** 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": + return "error"; + case "Waiting": + case "Ghosted": + return "warning"; + case "Interview": + return "info"; + case "Applied": + return "primary"; + default: + return "default"; + } +} + +const LABEL_KEYS: Record = { + Applied: "statusApplied", + Waiting: "statusWaiting", + Interview: "statusInterview", + Offer: "statusOffer", + Rejected: "statusRejected", + Ghosted: "statusGhosted", +}; + +/** 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]); +}