Optimize workspace and daily-loop surfaces
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -135,20 +135,22 @@ export default function DashboardView() {
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
|
||||
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
|
||||
const summaryResource = useViewResource(
|
||||
async () => {
|
||||
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
|
||||
api.get<JobStats>("/jobapplications/stats"),
|
||||
api.get<OverviewAnalytics>("/jobapplications/analytics-overview"),
|
||||
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
|
||||
]);
|
||||
const loadSummary = useCallback(async () => {
|
||||
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
|
||||
api.get<JobStats>("/jobapplications/stats"),
|
||||
api.get<OverviewAnalytics>("/jobapplications/analytics-overview"),
|
||||
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats: statsResponse.data,
|
||||
overview: overviewResponse.data,
|
||||
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
|
||||
};
|
||||
},
|
||||
return {
|
||||
stats: statsResponse.data,
|
||||
overview: overviewResponse.data,
|
||||
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
|
||||
};
|
||||
}, []);
|
||||
|
||||
const summaryResource = useViewResource(
|
||||
loadSummary,
|
||||
{
|
||||
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
|
||||
errorMessage: "Unable to load dashboard summary data right now.",
|
||||
@@ -156,21 +158,23 @@ export default function DashboardView() {
|
||||
},
|
||||
);
|
||||
|
||||
const trendsResource = useViewResource(
|
||||
async () => {
|
||||
const params = { months };
|
||||
const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }),
|
||||
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }),
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }),
|
||||
]);
|
||||
const loadTrends = useCallback(async () => {
|
||||
const params = { months };
|
||||
const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }),
|
||||
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }),
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
analytics: analyticsResponse.data ?? [],
|
||||
tags: tagsResponse.data ?? [],
|
||||
tagTrends: trendsResponse.data,
|
||||
};
|
||||
},
|
||||
return {
|
||||
analytics: analyticsResponse.data ?? [],
|
||||
tags: tagsResponse.data ?? [],
|
||||
tagTrends: trendsResponse.data,
|
||||
};
|
||||
}, [months]);
|
||||
|
||||
const trendsResource = useViewResource(
|
||||
loadTrends,
|
||||
{
|
||||
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
|
||||
errorMessage: "Unable to load dashboard trends right now.",
|
||||
@@ -185,25 +189,54 @@ export default function DashboardView() {
|
||||
const tags = trendsResource.data.tags;
|
||||
const tagTrends = trendsResource.data.tagTrends;
|
||||
|
||||
const appliedValues = analytics.map((x) => x.applied);
|
||||
const responseValues = analytics.map((x) => x.responses);
|
||||
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
|
||||
const chartHeight = isMobile ? 210 : 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 tagColors = useMemo(
|
||||
() => [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main],
|
||||
[theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main],
|
||||
);
|
||||
|
||||
const metricCards = [
|
||||
const summaryView = useMemo(() => {
|
||||
const topSource = overview?.responseRateBySource?.[0];
|
||||
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
|
||||
|
||||
return {
|
||||
funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0,
|
||||
topSource,
|
||||
missingCvCount,
|
||||
priorityJobs: reminderJobs.slice(0, 5),
|
||||
};
|
||||
}, [overview, reminderJobs]);
|
||||
|
||||
const trendsView = useMemo(() => {
|
||||
const appliedValues = analytics.map((x) => x.applied);
|
||||
const responseValues = analytics.map((x) => x.responses);
|
||||
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
|
||||
const chartHeight = isMobile ? 210 : 250;
|
||||
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
|
||||
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||
const maxTagCount = Math.max(...tags.map((item) => item.count), 1);
|
||||
|
||||
return {
|
||||
appliedValues,
|
||||
responseValues,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
appliedPath: buildLinePath(appliedValues, chartWidth, chartHeight),
|
||||
responsePath: buildLinePath(responseValues, chartWidth, chartHeight),
|
||||
totalApplied,
|
||||
totalResponses,
|
||||
responseRate: totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0,
|
||||
maxTagCount,
|
||||
};
|
||||
}, [analytics, isMobile, tags]);
|
||||
|
||||
const metricCards = useMemo(() => ([
|
||||
{
|
||||
label: t("dashboardActiveApplications"),
|
||||
value: stats?.active ?? 0,
|
||||
sub: t("dashboardCurrentlyInProgress"),
|
||||
icon: <TrendingUpIcon fontSize="small" />,
|
||||
tone: theme.palette.primary.main,
|
||||
spark: appliedValues,
|
||||
spark: trendsView.appliedValues,
|
||||
},
|
||||
{
|
||||
label: t("dashboardApplied30Days"),
|
||||
@@ -211,7 +244,7 @@ export default function DashboardView() {
|
||||
sub: t("dashboardNewApplications"),
|
||||
icon: <AutoGraphIcon fontSize="small" />,
|
||||
tone: theme.palette.success.main,
|
||||
spark: appliedValues.slice(-6),
|
||||
spark: trendsView.appliedValues.slice(-6),
|
||||
},
|
||||
{
|
||||
label: t("dashboardMedianFirstResponse"),
|
||||
@@ -219,7 +252,7 @@ export default function DashboardView() {
|
||||
sub: t("dashboardDaysUntilFirstReply"),
|
||||
icon: <MailOutlineIcon fontSize="small" />,
|
||||
tone: theme.palette.info.main,
|
||||
spark: responseValues,
|
||||
spark: trendsView.responseValues,
|
||||
},
|
||||
{
|
||||
label: t("dashboardResponsesLogged"),
|
||||
@@ -227,30 +260,28 @@ export default function DashboardView() {
|
||||
sub: t("dashboardAcrossActiveJobs"),
|
||||
icon: <BusinessOutlinedIcon fontSize="small" />,
|
||||
tone: theme.palette.warning.main,
|
||||
spark: responseValues.slice(-6),
|
||||
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]);
|
||||
|
||||
const togglePref = (key: keyof Prefs) => {
|
||||
const next = { ...prefs, [key]: !prefs[key] };
|
||||
setPrefs(next);
|
||||
savePrefs(next);
|
||||
};
|
||||
const togglePref = useCallback((key: keyof Prefs) => {
|
||||
setPrefs((current) => {
|
||||
const next = { ...current, [key]: !current[key] };
|
||||
savePrefs(next);
|
||||
return 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, {
|
||||
const getReminderAction = useCallback((job: ReminderJob) => getWorkflowAction(job, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
followUp: t("jobTableFollowUp"),
|
||||
interviewPrep: t("jobTableInterviewStage"),
|
||||
readiness: t("jobTableReadiness"),
|
||||
});
|
||||
}), [t]);
|
||||
|
||||
const openReminderJob = (job: ReminderJob) => {
|
||||
const openReminderJob = useCallback((job: ReminderJob) => {
|
||||
navigate(buildWorkflowPath(job));
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -287,9 +318,9 @@ export default function DashboardView() {
|
||||
</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")} />
|
||||
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
|
||||
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
||||
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -378,27 +409,27 @@ export default function DashboardView() {
|
||||
<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" />
|
||||
<Chip size="small" label={t("dashboardAppliedCount", { count: trendsView.totalApplied })} variant="outlined" />
|
||||
<Chip size="small" label={t("dashboardResponsesCount", { count: trendsView.totalResponses })} variant="outlined" />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
|
||||
<Box sx={{ minWidth: chartWidth }}>
|
||||
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
||||
<Box sx={{ minWidth: trendsView.chartWidth }}>
|
||||
<svg width={trendsView.chartWidth} height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.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)}
|
||||
x2={trendsView.chartWidth}
|
||||
y1={Math.round(trendsView.chartHeight * tick)}
|
||||
y2={Math.round(trendsView.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}
|
||||
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.appliedPath ? <path d={trendsView.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) => (
|
||||
@@ -418,7 +449,7 @@ export default function DashboardView() {
|
||||
<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;
|
||||
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
@@ -444,10 +475,10 @@ export default function DashboardView() {
|
||||
</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={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
{topSource ? t("dashboardResponseConversion", { responses: topSource.responses, total: topSource.total }) : t("dashboardNoSourceData")}
|
||||
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
@@ -459,11 +490,11 @@ export default function DashboardView() {
|
||||
<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 ? (
|
||||
{summaryView.priorityJobs.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.1}>
|
||||
{priorityJobs.map((job) => {
|
||||
{summaryView.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" }}>
|
||||
|
||||
Reference in New Issue
Block a user