Optimize workspace and daily-loop surfaces

This commit is contained in:
2026-04-11 12:03:49 +02:00
parent 27fd70a2d7
commit 33ac4b963b
7 changed files with 474 additions and 224 deletions
+106 -75
View File
@@ -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" }}>