feat(ui): add existing-report analytics destination

This commit is contained in:
cesnimda
2026-10-06 19:26:14 +02:00
parent ec6baaeddd
commit 42a6e425b6
37 changed files with 2523 additions and 53 deletions
+59 -44
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
import {
Box,
@@ -88,18 +88,29 @@ function reminderTone(job: ReminderJob): "error" | "warning" | "info" {
return "info";
}
export default function DashboardView() {
export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboard" | "analytics" }) {
const theme = useTheme();
const navigate = useNavigate();
const { t, language } = useI18n();
const [months, setMonths] = useState<6 | 12 | 24>(12);
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
const reporting = mode === "analytics";
const [query, setQuery] = useSearchParams();
const [dashboardMonths, setDashboardMonths] = useState<6 | 12 | 24>(12);
const requestedMonths = Number(query.get("months"));
const months = reporting ? (requestedMonths === 6 || requestedMonths === 24 ? requestedMonths : 12) : dashboardMonths;
const setMonths = (value: 6 | 12 | 24) => {
if (!reporting) { setDashboardMonths(value); return; }
const next = new URLSearchParams(query);
next.set("months", String(value));
setQuery(next);
};
const [dashboardPrefs, setPrefs] = useState<Prefs>(() => reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : loadPrefs());
const prefs = reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : dashboardPrefs;
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
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 } }),
reporting ? Promise.resolve({ data: [] as ReminderJob[] }) : api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
]);
return {
@@ -107,14 +118,14 @@ export default function DashboardView() {
overview: overviewResponse.data,
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
};
}, []);
}, [reporting]);
const summaryResource = useViewResource(
loadSummary,
{
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
errorMessage: t("dashboardSummaryUnavailableTitle"),
deps: [t],
errorMessage: t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle"),
deps: [t, reporting],
},
);
@@ -137,7 +148,7 @@ export default function DashboardView() {
loadTrends,
{
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
errorMessage: t("dashboardTrendsUnavailableTitle"),
errorMessage: t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle"),
deps: [months, t],
},
);
@@ -156,7 +167,7 @@ export default function DashboardView() {
const monthLabel = (key: string) => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(key + "-01T12:00:00Z"));
const summaryReady = !summaryResource.loading && !summaryResource.refreshing && !summaryResource.error;
const trendsReady = !trendsResource.loading && !trendsResource.refreshing && !trendsResource.error;
const hasJobs = (stats?.total ?? 0) > 0;
const hasJobs = ((reporting ? stats?.active : stats?.total) ?? 0) > 0;
const hasActivity = analytics.some(point => point.applied > 0 || point.responses > 0);
const totalApplied = analytics.reduce((sum, point) => sum + point.applied, 0);
const totalResponses = analytics.reduce((sum, point) => sum + point.responses, 0);
@@ -187,13 +198,33 @@ export default function DashboardView() {
</Box>;
const detailsSx = { mt: 2, "& summary": { cursor: "pointer", color: "text.primary", fontWeight: 600, py: 1 }, "& th, & td": { px: 1, py: 0.75, overflowWrap: "anywhere" } };
const prefsButton = <Button variant="outlined" startIcon={<TuneIcon />} aria-haspopup="menu" aria-expanded={Boolean(prefsAnchor)} onClick={event => setPrefsAnchor(event.currentTarget)}>{t("dashboardCustomize")}</Button>;
const pipelineReport = <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
<Box sx={reporting ? grid : undefined}>
<Stack spacing={1.5}>
{funnel.map(item => <Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2">{statusLabel(t, item.label)}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.count)}</Typography></Box>
{bar(item.count, stats?.active ?? 0, statusTone(item.label) === "default" ? "text.secondary" : `${statusTone(item.label)}.main`)}
</Box>)}
{!funnel.length ? <Typography color="text.secondary">{t("dashboardNoPipelineData")}</Typography> : null}
</Stack>
<Box component="details" open={reporting || undefined} sx={{ ...detailsSx, mt: reporting ? 0 : 2 }}>
<Box component="summary" tabIndex={0}>{t("dashboardRecordedStatuses")}</Box>
<Stack spacing={1}>
{Object.entries(stats?.byStatus ?? {}).map(([status, count]) => <Box key={status} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Link component={RouterLink} to={`/jobs?${new URLSearchParams({ status })}`} sx={{ overflowWrap: "anywhere" }}>{statusLabel(t, status)}</Link>
<Typography variant="body2">{number(count)}</Typography>
</Box>)}
</Stack>
</Box>
</Box>
</PageSection>;
return (
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
<OnboardingChecklist hasJobs={hasJobs} />
{!reporting ? <OnboardingChecklist hasJobs={hasJobs} /> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t("dashboardSnapshotScope")}</Typography>
{prefsButton}
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
</Box>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{([
@@ -208,14 +239,14 @@ export default function DashboardView() {
</Menu>
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
title={t("dashboardSummaryUnavailableTitle")} description={t("dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
{summaryReady && !hasJobs ? <PageSection title={t("dashboardStartTitle")} description={t("dashboardStartBody")}
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
</PageSection> : null}
{allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
{summaryReady && hasJobs && prefs.cards ? (
{!reporting && summaryReady && hasJobs && prefs.cards ? (
<Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
{[
{ label: t("dashboardTrackedJobs"), value: stats!.active, caption: t("dashboardTrackedBody"), to: "/jobs" },
@@ -232,8 +263,8 @@ export default function DashboardView() {
</Box>
) : null}
{summaryReady && hasJobs ? <Box sx={grid}>
<PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
{!reporting && summaryReady && hasJobs ? <Box sx={grid}>
{!reporting ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionSample", { count: number(reminderJobs.length), shown: number(Math.min(5, reminderJobs.length)) })}</Typography>
<Typography variant="caption" component="p" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionBreakdown", {
followUp: number(reminderJobs.filter(job => job.workflowSignal?.actionKey === "follow-up").length),
@@ -254,37 +285,20 @@ export default function DashboardView() {
})}
{!reminderJobs.length ? <Typography color="text.secondary">{t("remindersNothing")}</Typography> : null}
</Stack>
</PageSection>
{prefs.funnel ? <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
<Stack spacing={1.5}>
{funnel.map(item => <Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2">{statusLabel(t, item.label)}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.count)}</Typography></Box>
{bar(item.count, stats?.active ?? 0, statusTone(item.label) === "default" ? "text.secondary" : `${statusTone(item.label)}.main`)}
</Box>)}
{!funnel.length ? <Typography color="text.secondary">{t("dashboardNoPipelineData")}</Typography> : null}
</Stack>
<Box component="details" sx={detailsSx}>
<Box component="summary" tabIndex={0}>{t("dashboardRecordedStatuses")}</Box>
<Stack spacing={1}>
{Object.entries(stats?.byStatus ?? {}).map(([status, count]) => <Box key={status} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Link component={RouterLink} to={`/jobs?${new URLSearchParams({ status })}`} sx={{ overflowWrap: "anywhere" }}>{statusLabel(t, status)}</Link>
<Typography variant="body2">{number(count)}</Typography>
</Box>)}
</Stack>
</Box>
</PageSection> : null}
{prefs.funnel ? pipelineReport : null}
</Box> : null}
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
title={t("dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
{(hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
</Box> : null}
{trendsReady && (hasJobs || hasActivity) && prefs.activity ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
{trendsReady && (reporting || hasJobs || hasActivity) && prefs.activity ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
<Typography variant="body2" color="text.secondary">{monthSpan}</Typography>
{totalApplied > 0 ? <Typography variant="body2">{t("dashboardResponseSample", { responses: number(totalResponses), total: number(totalApplied) })} ({number(totalResponses / totalApplied * 100)}%)</Typography> : null}
@@ -301,7 +315,7 @@ export default function DashboardView() {
</Box>
<Typography variant="body2" color="text.secondary">{t("dashboardChartLegend", { max: number(chart.max) })}</Typography>
</>}
<Box component="details" sx={detailsSx}>
<Box component="details" open={reporting || undefined} sx={detailsSx}>
<Box component="summary" tabIndex={0}>{t("dashboardMonthlyValues")}</Box>
<Table size="small" aria-label={t("dashboardMonthlyValues")} sx={{ tableLayout: "fixed" }}>
<TableHead><TableRow>{(["dashboardMonth", "dashboardApplications", "dashboardResponsesLogged"] as const).map(key => <TableCell key={key}>{t(key)}</TableCell>)}</TableRow></TableHead>
@@ -310,6 +324,7 @@ export default function DashboardView() {
</Box>
</PageSection> : null}
{reporting && summaryReady && hasJobs ? pipelineReport : null}
{summaryReady && hasJobs ? <Box sx={grid}>
{(overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
<Stack spacing={1.5}>{overview!.timeInStage!.map(item => <Box key={item.stage}>
@@ -346,8 +361,8 @@ export default function DashboardView() {
</Box>)}</Stack>
{!overview?.topCompanies.length ? <Typography color="text.secondary">{t("companiesEmpty")}</Typography> : null}
</PageSection> : null}
{trendsReady && (hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t("dashboardTagsBody")}>
<Stack spacing={1.5}>{tags.slice(0, 8).map(tag => <Box key={tag.tag}>
{trendsReady && (reporting || hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
<Stack spacing={1.5}>{tags.slice(0, reporting ? 10 : 8).map(tag => <Box key={tag.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{tag.tag}</Typography><Typography variant="body2">{number(tag.count)}</Typography></Box>
{bar(tag.count, Math.max(1, ...tags.map(item => item.count)), "primary.main")}
</Box>)}</Stack>
@@ -362,7 +377,7 @@ export default function DashboardView() {
</Box>
</Box>)}</Stack>
</Box> : null}
<Box component="details" sx={detailsSx}>
<Box component="details" open={reporting || undefined} sx={detailsSx}>
<Box component="summary" tabIndex={0}>{t("dashboardTagTrendValues")}</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>{t("dashboardTagTrendBody")}</Typography>
{!tagTrends?.series.length ? <Typography color="text.secondary">{t("dashboardNoTagTrendData")}</Typography> : tagTrends.series.map(series => <Box key={series.tag} sx={{ mb: 2 }}>