diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx
index 957b615..47128ed 100644
--- a/job-tracker-ui/src/components/DashboardView.tsx
+++ b/job-tracker-ui/src/components/DashboardView.tsx
@@ -4,22 +4,20 @@ import { useNavigate } from "react-router-dom";
import {
Box,
Button,
+ Card,
Checkbox,
Chip,
LinearProgress,
Menu,
MenuItem,
- Paper,
Stack,
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha, useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
-import TrendingUpIcon from "@mui/icons-material/TrendingUp";
-import MailOutlineIcon from "@mui/icons-material/MailOutline";
-import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
-import AutoGraphIcon from "@mui/icons-material/AutoGraph";
+import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
+import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
@@ -100,34 +98,25 @@ function buildLinePath(values: number[], width: number, height: number) {
.join(" ");
}
-function MiniSpark({ values, color }: { values: number[]; color: string }) {
- const width = 180;
- const height = 52;
- const path = buildLinePath(values, width, height);
-
+// Mockup cards float on the grey page background purely via the theme's Card shadow/radius --
+// this wraps MuiCard instead of Paper-with-hardcoded-shadow so every section stays in sync with
+// theme.ts (see 02-dashboard.png).
+function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
return (
-
+
+ {children}
+
);
}
-function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
- return (
-
- {children}
-
- );
+// "Needs your attention" row accent -- red for overdue follow-ups, amber for interview prep,
+// blue/info for everything else (package work, readiness review). No "positive/offer" tier
+// exists in the real reminder feed (see DashboardView report), so only these three appear.
+function reminderTone(job: ReminderJob): "error" | "warning" | "info" {
+ const key = job.workflowSignal?.actionKey;
+ if (key === "follow-up") return "error";
+ if (key === "interview-prep") return "warning";
+ return "info";
}
export default function DashboardView() {
@@ -200,11 +189,17 @@ export default function DashboardView() {
const summaryView = useMemo(() => {
const topSource = overview?.responseRateBySource?.[0];
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
+ const interviewCount = overview?.funnel?.find((item) => item.label === "Interview")?.count ?? 0;
+ const interviewPrepCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "interview-prep").length;
+ const overdueFollowUpCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "follow-up").length;
return {
funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0,
topSource,
missingCvCount,
+ interviewCount,
+ interviewPrepCount,
+ overdueFollowUpCount,
priorityJobs: reminderJobs.slice(0, 5),
};
}, [overview, reminderJobs]);
@@ -232,40 +227,37 @@ export default function DashboardView() {
};
}, [analytics, isMobile, tags]);
- const metricCards = useMemo(() => ([
- {
- label: t("dashboardActiveApplications"),
- value: stats?.active ?? 0,
- sub: t("dashboardCurrentlyInProgress"),
- icon: ,
- tone: theme.palette.primary.main,
- spark: trendsView.appliedValues,
- },
- {
- label: t("dashboardApplied30Days"),
- value: stats?.appliedLast30Days ?? 0,
- sub: t("dashboardNewApplications"),
- icon: ,
- tone: theme.palette.success.main,
- spark: trendsView.appliedValues.slice(-6),
- },
- {
- label: t("dashboardMedianFirstResponse"),
- value: overview?.medianDaysToFirstResponse ?? "—",
- sub: t("dashboardDaysUntilFirstReply"),
- icon: ,
- tone: theme.palette.info.main,
- spark: trendsView.responseValues,
- },
- {
- label: t("dashboardResponsesLogged"),
- value: overview?.totalResponses ?? 0,
- sub: t("dashboardAcrossActiveJobs"),
- icon: ,
- tone: theme.palette.warning.main,
- spark: trendsView.responseValues.slice(-6),
- },
- ]), [overview?.medianDaysToFirstResponse, overview?.totalResponses, stats?.active, stats?.appliedLast30Days, t, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, trendsView.appliedValues, trendsView.responseValues]);
+ // Mockup's 4 stat tiles (Active applications / Response rate / Interviews / Needs follow-up)
+ // -- every value below is derived from data this view already fetches, no new endpoints.
+ const metricCards = useMemo(() => {
+ const cards: { label: string; value: string | number; trend?: { text: string; tone: "success" | "error" }; caption?: string }[] = [
+ {
+ label: t("dashboardActiveApplications"),
+ value: stats?.active ?? 0,
+ trend: (stats?.appliedLast30Days ?? 0) > 0
+ ? { text: `+${stats?.appliedLast30Days} ${t("dashboardNewApplications").toLowerCase()}`, tone: "success" }
+ : undefined,
+ },
+ {
+ label: t("dashboardResponseRateTile"),
+ value: `${trendsView.responseRate}%`,
+ caption: summaryView.topSource ? `${summaryView.topSource.label} · ${summaryView.topSource.rate}%` : t("dashboardResponseSources"),
+ },
+ {
+ label: t("dashboardInterviews"),
+ value: summaryView.interviewCount,
+ caption: summaryView.interviewPrepCount > 0 ? t("dashboardUpcomingCount", { count: summaryView.interviewPrepCount }) : undefined,
+ },
+ {
+ label: t("dashboardFollowUpsDue"),
+ value: reminderJobs.length,
+ trend: summaryView.overdueFollowUpCount > 0
+ ? { text: t("dashboardOverdueCount", { count: summaryView.overdueFollowUpCount }), tone: "error" }
+ : undefined,
+ },
+ ];
+ return cards;
+ }, [reminderJobs.length, stats?.active, stats?.appliedLast30Days, summaryView.interviewCount, summaryView.interviewPrepCount, summaryView.overdueFollowUpCount, summaryView.topSource, t, trendsView.responseRate]);
const togglePref = useCallback((key: keyof Prefs) => {
setPrefs((current) => {
@@ -286,85 +278,67 @@ export default function DashboardView() {
navigate(buildWorkflowPath(job));
}, [navigate]);
+ const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1;
+
return (
0} />
-
-
-
-
- {t("dashboardHeroLabel")}
-
-
- {t("dashboardOverviewTitle")}
-
-
- {t("dashboardOverviewBody")}
-
-
-
-
-
-
-
+
+
+
+ {t("dashboardHeroLabel")}
+
+
+ {t("dashboardOverviewTitle")}
+
+
+ {t("dashboardOverviewBody")}
+
-
- {([6, 12, 24] as const).map((m) => (
-
- ))}
- } onClick={(e) => setPrefsAnchor(e.currentTarget)}>
- {t("dashboardCustomize")}
-
-
-
+
+
+
+
+
-
+
+
+ {([6, 12, 24] as const).map((m) => (
+
+ ))}
+ } onClick={(e) => setPrefsAnchor(e.currentTarget)}>
+ {t("dashboardCustomize")}
+
+
+
+
{metricCards.map((card) => (
-
-
- {card.label}
- {card.value}
- {card.sub}
+ {card.label}
+ {card.value}
+ {card.trend || card.caption ? (
+
+ {card.trend ? (
+ <>
+ {card.trend.tone === "success" ? (
+
+ ) : (
+
+ )}
+
+ {card.trend.text}
+
+ >
+ ) : (
+ {card.caption}
+ )}
-
- {card.icon}
-
-
-
-
-
+ ) : null}
))}
@@ -409,12 +390,18 @@ export default function DashboardView() {
- {t("dashboardApplicationActivity")}
+ {t("dashboardApplicationActivity")}
{t("dashboardMonthlyApplicationsResponses")}
-
-
-
+
+
+
+ {t("dashboardAppliedCount", { count: trendsView.totalApplied })}
+
+
+
+ {t("dashboardResponsesCount", { count: trendsView.totalResponses })}
+
@@ -432,8 +419,8 @@ export default function DashboardView() {
strokeDasharray="6 6"
/>
))}
- {trendsView.responsePath ? : null}
- {trendsView.appliedPath ? : null}
+ {trendsView.responsePath ? : null}
+ {trendsView.appliedPath ? : null}
{analytics.map((point) => (
@@ -449,9 +436,55 @@ export default function DashboardView() {
{!summaryResource.loading && !summaryResource.error ? (
- {t("dashboardConversionFunnelTitle")}
- {t("dashboardResponseSources")}
-
+ {t("dashboardTimeInStageTitle")}
+ {overview?.timeInStage?.length ? (
+
+ {overview.timeInStage.map((item, index) => (
+
+
+ {statusLabel(t, item.stage)}
+
+ {t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
+
+
+
+
+ ))}
+
+ ) : (
+ {t("dashboardNoTagsYet")}
+ )}
+
+ {tags.length ? (
+
+ {t("dashboardTopSkills")}
+
+ {tags.slice(0, 4).map((tag) => (
+
+ ))}
+
+
+ ) : null}
+
+ {t("dashboardConversionFunnelTitle")}
+
{(overview?.funnel ?? []).map((item) => {
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
return (
@@ -478,25 +511,9 @@ export default function DashboardView() {
})}
- {overview?.timeInStage?.length ? (
-
- {t("dashboardTimeInStageTitle")}
-
- {overview.timeInStage.map((item) => (
-
- {statusLabel(t, item.stage)}
-
- {t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
-
-
- ))}
-
-
- ) : null}
-
{summaryView.topSource?.label ?? t("dashboardResponseSources")}
- {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}
+ {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
@@ -508,7 +525,7 @@ export default function DashboardView() {
{!summaryResource.loading && !summaryResource.error ? (
- {t("remindersTitle")}
+ {t("remindersTitle")}
{t("remindersSubtitle")}
{summaryView.priorityJobs.length === 0 ? (
{t("remindersNothing")}
@@ -516,13 +533,40 @@ export default function DashboardView() {
{summaryView.priorityJobs.map((job) => {
const action = getReminderAction(job);
+ const tone = reminderTone(job);
+ const toneColor = theme.palette[tone].main;
+ const urgent = tone === "error";
return (
-
-
- {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}
- {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}
+
+
+
+
+ {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}
+ {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}
+
-
@@ -538,7 +582,7 @@ export default function DashboardView() {
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
- {t("dashboardTopCompaniesByActivity")}
+ {t("dashboardTopCompaniesByActivity")}
{(overview?.topCompanies ?? []).map((item, index) => (
@@ -557,7 +601,7 @@ export default function DashboardView() {
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
- {t("dashboardTopSkills")}
+ {t("dashboardTopSkills")}
{tags.length === 0 ? (
{t("dashboardNoTagsYet")}
) : (
@@ -580,7 +624,7 @@ export default function DashboardView() {
)}
- {t("dashboardSkillTrends")}
+ {t("dashboardSkillTrends")}
{!tagTrends || tagTrends.series.length === 0 ? (
{t("dashboardNoTagTrendData")}
) : (
diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx
index 81d0164..7cc5269 100644
--- a/job-tracker-ui/src/components/KanbanBoard.tsx
+++ b/job-tracker-ui/src/components/KanbanBoard.tsx
@@ -4,6 +4,7 @@ import {
Box,
Card,
CardContent,
+ Chip,
IconButton,
Menu,
MenuItem,
@@ -36,6 +37,36 @@ function toneColor(theme: any, status: Status | "Other"): string {
return TONE_PALETTE[statusTone(status)](theme);
}
+// Mockup's Applied-column cards carry small skill-tag pills ("React", "TS") sourced from the
+// job's own `tags` field (JSON array string) -- no new data, just parsed for display.
+function parseTags(raw?: string): string[] {
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(0, 2) : [];
+ } catch {
+ return [];
+ }
+}
+
+type PillTone = "error" | "warning" | "success" | "info";
+
+// Mockup's single status pill per card ("Reply due in 2d", "Tomorrow 14:00", "720k NOK / yr").
+// Built entirely from fields the board already fetches (needsFollowUp/followUpAt/salary/deadline)
+// -- there's no interview-datetime field on the board's JobApplication shape, so an "Interview
+// tomorrow 14:00"-style pill isn't reproducible; deadline is used as the closest real fallback.
+function cardPill(job: JobApplication, t: (key: any, params?: any) => string): { text: string; tone: PillTone } | null {
+ if (job.needsFollowUp) {
+ const due = job.followUpAt ? new Date(job.followUpAt) : null;
+ if (!due || due.getTime() <= Date.now()) return { text: t("kanbanFollowUpNow"), tone: "error" };
+ const days = Math.max(1, Math.ceil((due.getTime() - Date.now()) / 86400000));
+ return { text: t("kanbanReplyDueIn", { days }), tone: "warning" };
+ }
+ if (job.salary) return { text: job.salary, tone: "success" };
+ if (job.deadline) return { text: new Date(job.deadline).toLocaleDateString(), tone: "info" };
+ return null;
+}
+
export default function KanbanBoard() {
const theme = useTheme();
const { t } = useI18n();
@@ -129,7 +160,7 @@ export default function KanbanBoard() {
scrollSnapAlign: { xs: "start", md: "none" },
border: "1px solid",
borderColor: "divider",
- background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
+ backgroundColor: theme.palette.grey[100],
}}
>
@@ -139,50 +170,93 @@ export default function KanbanBoard() {
{statusLabel(t, status)}
-
- {list.length}
-
+
+
+ {list.length}
+
+
- {list.map((j) => (
- setDragJobId(j.id)}
- onDragEnd={() => setDragJobId(null)}
- sx={{
- cursor: "grab",
- borderRadius: 2.5,
- borderLeft: `4px solid ${c}`,
- boxShadow: theme.palette.mode === "dark" ? "none" : "0 1px 3px rgba(15,23,42,0.06)",
- }}
- >
-
-
-
- {j.jobTitle}
+ {list.map((j) => {
+ const pill = cardPill(j, t);
+ const tags = parseTags(j.tags);
+ return (
+ setDragJobId(j.id)}
+ onDragEnd={() => setDragJobId(null)}
+ sx={{
+ cursor: "grab",
+ borderRadius: 2.5,
+ borderLeft: `4px solid ${c}`,
+ }}
+ >
+
+
+
+ {j.jobTitle}
+
+ {
+ e.stopPropagation();
+ setMenuJobId(j.id);
+ setMenuAnchor(e.currentTarget);
+ }}
+ >
+
+
+
+
+ {[j.company?.name, j.location].filter(Boolean).join(" · ")}
- {
- e.stopPropagation();
- setMenuJobId(j.id);
- setMenuAnchor(e.currentTarget);
- }}
- >
-
-
-
-
- {[j.company?.name, j.location].filter(Boolean).join(" · ")}
-
-
- {j.daysSince}d
-
-
-
- ))}
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ ))}
+
+ )}
+
+ {pill && (
+
+
+
+ )}
+
+
+ {t("kanbanAppliedAgo", { days: j.daysSince })}
+
+
+
+ );
+ })}
{list.length === 0 && (
{t("kanbanDropHere")}
diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts
index 6268bcf..41de380 100644
--- a/job-tracker-ui/src/i18n/translations.ts
+++ b/job-tracker-ui/src/i18n/translations.ts
@@ -404,6 +404,11 @@ export const translations = {
dashboardDaysUntilFirstReply: "Days until first reply",
dashboardResponsesLogged: "Responses logged",
dashboardAcrossActiveJobs: "Across active jobs",
+ dashboardResponseRateTile: "Response rate",
+ dashboardInterviews: "Interviews",
+ dashboardUpcomingCount: "{count} upcoming",
+ dashboardOverdueCount: "{count} overdue",
+ dashboardFollowUpsDue: "Follow-ups due",
dashboardLowReadiness: "Low readiness",
dashboardMissingTailoredCv: "Reminder jobs missing tailored CV",
dashboardApplicationActivity: "Application activity",
@@ -476,6 +481,9 @@ export const translations = {
adminUsersPassword: "Password",
kanbanHint: "Drag cards between columns to update status.",
kanbanDropHere: "Drop here",
+ kanbanAppliedAgo: "Applied {days}d ago",
+ kanbanFollowUpNow: "Follow up now",
+ kanbanReplyDueIn: "Reply due in {days}d",
adminSystemTitle: "System status",
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, AI service health, and OCR readiness.",
adminSystemRunProbe: "Run probe now",
@@ -1458,6 +1466,11 @@ export const translations = {
dashboardDaysUntilFirstReply: "Dager til første svar",
dashboardResponsesLogged: "Registrerte svar",
dashboardAcrossActiveJobs: "På tvers av aktive jobber",
+ dashboardResponseRateTile: "Svarrate",
+ dashboardInterviews: "Intervjuer",
+ dashboardUpcomingCount: "{count} kommende",
+ dashboardOverdueCount: "{count} forfalt",
+ dashboardFollowUpsDue: "Oppfølginger som venter",
dashboardLowReadiness: "Lav beredskap",
dashboardMissingTailoredCv: "Påminnelsesjobber uten skreddersydd CV",
dashboardApplicationActivity: "Søknadsaktivitet",
@@ -1530,6 +1543,9 @@ export const translations = {
adminUsersPassword: "Passord",
kanbanHint: "Dra kort mellom kolonnene for å oppdatere status.",
kanbanDropHere: "Slipp her",
+ kanbanAppliedAgo: "Søkt for {days}d siden",
+ kanbanFollowUpNow: "Følg opp nå",
+ kanbanReplyDueIn: "Svar forfaller om {days}d",
adminSystemTitle: "Systemstatus",
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post, AI-tjenestehelse og OCR-beredskap.",
adminSystemRunProbe: "Kjør probe nå",