9adbde3f5e
- 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
489 lines
22 KiB
TypeScript
489 lines
22 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import {
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Chip,
|
|
LinearProgress,
|
|
Menu,
|
|
MenuItem,
|
|
Paper,
|
|
Stack,
|
|
Typography,
|
|
} from "@mui/material";
|
|
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 { api } from "../api";
|
|
import { getUserKeyFromToken } from "../themePrefs";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
|
import { JobApplication } from "../types";
|
|
|
|
interface JobStats {
|
|
total: number;
|
|
active: number;
|
|
deleted: number;
|
|
byStatus: Record<string, number>;
|
|
appliedLast30Days: number;
|
|
averageDaysSinceApplied: number;
|
|
}
|
|
|
|
type ReminderJob = JobApplication;
|
|
|
|
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
|
type TagPoint = { tag: string; count: number };
|
|
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));
|
|
}
|
|
|
|
function buildLinePath(values: number[], width: number, height: number) {
|
|
if (!values.length) return "";
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
const step = width / Math.max(1, values.length - 1);
|
|
const yFor = (value: number) => {
|
|
const t = max === min ? 0.5 : (value - min) / (max - min);
|
|
return height - t * height;
|
|
};
|
|
|
|
return values
|
|
.map((value, index) => `${index === 0 ? "M" : "L"} ${Math.round(index * step)} ${Math.round(yFor(value))}`)
|
|
.join(" ");
|
|
}
|
|
|
|
function MiniSpark({ values, color }: { values: number[]; color: string }) {
|
|
const width = 180;
|
|
const height = 52;
|
|
const path = buildLinePath(values, width, height);
|
|
|
|
return (
|
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
|
<path d={path} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
|
|
return (
|
|
<Paper
|
|
sx={{
|
|
p: 2.25,
|
|
borderRadius: 4,
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
background: "background.paper",
|
|
boxShadow: "0 18px 50px rgba(15, 23, 42, 0.06)",
|
|
...sx,
|
|
}}
|
|
>
|
|
{children}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export default function DashboardView() {
|
|
const theme = useTheme();
|
|
const navigate = useNavigate();
|
|
const { t } = useI18n();
|
|
const [stats, setStats] = useState<JobStats | null>(null);
|
|
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
|
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null);
|
|
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
|
|
const [tags, setTags] = useState<TagPoint[]>([]);
|
|
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
|
const [reminderJobs, setReminderJobs] = useState<ReminderJob[]>([]);
|
|
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));
|
|
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderJobs(Array.isArray(r.data) ? r.data : [])).catch(() => setReminderJobs([]));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const params = { 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<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
|
}, [months]);
|
|
|
|
const appliedValues = analytics.map((x) => x.applied);
|
|
const responseValues = analytics.map((x) => x.responses);
|
|
const chartWidth = 860;
|
|
const chartHeight = 250;
|
|
const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight);
|
|
const responsePath = buildLinePath(responseValues, chartWidth, chartHeight);
|
|
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.workflowSignal?.hasPackageGap).length;
|
|
|
|
const metricCards = [
|
|
{
|
|
label: t("dashboardActiveApplications"),
|
|
value: stats?.active ?? 0,
|
|
sub: t("dashboardCurrentlyInProgress"),
|
|
icon: <TrendingUpIcon fontSize="small" />,
|
|
tone: theme.palette.primary.main,
|
|
spark: appliedValues,
|
|
},
|
|
{
|
|
label: t("dashboardApplied30Days"),
|
|
value: stats?.appliedLast30Days ?? 0,
|
|
sub: t("dashboardNewApplications"),
|
|
icon: <AutoGraphIcon fontSize="small" />,
|
|
tone: theme.palette.success.main,
|
|
spark: appliedValues.slice(-6),
|
|
},
|
|
{
|
|
label: t("dashboardMedianFirstResponse"),
|
|
value: overview?.medianDaysToFirstResponse ?? "—",
|
|
sub: t("dashboardDaysUntilFirstReply"),
|
|
icon: <MailOutlineIcon fontSize="small" />,
|
|
tone: theme.palette.info.main,
|
|
spark: responseValues,
|
|
},
|
|
{
|
|
label: t("dashboardResponsesLogged"),
|
|
value: overview?.totalResponses ?? 0,
|
|
sub: t("dashboardAcrossActiveJobs"),
|
|
icon: <BusinessOutlinedIcon fontSize="small" />,
|
|
tone: theme.palette.warning.main,
|
|
spark: responseValues.slice(-6),
|
|
},
|
|
];
|
|
|
|
const togglePref = (key: keyof Prefs) => {
|
|
const next = { ...prefs, [key]: !prefs[key] };
|
|
setPrefs(next);
|
|
savePrefs(next);
|
|
};
|
|
|
|
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
|
|
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) => {
|
|
navigate(buildWorkflowPath(job));
|
|
};
|
|
|
|
return (
|
|
<Box>
|
|
<SectionCard
|
|
sx={{
|
|
background: theme.palette.mode === "dark"
|
|
? `radial-gradient(circle at top left, ${alpha(theme.palette.primary.main, 0.26)}, transparent 35%), linear-gradient(135deg, rgba(15,23,42,0.94), rgba(15,23,42,0.78))`
|
|
: `radial-gradient(circle at top left, ${alpha(theme.palette.primary.main, 0.18)}, transparent 35%), linear-gradient(135deg, rgba(255,255,255,0.98), rgba(248,250,252,0.96))`,
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
|
<Box sx={{ maxWidth: 760 }}>
|
|
<Typography variant="overline" sx={{ color: "primary.main", fontWeight: 800 }}>
|
|
{t("dashboardHeroLabel")}
|
|
</Typography>
|
|
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6 }}>
|
|
{t("dashboardOverviewTitle")}
|
|
</Typography>
|
|
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1.25, maxWidth: 680 }}>
|
|
{t("dashboardOverviewBody")}
|
|
</Typography>
|
|
|
|
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2.25, flexWrap: "wrap" }}>
|
|
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: responseRate })} />
|
|
<Chip variant="outlined" label={`${missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
|
<Chip variant="outlined" label={topSource ? `${topSource.label}: ${topSource.rate}%` : t("dashboardResponseSources")} />
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
{([6, 12, 24] as const).map((m) => (
|
|
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
|
{t("dashboardMonthsShort", { count: m })}
|
|
</Button>
|
|
))}
|
|
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
|
{t("dashboardCustomize")}
|
|
</Button>
|
|
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
|
|
{[
|
|
["cards", t("dashboardSummaryCards")],
|
|
["activity", t("dashboardActivityChart")],
|
|
["funnel", t("dashboardConversionFunnel")],
|
|
["companies", t("dashboardTopCompanies")],
|
|
["skills", t("dashboardSkillsInsights")],
|
|
].map(([key, label]) => (
|
|
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
|
|
<Checkbox checked={prefs[key as keyof Prefs]} />
|
|
{label}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</Box>
|
|
</Box>
|
|
</SectionCard>
|
|
|
|
{prefs.cards ? (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
|
|
{metricCards.map((card) => (
|
|
<SectionCard key={card.label}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2 }}>
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
|
|
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5 }}>{card.value}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>{card.sub}</Typography>
|
|
</Box>
|
|
<Box sx={{ width: 42, height: 42, borderRadius: 3, display: "grid", placeItems: "center", backgroundColor: alpha(card.tone, 0.12), color: card.tone }}>
|
|
{card.icon}
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ mt: 1.5 }}>
|
|
<MiniSpark values={card.spark.length ? card.spark : [0, 0, 0]} color={alpha(card.tone, 0.95)} />
|
|
</Box>
|
|
</SectionCard>
|
|
))}
|
|
</Box>
|
|
) : null}
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
|
|
{prefs.activity ? (
|
|
<SectionCard>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
|
<Box>
|
|
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardApplicationActivity")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
|
</Box>
|
|
<Stack direction="row" spacing={1} flexWrap="wrap">
|
|
<Chip size="small" label={t("dashboardAppliedCount", { count: totalApplied })} variant="outlined" />
|
|
<Chip size="small" label={t("dashboardResponsesCount", { count: totalResponses })} variant="outlined" />
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
|
<Box sx={{ minWidth: chartWidth }}>
|
|
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
|
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
|
|
<line
|
|
key={tick}
|
|
x1="0"
|
|
x2={chartWidth}
|
|
y1={Math.round(chartHeight * tick)}
|
|
y2={Math.round(chartHeight * tick)}
|
|
stroke={alpha(theme.palette.text.primary, 0.08)}
|
|
strokeDasharray="6 6"
|
|
/>
|
|
))}
|
|
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
|
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
|
</svg>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
|
|
{analytics.map((point) => (
|
|
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
|
|
{point.month.slice(5)}
|
|
</Typography>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</SectionCard>
|
|
) : null}
|
|
|
|
<SectionCard>
|
|
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardResponseSources")}</Typography>
|
|
<Stack spacing={1.2}>
|
|
{(overview?.funnel ?? []).map((item) => {
|
|
const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0;
|
|
return (
|
|
<Box key={item.label}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
|
</Box>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={width}
|
|
sx={{
|
|
height: 10,
|
|
borderRadius: 999,
|
|
backgroundColor: alpha(theme.palette.primary.main, 0.08),
|
|
'& .MuiLinearProgress-bar': {
|
|
borderRadius: 999,
|
|
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
|
|
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>{topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
|
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{topSource ? `${topSource.rate}%` : "—"}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
|
{topSource ? t("dashboardResponseConversion", { responses: topSource.responses, total: topSource.total }) : t("dashboardNoSourceData")}
|
|
</Typography>
|
|
</Box>
|
|
</SectionCard>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
|
|
<SectionCard>
|
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("remindersTitle")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
|
|
{priorityJobs.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
|
) : (
|
|
<Stack spacing={1.1}>
|
|
{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" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
|
</Box>
|
|
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
|
{action?.label ?? t("remindersOpen")}
|
|
</Button>
|
|
</Box>
|
|
)})}
|
|
</Stack>
|
|
)}
|
|
<Box sx={{ mt: 1.5 }}>
|
|
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
|
|
</Box>
|
|
</SectionCard>
|
|
|
|
{prefs.companies ? (
|
|
<SectionCard>
|
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
|
<Stack spacing={1.25}>
|
|
{(overview?.topCompanies ?? []).map((item, index) => (
|
|
<Box key={item.companyId} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, index === 0 ? 0.05 : 0.02) }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 900 }}>{item.company}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
|
|
</Box>
|
|
<Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" />
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</SectionCard>
|
|
) : null}
|
|
|
|
{prefs.skills ? (
|
|
<SectionCard>
|
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
|
{tags.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
|
|
) : (
|
|
<Stack spacing={1.15}>
|
|
{tags.slice(0, 8).map((tag, index) => {
|
|
const max = Math.max(...tags.map((item) => item.count), 1);
|
|
const width = (tag.count / max) * 100;
|
|
return (
|
|
<Box key={tag.tag}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, mb: 0.5 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>{tag.tag}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{tag.count}</Typography>
|
|
</Box>
|
|
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
|
<Box sx={{ width: `${width}%`, height: "100%", borderRadius: 999, bgcolor: tagColors[index % tagColors.length] }} />
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
)}
|
|
|
|
<Typography variant="h6" sx={{ fontWeight: 950, mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
|
{!tagTrends || tagTrends.series.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography>
|
|
) : (
|
|
<Stack spacing={1.1}>
|
|
{tagTrends.series.map((series, index) => (
|
|
<Box key={series.tag}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((sum, value) => sum + value, 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: 18,
|
|
borderRadius: 1.25,
|
|
backgroundColor: count > 0 ? alpha(tagColors[index % tagColors.length], 0.22 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.05),
|
|
}}
|
|
title={`${tagTrends.months[i]}: ${count}`}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</SectionCard>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|