Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features #1

Merged
cesnimda merged 26 commits from chore/wave0-quick-wins into main 2026-07-03 11:14:15 +02:00
7 changed files with 125 additions and 71 deletions
Showing only changes of commit 5a306f51a1 - Show all commits
+3 -1
View File
@@ -191,7 +191,9 @@ Authentication:
- Updates an application; records a `StatusChanged` event if the status changed. - Updates an application; records a `StatusChanged` event if the status changed.
- `PATCH /api/jobapplications/{id}/status` - `PATCH /api/jobapplications/{id}/status`
- Body: `{ "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` - `PATCH /api/jobapplications/{id}/followup`
- Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`) - Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`)
- Sets/clears follow-up date; records a `FollowUpSet` event. - Sets/clears follow-up date; records a `FollowUpSet` event.
+4 -16
View File
@@ -30,6 +30,7 @@ import { Company, JobImportResult } from "../types";
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies"; import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline";
import TagsInput from "./TagsInput"; import TagsInput from "./TagsInput";
interface Props { interface Props {
@@ -60,7 +61,6 @@ type CreatedJobResponse = {
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other"; type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>; type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
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 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 FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; 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 [dateApplied, setDateApplied] = useState(() => getTodayIso());
const [jobTitle, setJobTitle] = useState(""); 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 [location, setLocation] = useState("");
const [salary, setSalary] = useState(""); const [salary, setSalary] = useState("");
const [salaryMin, setSalaryMin] = 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[]) => { const filesLabel = (files: File[]) => {
if (files.length === 0) return t("addJobModalNoFilesSelected"); if (files.length === 0) return t("addJobModalNoFilesSelected");
if (files.length === 1) return files[0].name; if (files.length === 1) return files[0].name;
@@ -479,9 +467,9 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
/> />
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}> <TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => ( {PIPELINE_STATUSES.map((s) => (
<MenuItem key={s} value={s}> <MenuItem key={s} value={s}>
{statusLabel(s)} {pipelineStatusLabel(t, s)}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@@ -24,6 +24,7 @@ import { useToast } from "../toast";
import { useCompanies } from "../hooks/useCompanies"; import { useCompanies } from "../hooks/useCompanies";
import TagsInput from "./TagsInput"; import TagsInput from "./TagsInput";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -32,7 +33,6 @@ interface Props {
onSaved: () => void; onSaved: () => void;
} }
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } }; const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
@@ -207,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}> <TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)} {PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
</TextField> </TextField>
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} /> <DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box> <Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
+3 -21
View File
@@ -45,6 +45,7 @@ import ViewStateNotice from "./ViewStateNotice";
import { useCompanies } from "../hooks/useCompanies"; import { useCompanies } from "../hooks/useCompanies";
import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { formatSalary } from "../salary"; import { formatSalary } from "../salary";
import { statusLabel, statusTone } from "../pipeline";
import JobDetailsDialog from "./JobDetailsDialog"; import JobDetailsDialog from "./JobDetailsDialog";
import EditJobDialog from "./EditJobDialog"; import EditJobDialog from "./EditJobDialog";
import { useToast } from "../toast"; import { useToast } from "../toast";
@@ -98,10 +99,6 @@ interface Props {
mode?: "jobs" | "trash"; mode?: "jobs" | "trash";
} }
function normalizeStatus(status: string): string {
return status === "Interviewing" ? "Interview" : status;
}
function parseTags(raw?: string | null): string[] { function parseTags(raw?: string | null): string[] {
if (!raw) return []; if (!raw) return [];
try { 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 { function generateOverview(job: JobApplication): string {
if (job.fullSummary) return job.fullSummary; if (job.fullSummary) return job.fullSummary;
@@ -547,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
{columns.status ? <Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null} {columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
</Box> </Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
@@ -695,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
))} ))}
</Box> </Box>
</TableCell> </TableCell>
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null} {columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null} {columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null} {columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null} {columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
+12 -31
View File
@@ -19,41 +19,22 @@ import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types"; import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource"; import { useViewResource } from "../hooks/useViewResource";
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const STATUSES = PIPELINE_STATUSES;
type Status = (typeof STATUSES)[number]; type Status = PipelineStatus;
function normalizeStatus(status: string): Status | "Other" { const TONE_PALETTE: Record<string, (theme: any) => string> = {
if (status === "Interviewing") return "Interview"; error: (theme) => theme.palette.error.main,
if ((STATUSES as readonly string[]).includes(status)) return status as Status; warning: (theme) => theme.palette.warning.main,
return "Other"; 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 { function toneColor(theme: any, status: Status | "Other"): string {
if (status === "Rejected") return theme.palette.error.main; return TONE_PALETTE[statusTone(status)](theme);
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;
}
} }
export default function KanbanBoard() { export default function KanbanBoard() {
+37
View File
@@ -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<string, string>)[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']);
});
});
+64
View File
@@ -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<string, PipelineStatus> = {
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<PipelineStatus, string> = {
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]);
}