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:
2026-03-24 14:28:01 +01:00
parent d166f9854d
commit 9adbde3f5e
12 changed files with 974 additions and 314 deletions
+18 -21
View File
@@ -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 }}>
+31 -72
View File
@@ -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>
+16 -16
View File
@@ -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) => {