feat(S05/T01): Unified workflow trust signals across the API, table, da…
- JobTrackerApi/Controllers/JobApplicationsController.cs - JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs - job-tracker-ui/src/jobWorkflowSignals.ts - job-tracker-ui/src/components/JobTable.tsx - job-tracker-ui/src/components/DashboardView.tsx - job-tracker-ui/src/components/RemindersView.tsx - job-tracker-ui/src/workflow-trust-signals.test.tsx
This commit is contained in:
@@ -23,7 +23,8 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
||||
import { api } from "../api";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
||||
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { JobApplication } from "../types";
|
||||
|
||||
interface JobStats {
|
||||
total: number;
|
||||
@@ -34,15 +35,7 @@ interface JobStats {
|
||||
averageDaysSinceApplied: number;
|
||||
}
|
||||
|
||||
type ReminderJob = {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
status: string;
|
||||
followUpAt?: string | null;
|
||||
tailoredCvText?: string | null;
|
||||
followUpReason?: string | null;
|
||||
company?: { name?: string | null };
|
||||
};
|
||||
type ReminderJob = JobApplication;
|
||||
|
||||
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
||||
type TagPoint = { tag: string; count: number };
|
||||
@@ -167,7 +160,7 @@ export default function DashboardView() {
|
||||
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
|
||||
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0;
|
||||
const topSource = overview?.responseRateBySource?.[0];
|
||||
const missingCvCount = reminderJobs.filter((job) => !job.tailoredCvText).length;
|
||||
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
|
||||
|
||||
const metricCards = [
|
||||
{
|
||||
@@ -214,13 +207,15 @@ export default function DashboardView() {
|
||||
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||
const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0;
|
||||
const priorityJobs = reminderJobs.slice(0, 5);
|
||||
const getReminderAction = (job: ReminderJob) => getWorkflowAction(job, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
followUp: t("jobTableFollowUp"),
|
||||
interviewPrep: t("jobTableInterviewStage"),
|
||||
readiness: t("jobTableReadiness"),
|
||||
});
|
||||
|
||||
const openReminderJob = (job: ReminderJob) => {
|
||||
const reason = (job.followUpReason ?? '').toLowerCase();
|
||||
if (reason.includes('tailored cv')) {
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }));
|
||||
return;
|
||||
}
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' }));
|
||||
navigate(buildWorkflowPath(job));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -393,17 +388,19 @@ export default function DashboardView() {
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.1}>
|
||||
{priorityJobs.map((job) => (
|
||||
{priorityJobs.map((job) => {
|
||||
const action = getReminderAction(job);
|
||||
return (
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
||||
{job.followUpReason?.toLowerCase().includes('tailored cv') ? t("jobDetailsTabTailoredCv") : t("jobTableFollowUp")}
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
)})}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
|
||||
@@ -47,29 +47,8 @@ import { useToast } from "../toast";
|
||||
import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
||||
|
||||
interface JobApplication {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
status: string;
|
||||
dateApplied: string;
|
||||
daysSince: number;
|
||||
jobUrl?: string | null;
|
||||
notes?: string | null;
|
||||
location?: string | null;
|
||||
salary?: string | null;
|
||||
tags?: string | null;
|
||||
description?: string | null;
|
||||
isDeleted?: boolean;
|
||||
company: { name: string };
|
||||
companyId?: number;
|
||||
needsFollowUp?: boolean;
|
||||
followUpReason?: string | null;
|
||||
shortSummary?: string | null;
|
||||
fullSummary?: string | null;
|
||||
tailoredCvText?: string | null;
|
||||
}
|
||||
import { JobApplication } from "../types";
|
||||
import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
|
||||
|
||||
interface PagedResult<T> {
|
||||
items: T[];
|
||||
@@ -210,8 +189,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (readinessFilter === "all") return jobs;
|
||||
if (readinessFilter === "interview") return jobs.filter((job) => job.status === "Interview" || job.status === "Interviewing");
|
||||
return jobs.filter((job) => !job.tailoredCvText || !job.notes);
|
||||
if (readinessFilter === "interview") return jobs.filter((job) => needsInterviewPrep(job));
|
||||
return jobs.filter((job) => needsWorkflowWork(job));
|
||||
}, [jobs, readinessFilter]);
|
||||
|
||||
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIds.includes(job.id));
|
||||
@@ -289,58 +268,38 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return src.length > 220 ? `${src.slice(0, 220)}...` : src;
|
||||
};
|
||||
|
||||
const openFollowUpWorkspace = (jobId: number) => {
|
||||
navigate(buildJobWorkspacePath(jobId, { tab: JOB_DETAILS_TABS.followUp, followMode: "waiting-update" }));
|
||||
};
|
||||
|
||||
const openTailoredCvWorkspace = (jobId: number) => {
|
||||
navigate(buildJobWorkspacePath(jobId, { tab: JOB_DETAILS_TABS.tailoredCv }));
|
||||
};
|
||||
|
||||
const getPackageActionDetail = (job: JobApplication) => {
|
||||
const missingTailoredCv = !job.tailoredCvText;
|
||||
const missingNotes = !job.notes?.trim();
|
||||
|
||||
if (missingTailoredCv && missingNotes) return t("jobTablePackageMissingCvAndNotes");
|
||||
if (missingTailoredCv) return t("jobTableCvMissing");
|
||||
if (missingNotes) return t("jobTablePackageMissingNotes");
|
||||
return null;
|
||||
};
|
||||
const buildWorkflowActionDetail = (job: JobApplication) => getWorkflowAction(job, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
followUp: t("jobTableFollowUp"),
|
||||
interviewPrep: t("jobTableInterviewStage"),
|
||||
readiness: t("jobTableReadiness"),
|
||||
});
|
||||
|
||||
const getActionSignals = (job: JobApplication) => {
|
||||
const signals: Array<{
|
||||
label: string;
|
||||
detail: string;
|
||||
onClick: () => void;
|
||||
variant: "contained" | "outlined";
|
||||
color?: "warning" | "primary";
|
||||
}> = [];
|
||||
const action = buildWorkflowActionDetail(job);
|
||||
if (!action || job.isDeleted) return [];
|
||||
|
||||
if (job.needsFollowUp) {
|
||||
signals.push({
|
||||
label: t("jobTableFollowUp"),
|
||||
detail: job.followUpReason ?? t("jobTableNeedsFollowUp"),
|
||||
onClick: () => openFollowUpWorkspace(job.id),
|
||||
variant: "contained",
|
||||
color: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
const packageDetail = !job.isDeleted ? getPackageActionDetail(job) : null;
|
||||
if (packageDetail) {
|
||||
signals.push({
|
||||
label: t("jobTablePackageWork"),
|
||||
detail: packageDetail,
|
||||
onClick: () => openTailoredCvWorkspace(job.id),
|
||||
variant: job.needsFollowUp ? "outlined" : "contained",
|
||||
color: job.needsFollowUp ? "primary" : "warning",
|
||||
});
|
||||
}
|
||||
|
||||
return signals;
|
||||
return [{
|
||||
label: action.label,
|
||||
detail: action.detail,
|
||||
onClick: () => navigate(action.path),
|
||||
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
|
||||
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
|
||||
}];
|
||||
};
|
||||
|
||||
const getPrimaryAction = (job: JobApplication) => getActionSignals(job)[0] ?? null;
|
||||
const getPrimaryAction = (job: JobApplication) => {
|
||||
const action = buildWorkflowActionDetail(job);
|
||||
if (!action || job.isDeleted) return null;
|
||||
|
||||
return {
|
||||
label: action.label,
|
||||
detail: action.detail,
|
||||
onClick: () => navigate(action.path),
|
||||
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
|
||||
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
||||
|
||||
type ReminderGroups = {
|
||||
missingCv: JobApplication[];
|
||||
@@ -19,11 +19,8 @@ type ReminderGroups = {
|
||||
function groupItems(items: JobApplication[]): ReminderGroups {
|
||||
const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] };
|
||||
items.forEach((item) => {
|
||||
const reason = (item.followUpReason ?? "").toLowerCase();
|
||||
if (reason.includes("tailored cv")) groups.missingCv.push(item);
|
||||
else if (reason.includes("interview prep") || reason.includes("prep notes")) groups.missingInterviewNotes.push(item);
|
||||
else if (reason.includes("follow-up") || reason.includes("follow up")) groups.overdueFollowUp.push(item);
|
||||
else groups.other.push(item);
|
||||
const group = getReminderGroup(item);
|
||||
groups[group].push(item);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
@@ -35,7 +32,15 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{items.map((j) => (
|
||||
{items.map((j) => {
|
||||
const action = getWorkflowAction(j, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
followUp: t("jobTableFollowUp"),
|
||||
interviewPrep: t("jobTableInterviewStage"),
|
||||
readiness: t("jobTableReadiness"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
@@ -43,19 +48,19 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||
{j.followUpReason ? <Chip size="small" label={j.followUpReason} variant="outlined" /> : null}
|
||||
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
)})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -78,12 +83,7 @@ export default function RemindersView() {
|
||||
const grouped = useMemo(() => groupItems(items), [items]);
|
||||
|
||||
const openJob = (job: JobApplication) => {
|
||||
const reason = (job.followUpReason ?? '').toLowerCase();
|
||||
if (reason.includes('tailored cv')) {
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }));
|
||||
return;
|
||||
}
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' }));
|
||||
navigate(buildWorkflowPath(job));
|
||||
};
|
||||
|
||||
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
||||
|
||||
@@ -26,8 +26,21 @@ const pagedJobs = {
|
||||
needsFollowUp: true,
|
||||
followUpReason: 'Follow-up due soon',
|
||||
shortSummary: 'Strong backend match',
|
||||
tailoredCvText: null,
|
||||
notes: null,
|
||||
tailoredCvText: 'Saved CV',
|
||||
notes: 'Notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
workflowSignal: {
|
||||
actionKey: 'follow-up',
|
||||
reason: 'Follow-up due soon',
|
||||
workspaceTab: 'follow-up',
|
||||
followMode: 'waiting-update',
|
||||
needsAttention: true,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
@@ -42,6 +55,19 @@ const pagedJobs = {
|
||||
shortSummary: 'Platform work',
|
||||
tailoredCvText: null,
|
||||
notes: null,
|
||||
workflowSignal: {
|
||||
actionKey: 'package-work',
|
||||
reason: 'Tailored CV missing for this role.',
|
||||
workspaceTab: 'tailored-cv',
|
||||
followMode: null,
|
||||
needsAttention: true,
|
||||
hasPackageGap: true,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: false,
|
||||
hasTailoredCv: false,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
@@ -61,6 +87,19 @@ const reminderItems = [
|
||||
followUpReason: 'Follow-up due soon',
|
||||
tailoredCvText: 'Saved CV',
|
||||
followUpAt: new Date().toISOString(),
|
||||
workflowSignal: {
|
||||
actionKey: 'follow-up',
|
||||
reason: 'Follow-up due soon',
|
||||
workspaceTab: 'follow-up',
|
||||
followMode: 'waiting-update',
|
||||
needsAttention: true,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
@@ -73,6 +112,19 @@ const reminderItems = [
|
||||
followUpReason: 'Tailored CV missing',
|
||||
tailoredCvText: null,
|
||||
followUpAt: new Date().toISOString(),
|
||||
workflowSignal: {
|
||||
actionKey: 'package-work',
|
||||
reason: 'Tailored CV missing for this role.',
|
||||
workspaceTab: 'tailored-cv',
|
||||
followMode: null,
|
||||
needsAttention: true,
|
||||
hasPackageGap: true,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: false,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -152,7 +204,7 @@ test('reminders open action routes tailored-cv gaps into the tailored cv workspa
|
||||
const platformJob = await screen.findByText(/platform engineer/i);
|
||||
const platformCard = platformJob.closest('.MuiPaper-root') ?? platformJob.parentElement?.parentElement;
|
||||
expect(platformCard).toBeTruthy();
|
||||
fireEvent.click(within(platformCard as HTMLElement).getByRole('button', { name: /open/i }));
|
||||
fireEvent.click(within(platformCard as HTMLElement).getByRole('button', { name: /build package/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { JobApplication, WorkflowSignal } from "./types";
|
||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "./jobWorkspaceRoute";
|
||||
|
||||
const TAB_BY_WORKSPACE: Record<string, number> = {
|
||||
"tailored-cv": JOB_DETAILS_TABS.tailoredCv,
|
||||
"follow-up": JOB_DETAILS_TABS.followUp,
|
||||
"interview-prep": JOB_DETAILS_TABS.interviewPrep,
|
||||
readiness: JOB_DETAILS_TABS.readiness,
|
||||
};
|
||||
|
||||
export type WorkflowActionPresentation = {
|
||||
key: WorkflowSignal["actionKey"];
|
||||
label: string;
|
||||
detail: string;
|
||||
path: string;
|
||||
tab: number;
|
||||
followMode?: string;
|
||||
};
|
||||
|
||||
export type ReminderGroupKey = "missingCv" | "missingInterviewNotes" | "overdueFollowUp" | "other";
|
||||
|
||||
export function getWorkflowSignal(job: Pick<JobApplication, "workflowSignal">): WorkflowSignal | null {
|
||||
return job.workflowSignal ?? null;
|
||||
}
|
||||
|
||||
export function buildWorkflowPath(job: Pick<JobApplication, "id" | "workflowSignal">): string {
|
||||
const signal = getWorkflowSignal(job);
|
||||
const tab = signal ? TAB_BY_WORKSPACE[signal.workspaceTab] ?? JOB_DETAILS_TABS.overview : JOB_DETAILS_TABS.overview;
|
||||
return buildJobWorkspacePath(job.id, {
|
||||
tab,
|
||||
followMode: signal?.followMode ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function getWorkflowAction(
|
||||
job: Pick<JobApplication, "id" | "workflowSignal">,
|
||||
labels: {
|
||||
packageWork: string;
|
||||
followUp: string;
|
||||
interviewPrep: string;
|
||||
readiness: string;
|
||||
},
|
||||
): WorkflowActionPresentation | null {
|
||||
const signal = getWorkflowSignal(job);
|
||||
if (!signal || !signal.needsAttention) return null;
|
||||
|
||||
const label = signal.actionKey === "package-work"
|
||||
? labels.packageWork
|
||||
: signal.actionKey === "interview-prep"
|
||||
? labels.interviewPrep
|
||||
: signal.actionKey === "follow-up"
|
||||
? labels.followUp
|
||||
: labels.readiness;
|
||||
|
||||
return {
|
||||
key: signal.actionKey,
|
||||
label,
|
||||
detail: signal.reason,
|
||||
path: buildWorkflowPath(job),
|
||||
tab: TAB_BY_WORKSPACE[signal.workspaceTab] ?? JOB_DETAILS_TABS.overview,
|
||||
followMode: signal.followMode ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function needsWorkflowWork(job: Pick<JobApplication, "workflowSignal">): boolean {
|
||||
const signal = getWorkflowSignal(job);
|
||||
return Boolean(signal?.hasPackageGap || signal?.needsInterviewPrep);
|
||||
}
|
||||
|
||||
export function needsInterviewPrep(job: Pick<JobApplication, "workflowSignal">): boolean {
|
||||
return Boolean(getWorkflowSignal(job)?.needsInterviewPrep);
|
||||
}
|
||||
|
||||
export function getReminderGroup(job: Pick<JobApplication, "workflowSignal">): ReminderGroupKey {
|
||||
const actionKey = getWorkflowSignal(job)?.actionKey;
|
||||
if (actionKey === "package-work") return "missingCv";
|
||||
if (actionKey === "interview-prep") return "missingInterviewNotes";
|
||||
if (actionKey === "follow-up") return "overdueFollowUp";
|
||||
return "other";
|
||||
}
|
||||
@@ -11,6 +11,23 @@ export interface Company {
|
||||
pipelineStage?: string;
|
||||
}
|
||||
|
||||
export type WorkflowActionKey = "package-work" | "follow-up" | "interview-prep" | "review-readiness";
|
||||
export type WorkflowWorkspaceTab = "tailored-cv" | "follow-up" | "interview-prep" | "readiness";
|
||||
|
||||
export interface WorkflowSignal {
|
||||
actionKey: WorkflowActionKey;
|
||||
reason: string;
|
||||
workspaceTab: WorkflowWorkspaceTab;
|
||||
followMode?: string | null;
|
||||
needsAttention: boolean;
|
||||
hasPackageGap: boolean;
|
||||
needsInterviewPrep: boolean;
|
||||
needsFollowUpAction: boolean;
|
||||
hasTailoredCv: boolean;
|
||||
hasSavedApplicationAnswerDraft: boolean;
|
||||
hasInterviewPrepNotes: boolean;
|
||||
}
|
||||
|
||||
export interface JobApplication {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
@@ -38,6 +55,7 @@ export interface JobApplication {
|
||||
fullSummary?: string | null;
|
||||
tailoredCvText?: string | null;
|
||||
tailoredCvUpdatedAt?: string | null;
|
||||
workflowSignal?: WorkflowSignal | null;
|
||||
|
||||
hasResume?: boolean;
|
||||
hasCoverLetter?: boolean;
|
||||
@@ -48,7 +66,7 @@ export interface JobApplication {
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
needsFollowUp?: boolean;
|
||||
followUpReason?: string;
|
||||
followUpReason?: string | null;
|
||||
}
|
||||
|
||||
export interface CandidateFitChannelGuidance {
|
||||
@@ -97,6 +115,7 @@ export interface ReadinessResponse {
|
||||
completed: string[];
|
||||
missing: string[];
|
||||
reminders: string[];
|
||||
workflowSignal: WorkflowSignal;
|
||||
}
|
||||
|
||||
export interface FollowUpDraft {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import RemindersView from './components/RemindersView';
|
||||
import JobTable from './components/JobTable';
|
||||
import { api } from './api';
|
||||
import { JobApplication } from './types';
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function buildJob(overrides: Partial<JobApplication>): JobApplication {
|
||||
return {
|
||||
id: 1,
|
||||
jobTitle: 'Backend Developer',
|
||||
company: { id: 1, name: 'Acme' },
|
||||
companyId: 1,
|
||||
status: 'Waiting',
|
||||
dateApplied: new Date('2026-03-01T00:00:00Z').toISOString(),
|
||||
location: 'Oslo',
|
||||
salary: undefined,
|
||||
nextAction: undefined,
|
||||
followUpAt: new Date('2026-03-20T00:00:00Z').toISOString(),
|
||||
feedbackRequestedAt: undefined,
|
||||
responseReceived: false,
|
||||
responseDate: undefined,
|
||||
description: 'Role summary',
|
||||
translatedDescription: undefined,
|
||||
descriptionLanguage: undefined,
|
||||
tags: undefined,
|
||||
deadline: undefined,
|
||||
notes: 'General notes',
|
||||
coverLetterText: undefined,
|
||||
recruiterMessageDraft: undefined,
|
||||
jobUrl: undefined,
|
||||
shortSummary: 'Strong match',
|
||||
fullSummary: null,
|
||||
tailoredCvText: 'Saved CV',
|
||||
tailoredCvUpdatedAt: null,
|
||||
workflowSignal: {
|
||||
actionKey: 'follow-up',
|
||||
reason: 'Follow-up is due for this role.',
|
||||
workspaceTab: 'follow-up',
|
||||
followMode: 'waiting-update',
|
||||
needsAttention: true,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
hasResume: true,
|
||||
hasCoverLetter: false,
|
||||
hasPortfolio: false,
|
||||
hasOtherAttachment: false,
|
||||
daysSince: 10,
|
||||
isDeleted: false,
|
||||
deletedAt: undefined,
|
||||
needsFollowUp: true,
|
||||
followUpReason: 'Follow-up is due for this role.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setupApiMocks({ reminders, jobs }: { reminders?: JobApplication[]; jobs?: JobApplication[] }) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/companies') return Promise.resolve({ data: [{ id: 1, name: 'Acme' }, { id: 2, name: 'Beta' }] } as any);
|
||||
if (url === '/jobapplications/reminders') return Promise.resolve({ data: reminders ?? [] } as any);
|
||||
if (url === '/jobapplications') return Promise.resolve({ data: { items: jobs ?? [], total: jobs?.length ?? 0, page: 1, pageSize: 15 } } as any);
|
||||
if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: reminders?.length ?? 0, active: reminders?.length ?? 0, deleted: 0, byStatus: {}, appliedLast30Days: reminders?.length ?? 0, averageDaysSinceApplied: 7 } } as any);
|
||||
if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: [], responseRateBySource: [], topCompanies: [], totalResponses: 1, totalActive: reminders?.length ?? 0 } } as any);
|
||||
if (url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any);
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
}
|
||||
|
||||
function LocationIndicator() {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location-indicator">{location.pathname}{location.search}</div>;
|
||||
}
|
||||
|
||||
function renderWithProviders(initialPath: string, routes: React.ReactNode) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>{routes}</Routes>
|
||||
</MemoryRouter>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('follow-up workflow signals route all overview surfaces to the same follow-up workspace', async () => {
|
||||
const job = buildJob({
|
||||
id: 42,
|
||||
followUpReason: 'Tailored CV missing',
|
||||
workflowSignal: {
|
||||
actionKey: 'follow-up',
|
||||
reason: 'Follow-up is due for this role.',
|
||||
workspaceTab: 'follow-up',
|
||||
followMode: 'waiting-update',
|
||||
needsAttention: true,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
});
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
|
||||
const dashboardRender = renderWithProviders('/dashboard', <>
|
||||
<Route path="/dashboard" element={<><LocationIndicator /><DashboardView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
await screen.findByText(/follow-up is due for this role/i);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /follow up/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update'));
|
||||
dashboardRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
const remindersRender = renderWithProviders('/reminders', <>
|
||||
<Route path="/reminders" element={<><LocationIndicator /><RemindersView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /follow up/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update'));
|
||||
remindersRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
renderWithProviders('/table', <>
|
||||
<Route path="/table" element={<><LocationIndicator /><JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /backend developer — follow up signal/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update'));
|
||||
});
|
||||
|
||||
test('package-work workflow signals route all overview surfaces to the shared tailored-cv workspace', async () => {
|
||||
const job = buildJob({
|
||||
id: 43,
|
||||
jobTitle: 'Platform Engineer',
|
||||
followUpReason: 'Follow-up due soon',
|
||||
workflowSignal: {
|
||||
actionKey: 'package-work',
|
||||
reason: 'Saved application answers still need work.',
|
||||
workspaceTab: 'tailored-cv',
|
||||
followMode: null,
|
||||
needsAttention: true,
|
||||
hasPackageGap: true,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
});
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
|
||||
const dashboardRender = renderWithProviders('/dashboard', <>
|
||||
<Route path="/dashboard" element={<><LocationIndicator /><DashboardView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /build package/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3'));
|
||||
dashboardRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
const remindersRender = renderWithProviders('/reminders', <>
|
||||
<Route path="/reminders" element={<><LocationIndicator /><RemindersView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /build package/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3'));
|
||||
remindersRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
renderWithProviders('/table', <>
|
||||
<Route path="/table" element={<><LocationIndicator /><JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /platform engineer — build package signal/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3'));
|
||||
});
|
||||
|
||||
test('job table readiness filter follows workflow signals instead of raw notes or cv text heuristics', async () => {
|
||||
const packageGapJob = buildJob({
|
||||
id: 44,
|
||||
jobTitle: 'Application Engineer',
|
||||
status: 'Applied',
|
||||
notes: 'General notes only',
|
||||
tailoredCvText: 'Saved tailored CV',
|
||||
workflowSignal: {
|
||||
actionKey: 'package-work',
|
||||
reason: 'Saved application answers still need work.',
|
||||
workspaceTab: 'tailored-cv',
|
||||
followMode: null,
|
||||
needsAttention: true,
|
||||
hasPackageGap: true,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: false,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const readyRejectedJob = buildJob({
|
||||
id: 45,
|
||||
jobTitle: 'Operations Analyst',
|
||||
status: 'Rejected',
|
||||
notes: 'Some notes',
|
||||
tailoredCvText: null,
|
||||
needsFollowUp: false,
|
||||
followUpReason: null,
|
||||
workflowSignal: {
|
||||
actionKey: 'review-readiness',
|
||||
reason: 'No urgent workflow gaps are blocking this job right now.',
|
||||
workspaceTab: 'readiness',
|
||||
followMode: null,
|
||||
needsAttention: false,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: false,
|
||||
hasTailoredCv: false,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: false,
|
||||
},
|
||||
});
|
||||
|
||||
setupApiMocks({ jobs: [packageGapJob, readyRejectedJob] });
|
||||
|
||||
renderWithProviders('/table', <>
|
||||
<Route path="/table" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} />
|
||||
</>);
|
||||
|
||||
const readinessSelect = (await screen.findAllByRole('combobox')).find((element) => /all readiness/i.test(element.textContent ?? ''));
|
||||
expect(readinessSelect).toBeTruthy();
|
||||
fireEvent.mouseDown(readinessSelect as HTMLElement);
|
||||
fireEvent.click(await screen.findByRole('option', { name: /needs work/i }));
|
||||
|
||||
expect(await screen.findByText(/application engineer/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/operations analyst/i)).not.toBeInTheDocument();
|
||||
});
|
||||
Reference in New Issue
Block a user