Dashboard upgrades, workflows added and assitant emailer

This commit is contained in:
cesnimda
2026-03-21 13:25:13 +01:00
parent 8cc4b0dfce
commit 51a539068f
9 changed files with 1358 additions and 1421 deletions
+279 -469
View File
@@ -1,9 +1,23 @@
import React, { useEffect, useMemo, useState } from "react";
import { Box, Button, ButtonGroup, Divider, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import {
Box,
Button,
ButtonGroup,
Checkbox,
Menu,
MenuItem,
Paper,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
interface JobStats {
total: number;
@@ -29,6 +43,41 @@ type SummarizerMetrics = {
lastFailureAt?: string | null;
lastError?: string | null;
};
type OverviewAnalytics = {
funnel: { label: string; count: number }[];
responseRateBySource: { label: string; total: number; responses: number; rate: number }[];
topCompanies: { companyId: number; company: string; count: number; responses: number; responseRate: number }[];
medianDaysToFirstResponse?: number | null;
totalResponses: number;
totalActive: number;
};
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
type Prefs = {
cards: boolean;
activity: boolean;
funnel: boolean;
companies: boolean;
skills: boolean;
};
function prefsKey() {
return `dashboardPrefs:${getUserKeyFromToken()}`;
}
function loadPrefs(): Prefs {
try {
const raw = window.localStorage.getItem(prefsKey());
if (!raw) return { cards: true, activity: true, funnel: true, companies: true, skills: true };
return { cards: true, activity: true, funnel: true, companies: true, skills: true, ...JSON.parse(raw) };
} catch {
return { cards: true, activity: true, funnel: true, companies: true, skills: true };
}
}
function savePrefs(next: Prefs) {
window.localStorage.setItem(prefsKey(), JSON.stringify(next));
}
function clamp(n: number, a: number, b: number) {
return Math.max(a, Math.min(b, n));
@@ -43,68 +92,55 @@ function toPath(values: number[], w: number, h: number) {
const t = max === min ? 0.5 : (v - min) / (max - min);
return h - t * h;
};
return values
.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`)
.join(" ");
return values.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" ");
}
function formatRelative(ts?: string | null) {
if (!ts) return "Never";
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return "Unknown";
const deltaMs = Date.now() - d.getTime();
const mins = Math.round(deltaMs / 60000);
const mins = Math.round((Date.now() - d.getTime()) / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
return `${Math.round(hours / 24)}d ago`;
}
export default function DashboardView() {
const theme = useTheme();
const [stats, setStats] = useState<JobStats | null>(null);
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null);
const [tab, setTab] = useState(0);
const [rangeMode, setRangeMode] = useState<"preset" | "custom">("preset");
const [months, setMonths] = useState<6 | 12 | 24>(12);
const [fromMonth, setFromMonth] = useState(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 11, 1);
return start.toISOString().slice(0, 7);
});
const [fromMonth, setFromMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth() - 11, 1).toISOString().slice(0, 7));
const [toMonth, setToMonth] = useState(() => new Date().toISOString().slice(0, 7));
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null);
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
const [tags, setTags] = useState<TagPoint[]>([]);
const [summarizerMetrics, setSummarizerMetrics] = useState<SummarizerMetrics | null>(null);
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
useEffect(() => {
api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data));
api.get<OverviewAnalytics>("/jobapplications/analytics-overview").then((r) => setOverview(r.data)).catch(() => setOverview(null));
}, []);
useEffect(() => {
const params =
rangeMode === "custom" && appliedCustom
? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` }
: { months };
const params = rangeMode === "custom" && appliedCustom ? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` } : { months };
api
.get<AnalyticsPoint[]>("/jobapplications/analytics", { params })
.then((r) => setAnalytics(r.data ?? []))
.catch(() => setAnalytics([]));
api
.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } })
.then((r) => setTags(r.data ?? []))
.catch(() => setTags([]));
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([]));
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([]));
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months: rangeMode === "custom" ? 6 : months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
}, [months, rangeMode, appliedCustom]);
useEffect(() => {
if (tab !== 2) return;
let cancelled = false;
let intervalId: number | undefined;
const loadMetrics = async () => {
const load = async () => {
try {
const res = await api.get<SummarizerMetrics>("/jobapplications/summarizer-metrics");
if (!cancelled) setSummarizerMetrics(res.data);
@@ -112,117 +148,45 @@ export default function DashboardView() {
if (!cancelled) setSummarizerMetrics(null);
}
};
if (tab === 2) {
void loadMetrics();
intervalId = window.setInterval(() => {
void loadMetrics();
}, 30000);
}
void load();
const id = window.setInterval(() => void load(), 30000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
window.clearInterval(id);
};
}, [tab]);
const statusRows = useMemo(() => {
const by = stats?.byStatus ?? {};
return Object.entries(by).sort((a, b) => b[1] - a[1]);
}, [stats]);
const statusRows = useMemo(() => Object.entries(stats?.byStatus ?? {}).sort((a, b) => b[1] - a[1]), [stats]);
const maxStatus = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0;
const chartW = 860;
const chartH = 260;
const appliedSeries = analytics.map((x) => x.applied);
const responseSeries = analytics.map((x) => x.responses);
const appliedPath = toPath(appliedSeries, chartW, chartH);
const responsePath = toPath(responseSeries, chartW, chartH);
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
const tagTotal = tags.reduce((acc, item) => acc + item.count, 0);
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((x) => x.count)) : 0;
const appliedSeries = useMemo(() => analytics.map((x) => x.applied), [analytics]);
const responseSeries = useMemo(() => analytics.map((x) => x.responses), [analytics]);
const totalAppliedInRange = useMemo(() => appliedSeries.reduce((sum, value) => sum + value, 0), [appliedSeries]);
const totalResponsesInRange = useMemo(() => responseSeries.reduce((sum, value) => sum + value, 0), [responseSeries]);
const topSkill = tags[0];
const topStatus = statusRows[0];
const strongestMonth = useMemo(() => {
if (!analytics.length) return null;
return analytics.reduce((best, current) => (current.applied > best.applied ? current : best), analytics[0]);
}, [analytics]);
const metricCards = [
{ label: "Active applications", value: stats?.active ?? "-", sub: "Currently in progress" },
{ label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" },
{ label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" },
{ label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" },
{ label: "In trash", value: stats?.deleted ?? "-", sub: "Soft-deleted" },
];
const metricCards = useMemo(() => {
return [
{
label: "Active applications",
value: stats?.active ?? "-",
sub: "Currently in progress",
},
{
label: "Applied (30 days)",
value: stats?.appliedLast30Days ?? "-",
sub: "New applications",
},
{
label: "Average days",
value: stats?.averageDaysSinceApplied ?? "-",
sub: "Since applied",
},
{
label: "Responses logged",
value: totalResponsesInRange,
sub: "Current chart range",
},
{
label: "In trash",
value: stats?.deleted ?? "-",
sub: "Soft-deleted",
},
];
}, [stats, totalResponsesInRange]);
const pApplied = useMemo(() => toPath(appliedSeries, chartW, chartH), [appliedSeries]);
const pResponses = useMemo(() => toPath(responseSeries, chartW, chartH), [responseSeries]);
const appliedColor = theme.palette.success.main;
const responsesColor = theme.palette.info.main;
const tagColors = useMemo(() => {
return [
theme.palette.primary.main,
theme.palette.success.main,
theme.palette.warning.main,
theme.palette.info.main,
theme.palette.error.main,
alpha("#f97316", 0.9),
alpha("#14b8a6", 0.9),
alpha("#a855f7", 0.9),
alpha("#64748b", 0.9),
alpha("#0ea5e9", 0.9),
];
}, [theme.palette]);
const tagTotal = useMemo(() => tags.reduce((acc, t) => acc + (t.count || 0), 0), [tags]);
const hitRate = useMemo(() => {
const requests = summarizerMetrics?.requests ?? 0;
if (!requests) return null;
return Math.round(((summarizerMetrics?.cacheHits ?? 0) / requests) * 100);
}, [summarizerMetrics]);
const togglePref = (key: keyof Prefs) => {
const next = { ...prefs, [key]: !prefs[key] };
setPrefs(next);
savePrefs(next);
};
const StatCard = ({ label, value, sub }: { label: string; value: React.ReactNode; sub: string }) => (
<Box
sx={{
p: 2,
minHeight: 118,
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<Typography variant="overline" sx={{ color: "text.secondary", lineHeight: 1.4 }}>
{label}
</Typography>
<Typography variant="h4" sx={{ mt: 0.5, fontWeight: 950, lineHeight: 1.1 }}>
{value}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
{sub}
</Typography>
<Box sx={{ p: 2, minHeight: 118, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
<Typography variant="h4" sx={{ mt: 0.5, fontWeight: 950 }}>{value}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>{sub}</Typography>
</Box>
);
@@ -234,167 +198,179 @@ export default function DashboardView() {
<Tab label="Summarizer" />
</Tabs>
{tab !== 2 ? (
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
Customize dashboard
</Button>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{[
["cards", "Summary cards"],
["activity", "Activity chart"],
["funnel", "Conversion funnel"],
["companies", "Top companies"],
["skills", "Skills insights"],
].map(([key, label]) => (
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
<Checkbox checked={prefs[key as keyof Prefs]} />
{label}
</MenuItem>
))}
</Menu>
</Box>
) : null}
{tab === 0 ? (
<>
<Paper sx={{ p: 0.5 }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" },
}}
>
{metricCards.map((m, idx) => (
<Box
key={m.label}
sx={{
borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` },
borderTop: {
xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`,
sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`,
xl: "none",
},
}}
>
<StatCard label={m.label} value={m.value} sub={m.sub} />
</Box>
))}
</Box>
</Paper>
<Paper sx={{ mt: 2, p: 2.25 }}>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950 }}>
Application activity
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Color-coded monthly trend for applications and responses.
</Typography>
</Box>
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 1 }}>
<ButtonGroup size="small" variant="outlined">
{([6, 12, 24] as const).map((m) => (
<Button
key={m}
variant={rangeMode === "preset" && months === m ? "contained" : "outlined"}
onClick={() => {
setRangeMode("preset");
setMonths(m);
}}
>
{m} mo
</Button>
))}
<Button
variant={rangeMode === "custom" ? "contained" : "outlined"}
onClick={() => {
setRangeMode("custom");
setAppliedCustom({ from: fromMonth, to: toMonth });
}}
>
Custom
</Button>
</ButtonGroup>
{rangeMode === "custom" ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} sx={{ width: 150 }} />
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} sx={{ width: 150 }} />
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>
Apply
</Button>
{prefs.cards ? (
<Paper sx={{ p: 0.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
{metricCards.map((m, idx) => (
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
<StatCard label={m.label} value={m.value} sub={m.sub} />
</Box>
) : null}
))}
</Box>
</Box>
</Paper>
) : null}
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ width: 12, height: 12, borderRadius: 999, bgcolor: appliedColor }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>Applied</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ width: 12, height: 12, borderRadius: 999, bgcolor: responsesColor }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>Responses</Typography>
</Box>
</Box>
<Box sx={{ mt: 2, overflowX: "auto" }}>
<Box sx={{ minWidth: chartW }}>
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
<defs>
<linearGradient id="fillApplied" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={alpha(appliedColor, 0.35)} />
<stop offset="100%" stopColor={alpha(appliedColor, 0.03)} />
</linearGradient>
<linearGradient id="fillResponses" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={alpha(responsesColor, 0.28)} />
<stop offset="100%" stopColor={alpha(responsesColor, 0.03)} />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((tick) => (
<line
key={tick}
x1="0"
x2={chartW}
y1={Math.round(chartH * tick)}
y2={Math.round(chartH * tick)}
stroke={alpha(theme.palette.text.primary, 0.08)}
strokeDasharray="5 5"
/>
))}
{pResponses ? (
{prefs.activity ? (
<Paper sx={{ mt: 2, p: 2.25 }}>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950 }}>Application activity</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Monthly applications versus responses.</Typography>
</Box>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
<ButtonGroup size="small">
{([6, 12, 24] as const).map((m) => (
<Button key={m} variant={rangeMode === "preset" && months === m ? "contained" : "outlined"} onClick={() => { setRangeMode("preset"); setMonths(m); }}>{m} mo</Button>
))}
<Button variant={rangeMode === "custom" ? "contained" : "outlined"} onClick={() => { setRangeMode("custom"); setAppliedCustom({ from: fromMonth, to: toMonth }); }}>Custom</Button>
</ButtonGroup>
{rangeMode === "custom" ? (
<>
<path d={`${pResponses} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillResponses)" />
<path d={pResponses} fill="none" stroke={alpha(responsesColor, 0.95)} strokeWidth="2.5" />
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} />
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} />
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>Apply</Button>
</>
) : null}
{pApplied ? (
<>
<path d={`${pApplied} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillApplied)" />
<path d={pApplied} fill="none" stroke={alpha(appliedColor, 0.95)} strokeWidth="2.5" />
</>
) : null}
</svg>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1, color: "text.secondary" }}>
{analytics.map((p) => (
<Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>
{p.month.slice(5)}
</Typography>
))}
</Box>
</Box>
</Box>
</Paper>
<Box sx={{ mt: 2, overflowX: "auto" }}>
<Box sx={{ minWidth: chartW }}>
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
{[0.25, 0.5, 0.75].map((tick) => <line key={tick} x1="0" x2={chartW} y1={Math.round(chartH * tick)} y2={Math.round(chartH * tick)} stroke={alpha(theme.palette.text.primary, 0.08)} strokeDasharray="5 5" />)}
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="2.5" /> : null}
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="2.5" /> : null}
</svg>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>{analytics.map((p) => <Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>{p.month.slice(5)}</Typography>)}</Box>
</Box>
</Box>
</Paper>
) : null}
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(3, 1fr)" }, gap: 2 }}>
<Paper sx={{ p: 2.25 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Top skill</Typography>
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{topSkill?.tag ?? "No tags yet"}</Typography>
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
{topSkill ? `${topSkill.count} tagged jobs in the selected range.` : "Import or add tagged jobs to surface skill trends."}
</Typography>
</Paper>
<Paper sx={{ p: 2.25 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Busiest month</Typography>
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{strongestMonth?.month ?? "-"}</Typography>
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
{strongestMonth ? `${strongestMonth.applied} applications logged in your strongest month.` : "No monthly application data yet."}
</Typography>
</Paper>
<Paper sx={{ p: 2.25 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Top stage</Typography>
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{topStatus?.[0] ?? "No status data"}</Typography>
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
{topStatus ? `${topStatus[1]} applications are currently clustered in this stage.` : "Add a few jobs to reveal your pipeline shape."}
</Typography>
</Paper>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
{prefs.funnel ? (
<Paper sx={{ p: 2.25 }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Conversion funnel</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{(overview?.funnel ?? []).map((item) => (
<Box key={item.label} sx={{ display: "grid", gridTemplateColumns: "140px 1fr 50px", gap: 1, alignItems: "center" }}>
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
<Box sx={{ width: `${funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.9)}, ${alpha(theme.palette.primary.main, 0.3)})` }} />
</Box>
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
</Box>
))}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>Response sources</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
{(overview?.responseRateBySource ?? []).map((item) => (
<Box key={item.label} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Typography variant="body2">{item.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{item.rate}%</Typography>
</Box>
))}
</Box>
</Paper>
) : null}
{prefs.companies ? (
<Paper sx={{ p: 2.25 }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top companies by activity</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{(overview?.topCompanies ?? []).map((item) => (
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
<Typography sx={{ fontWeight: 800 }}>{item.company}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count} jobs</Typography>
<Typography variant="body2" sx={{ fontWeight: 900 }}>{item.responseRate}%</Typography>
</Box>
))}
</Box>
</Paper>
) : null}
</Box>
{prefs.skills ? (
<Paper sx={{ mt: 2, p: 2.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top skills</Typography>
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography> : (
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<svg width="132" height="132" viewBox="0 0 132 132">
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.1)} strokeWidth="14" fill="none" />
{(() => {
const r = 52;
const circ = 2 * Math.PI * r;
let offset = 0;
return tags.map((t, i) => {
const len = circ * (tagTotal ? t.count / tagTotal : 0);
const el = <circle key={t.tag} cx="66" cy="66" r={r} fill="none" stroke={tagColors[i % tagColors.length]} strokeWidth="14" strokeDasharray={`${len} ${circ}`} strokeDashoffset={-offset} transform="rotate(-90 66 66)" />;
offset += len;
return el;
});
})()}
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>{tagTotal}</text>
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>skill tags</text>
</svg>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
{tags.slice(0, 8).map((t, i) => <Box key={t.tag} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Box sx={{ display: "flex", alignItems: "center", gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length] }} /><Typography variant="body2" sx={{ fontWeight: 700 }}>{t.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{t.count}</Typography></Box>)}
</Box>
</Box>
)}
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Skill trends</Typography>
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tag trend data yet.</Typography> : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{tagTrends.series.map((series, idx) => (
<Box key={series.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
<Typography sx={{ fontWeight: 800 }}>{series.tag}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((a, b) => a + b, 0)} total</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
{series.counts.map((count, i) => (
<Box key={`${series.tag}-${i}`} sx={{ height: 14, borderRadius: 1, bgcolor: count > 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} />
))}
</Box>
</Box>
))}
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${tagTrends.months.length}, 1fr)`, gap: 0.5 }}>
{tagTrends.months.map((month) => <Typography key={month} variant="caption" sx={{ textAlign: "center", color: "text.secondary" }}>{month.slice(5)}</Typography>)}
</Box>
</Box>
)}
</Box>
</Box>
</Paper>
) : null}
</>
) : null}
@@ -402,214 +378,48 @@ export default function DashboardView() {
<Paper sx={{ p: 2.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 0.8fr" }, gap: 2.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
Status breakdown
</Typography>
{statusRows.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>No data yet.</Typography>
) : (
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Status breakdown</Typography>
{statusRows.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No data yet.</Typography> : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{statusRows.map(([status, value]) => {
const tone =
status === "Rejected"
? theme.palette.error.main
: status === "Waiting" || status === "Ghosted"
? theme.palette.warning.main
: status === "Offer"
? theme.palette.success.main
: status === "Interview"
? theme.palette.info.main
: theme.palette.primary.main;
const tone = status === "Rejected" ? theme.palette.error.main : status === "Waiting" || status === "Ghosted" ? theme.palette.warning.main : status === "Offer" ? theme.palette.success.main : status === "Interview" ? theme.palette.info.main : theme.palette.primary.main;
const w = maxStatus ? clamp(Math.round((value / maxStatus) * 100), 0, 100) : 0;
return (
<Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}>
<Typography sx={{ fontWeight: 850 }}>{status}</Typography>
<Box
sx={{
height: 10,
borderRadius: 999,
background: theme.palette.mode === "dark" ? alpha("#94a3b8", 0.16) : alpha("#0f172a", 0.08),
overflow: "hidden",
}}
>
<Box
sx={{
width: `${w}%`,
height: "100%",
background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})`,
}}
/>
</Box>
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography>
</Box>
);
return <Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}><Typography sx={{ fontWeight: 850 }}>{status}</Typography><Box sx={{ height: 10, borderRadius: 999, background: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}><Box sx={{ width: `${w}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})` }} /></Box><Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography></Box>;
})}
</Box>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" }, gap: 1.5, mt: 2.25 }}>
<Paper variant="outlined" sx={{ p: 1.75, borderRadius: 2.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Applications in range</Typography>
<Typography variant="h5" sx={{ fontWeight: 950 }}>{totalAppliedInRange}</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 1.75, borderRadius: 2.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Tracked skills</Typography>
<Typography variant="h5" sx={{ fontWeight: 950 }}>{tagTotal}</Typography>
</Paper>
</Box>
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
Top skills
</Typography>
{tags.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography>
) : (
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<svg width="132" height="132" viewBox="0 0 132 132">
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.10)} strokeWidth="14" fill="none" />
{(() => {
const r = 52;
const circ = 2 * Math.PI * r;
let offset = 0;
return tags.map((t, i) => {
const pct = tagTotal ? t.count / tagTotal : 0;
const len = circ * pct;
const el = (
<circle
key={t.tag}
cx="66"
cy="66"
r={r}
fill="none"
stroke={tagColors[i % tagColors.length]}
strokeWidth="14"
strokeDasharray={`${len} ${circ}`}
strokeDashoffset={-offset}
transform="rotate(-90 66 66)"
/>
);
offset += len;
return el;
});
})()}
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>
{tagTotal}
</text>
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>
skill tags
</text>
</svg>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Response rate by source</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{(overview?.responseRateBySource ?? []).map((item) => (
<Box key={item.label} sx={{ p: 1.25, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.responses} responses from {item.total} jobs</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mt: 0.5 }}>{item.rate}%</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, minWidth: 0 }}>
{tags.slice(0, 8).map((t, i) => (
<Box key={t.tag} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
<Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length], flex: "0 0 auto" }} />
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
{t.tag}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 900, flex: "0 0 auto" }}>
{t.count}
</Typography>
</Box>
))}
</Box>
</Box>
)}
))}
</Box>
</Box>
</Box>
</Paper>
) : null}
{tab === 2 ? (
<>
<Paper sx={{ p: 0.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" } }}>
{[
{
label: "Service status",
value: summarizerMetrics?.healthy ? "Healthy" : "Offline",
sub: summarizerMetrics?.model || "Summarizer health check",
},
{
label: "Health latency",
value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-",
sub: "Latest /health round-trip",
},
{
label: "Average latency",
value: summarizerMetrics?.averageLatencyMs != null ? `${summarizerMetrics.averageLatencyMs} ms` : "-",
sub: "Across API summary requests",
},
{
label: "Cache hit rate",
value: hitRate != null ? `${hitRate}%` : "-",
sub: "API-side memory cache reuse",
},
].map((m, idx) => (
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
<StatCard label={m.label} value={m.value} sub={m.sub} />
</Box>
))}
</Box>
<Paper sx={{ p: 2.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
{[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Average latency", value: summarizerMetrics?.averageLatencyMs != null ? `${summarizerMetrics.averageLatencyMs} ms` : "-", sub: "Across API summary requests" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastSuccessAt), sub: "Recent successful summary request" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
</Box>
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
<Typography variant="body2"><strong>Requests:</strong> {summarizerMetrics?.requests ?? 0}</Typography>
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
</Paper>
<Paper sx={{ mt: 2, p: 2.25 }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
Summarizer telemetry
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
Useful for spotting slowdowns, cache misses, or service outages in the local summarizer app.
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" }, gap: 2 }}>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography>
<Typography variant="h5" sx={{ fontWeight: 950, mb: 1.5 }}>{summarizerMetrics?.requests ?? 0}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 1 }}>
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>Hits</Typography>
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.cacheHits ?? 0}</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>Misses</Typography>
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.cacheMisses ?? 0}</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>Failures</Typography>
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.failures ?? 0}</Typography>
</Box>
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Last activity</Typography>
<Box sx={{ mt: 1.25, display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2"><strong>Last success:</strong> {formatRelative(summarizerMetrics?.lastSuccessAt)}</Typography>
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
<Typography variant="body2"><strong>Model:</strong> {summarizerMetrics?.model || "Unknown"}</Typography>
</Box>
</Paper>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="overline" sx={{ color: "text.secondary" }}>Last error</Typography>
<Typography variant="body2" sx={{ mt: 0.75, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>
{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}
</Typography>
</Paper>
</>
</Paper>
) : null}
</Box>
);