feat(ui): redesign personal dashboard presentation

This commit is contained in:
cesnimda
2026-10-06 18:26:09 +02:00
parent 384dfa8a4b
commit ec6baaeddd
35 changed files with 1922 additions and 528 deletions
@@ -0,0 +1,190 @@
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(c => c.name === 'XSRF-TOKEN')!.value;
return { me, headers: { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string } };
}
async function capture(page: Page, info: TestInfo, name: string) {
const path = info.outputPath(`${name}.png`); await page.screenshot({ path, fullPage: true });
await info.attach(name, { path, contentType: 'image/png' });
}
test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsive presentation', async ({ page, playwright }, info) => {
test.setTimeout(240000);
const admin = await playwright.request.newContext();
const { headers: adminHeaders } = await login(admin, 'e2e@example.test');
const email = `dashboard-${Date.now()}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const { headers } = await login(page.request, email);
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys teknologi og fellestjenester æøå', source: 'NAV', location: 'Tromsø' } });
expect(companyResponse.status()).toBe(201);
const companyId = (await companyResponse.json()).id;
const now = new Date();
for (let index = 0; index < 8; index++) {
const date = new Date(now.getFullYear(), now.getMonth() - index % 3, 3).toISOString();
const data = { companyId, jobTitle: `Plattformutvikler og rådgiver ${index}`, status: ['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Faglig vurdering', 'Saved', 'Preparing'][index], dateApplied: index < 6 ? date : null, tags: 'TypeScript, Azure', salaryMin: 600000, salaryMax: 800000, salaryCurrency: 'NOK', salaryPeriod: 'year' };
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data });
expect(created.status()).toBe(201);
if (index === 2 || index === 3) {
const id = (await created.json()).id;
expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { ...data, responseReceived: true, responseDate: date } })).status()).toBe(204);
}
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/dashboard');
await expect(page.getByRole('link', { name: 'Nordlys teknologi og fellestjenester æøå', exact: true })).toBeVisible();
const dismiss = page.getByRole('button', { name: 'Dismiss setup checklist' });
if (await dismiss.isVisible()) await dismiss.click();
await capture(page, info, 'dashboard-populated-light-en');
const stats = await (await page.request.get(`${apiUrl}/jobapplications/stats`)).json();
expect(stats.active).toBe(8);
const monthly = await (await page.request.get(`${apiUrl}/jobapplications/analytics?months=12`)).json();
const applied = monthly.reduce((sum: number, point: { applied: number }) => sum + point.applied, 0);
const responses = monthly.reduce((sum: number, point: { responses: number }) => sum + point.responses, 0);
await expect(page.getByRole('link', { name: 'Tracked jobs: 8' })).toBeVisible();
await expect(page.getByText(`${responses} / ${applied} recorded responses`, { exact: false })).toBeVisible();
const activity = page.getByRole('region', { name: 'Application activity' });
const monthlyControl = activity.getByText('View monthly values');
await monthlyControl.focus(); await page.keyboard.press('Enter');
await expect(activity.getByRole('table')).toBeVisible();
await expect(monthlyControl).toBeFocused();
const ring = await monthlyControl.evaluate(element => getComputedStyle(element).outlineWidth);
expect(parseFloat(ring)).toBeGreaterThanOrEqual(3);
await capture(page, info, 'dashboard-monthly-values-keyboard');
for (const point of monthly) {
const month = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${point.month}-01T12:00:00Z`));
await expect(activity.getByRole('row', { name: `${month} ${point.applied} ${point.responses}`, exact: true })).toBeVisible();
}
await activity.getByText('View monthly values').click();
await page.getByText('Recorded status breakdown', { exact: true }).click();
await page.getByRole('link', { name: 'Faglig vurdering', exact: true }).click();
await expect(page).toHaveURL(/\/jobs\?status=Faglig\+vurdering/);
await expect(page.locator('[data-job-row-id]')).toHaveCount(1);
await page.goBack();
await page.getByRole('link', { name: 'Nordlys teknologi og fellestjenester æøå', exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/jobs\\?companyId=${companyId}`));
await expect(page.locator('[data-job-row-id]')).toHaveCount(8);
await page.goBack();
for (const mode of ['light', 'dark'] as const) for (const language of ['en', 'nb'] as const) {
const t = translations[language];
await page.evaluate(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
// Configure the native preference before mounting the next visual fixture.
await page.goto('about:blank');
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.goto('/dashboard');
await expect(page.getByText(t.dashboardTrackedJobs, { exact: true })).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode);
for (const [width, height, name] of [[1440, 900, 'desktop'], [390, 844, 'phone']] as const) {
await page.setViewportSize({ width, height });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `dashboard-${name}-${mode}-${language}`);
}
const customize = page.getByRole('button', { name: t.dashboardCustomize, exact: true });
await customize.focus(); await page.keyboard.press('Enter');
await expect(page.getByRole('menuitemcheckbox').first()).toBeFocused();
if (language === 'en') {
const menuScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`dashboard-${mode}-menu-axe`, { body: JSON.stringify({ violations: menuScan.violations, incomplete: menuScan.incomplete }), contentType: 'application/json' });
expect(menuScan.violations).toEqual([]);
}
await page.keyboard.press('Escape'); await expect(customize).toBeFocused();
await expect(page.locator('[role="menuitemcheckbox"]')).toHaveCount(0);
if (language === 'en') {
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`dashboard-${mode}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
expect(result.violations).toEqual([]);
}
}
for (const [width, height] of [[320, 740], [768, 1024], [1024, 768], [1920, 1080]]) {
await page.setViewportSize({ width, height });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `dashboard-${width}-dark-nb`);
}
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.setViewportSize({ width: 1440, height: 900 });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: 'en' } })).status()).toBe(204);
await page.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await page.reload();
await page.getByRole('button', { name: 'Customize dashboard' }).click();
for (const item of await page.getByRole('menuitemcheckbox').all()) await item.click();
await page.keyboard.press('Escape');
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
await page.reload();
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
for (const name of ['Application activity', 'Current pipeline', 'Top companies by activity', 'Job tags']) await expect(page.getByRole('region', { name })).toHaveCount(0);
await capture(page, info, 'dashboard-all-hidden');
const emailB = `dashboard-b-${Date.now()}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200);
await page.getByRole('button', { name: 'User', exact: true }).click();
await page.getByRole('menuitem', { name: 'Sign out', exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await page.getByLabel('Username or email', { exact: true }).fill(emailB);
await page.getByLabel('Current password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText('Your search starts here')).toBeVisible();
await expect(page.getByText('Your dashboard sections are hidden.')).toHaveCount(0);
await expect(page.getByText('Nordlys teknologi og fellestjenester æøå', { exact: true })).toHaveCount(0);
await capture(page, info, 'dashboard-new-account');
await page.getByRole('button', { name: 'Customize dashboard' }).click();
for (const item of await page.getByRole('menuitemcheckbox').all()) await expect(item).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('Escape');
const b = await login(page.request, emailB);
const bCompany = await (await page.request.post(`${apiUrl}/companies`, { headers: b.headers, data: { name: 'New search' } })).json();
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: b.headers, data: { companyId: bCompany.id, jobTitle: 'First prospect', status: 'Saved' } })).status()).toBe(201);
await page.reload();
await expect(page.getByRole('link', { name: 'Tracked jobs: 1' })).toBeVisible();
await expect(page.getByText('No applications or dated responses in this period.')).toBeVisible();
await capture(page, info, 'dashboard-one-prospect-zero-activity');
await page.getByRole('button', { name: 'User', exact: true }).click();
await page.getByRole('menuitem', { name: 'Sign out', exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await page.getByLabel('Username or email', { exact: true }).fill(email);
await page.getByLabel('Current password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
await expect(page.getByText('First prospect', { exact: true })).toHaveCount(0);
await admin.dispose();
});
test('[PRES-S01 E08] Dashboard loading and independent failed resources retain retry and exact values', async ({ page }, info) => {
await login(page.request, 'e2e@example.test');
let failSummary = false;
let failTrends = true;
let release: (() => void) | undefined;
const hold = new Promise<void>(resolve => { release = resolve; });
await page.route(`${apiUrl}/jobapplications/stats`, async route => {
await hold;
return failSummary ? route.fulfill({ status: 503, json: { message: 'Summary unavailable' } }) : route.fulfill({ json: { total: 4, active: 4, deleted: 0, byStatus: { Applied: 4 }, appliedLast30Days: 4 } });
});
await page.route(`${apiUrl}/jobapplications/analytics-overview`, route => route.fulfill({ json: { funnel: [{ label: 'Applied', count: 4 }], topCompanies: [], responseRateBySource: [], totalActive: 4, totalResponses: 1 } }));
await page.route(`${apiUrl}/jobapplications/analytics?*`, async route => {
await hold;
return failTrends ? route.fulfill({ status: 503, json: { message: 'Trends unavailable' } }) : route.fulfill({ json: [{ month: '2026-08', applied: 4, responses: 1 }] });
});
await page.goto('/dashboard');
await expect(page.getByRole('progressbar').first()).toBeVisible();
await capture(page, info, 'dashboard-loading');
release!();
await expect(page.getByText('Unable to load dashboard trends')).toBeVisible();
await expect(page.getByRole('link', { name: 'Tracked jobs: 4' })).toBeVisible();
await capture(page, info, 'dashboard-trends-error');
failTrends = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.getByText('1 / 4 recorded responses', { exact: false })).toBeVisible();
failSummary = true; await page.reload();
await expect(page.getByText('Unable to load dashboard summary')).toBeVisible();
await expect(page.getByRole('region', { name: 'Application activity' })).toBeVisible();
await capture(page, info, 'dashboard-summary-error');
failSummary = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.getByRole('link', { name: 'Tracked jobs: 4' })).toBeVisible();
});
+212 -513
View File
@@ -1,30 +1,28 @@
import React, { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import {
Box,
Button,
Card,
Checkbox,
Chip,
LinearProgress,
Menu,
MenuItem,
Stack,
Link,
Table, TableBody, TableCell, TableHead, TableRow,
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha, useTheme } from "@mui/material/styles";
import { useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import CheckBoxIcon from "@mui/icons-material/CheckBox";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
import { statusLabel, statusTone } from "../pipeline";
import { PageSection } from "../layout/PageLayout";
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
@@ -80,36 +78,6 @@ 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(" ");
}
// Mockup cards float on the grey page background purely via the theme's Card shadow/radius --
// this wraps MuiCard instead of Paper-with-hardcoded-shadow so every section stays in sync with
// theme.ts (see 02-dashboard.png).
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
return (
<Card sx={{ p: "var(--app-card-padding)", ...sx }}>
{children}
</Card>
);
}
// "Needs your attention" row accent -- red for overdue follow-ups, amber for interview prep,
// blue/info for everything else (package work, readiness review). No "positive/offer" tier
// exists in the real reminder feed (see DashboardView report), so only these three appear.
@@ -122,9 +90,8 @@ function reminderTone(job: ReminderJob): "error" | "warning" | "info" {
export default function DashboardView() {
const theme = useTheme();
const isMobile = useMediaQuery("(max-width:767.95px)");
const navigate = useNavigate();
const { t } = useI18n();
const { t, language } = useI18n();
const [months, setMonths] = useState<6 | 12 | 24>(12);
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
@@ -155,7 +122,7 @@ export default function DashboardView() {
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<TagPoint[]>("/jobapplications/tags", { params: { limit: 10 } }),
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }),
]);
@@ -182,499 +149,231 @@ export default function DashboardView() {
const tags = trendsResource.data.tags;
const tagTrends = trendsResource.data.tagTrends;
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 summaryView = useMemo(() => {
const topSource = overview?.responseRateBySource?.[0];
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
const interviewCount = overview?.funnel?.find((item) => item.label === "Interview")?.count ?? 0;
const interviewPrepCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "interview-prep").length;
const overdueFollowUpCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "follow-up").length;
return {
funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0,
topSource,
missingCvCount,
interviewCount,
interviewPrepCount,
overdueFollowUpCount,
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 = 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]);
// Mockup's 4 stat tiles (Active applications / Response rate / Interviews / Needs follow-up)
// -- every value below is derived from data this view already fetches, no new endpoints.
const metricCards = useMemo(() => {
const cards: { label: string; value: string | number; trend?: { text: string; tone: "success" | "error" }; caption?: string }[] = [
{
label: t("dashboardActiveApplications"),
value: stats?.active ?? 0,
trend: (stats?.appliedLast30Days ?? 0) > 0
? { text: `+${stats?.appliedLast30Days} ${t("dashboardNewApplications").toLowerCase()}`, tone: "success" }
: undefined,
},
{
label: t("dashboardResponseRateTile"),
value: `${trendsView.responseRate}%`,
caption: summaryView.topSource ? `${summaryView.topSource.label} · ${summaryView.topSource.rate}%` : t("dashboardResponseSources"),
},
{
label: t("dashboardInterviews"),
value: summaryView.interviewCount,
caption: summaryView.interviewPrepCount > 0 ? t("dashboardUpcomingCount", { count: summaryView.interviewPrepCount }) : undefined,
},
{
label: t("dashboardFollowUpsDue"),
value: reminderJobs.length,
trend: summaryView.overdueFollowUpCount > 0
? { text: t("dashboardOverdueCount", { count: summaryView.overdueFollowUpCount }), tone: "error" }
: undefined,
},
];
return cards;
}, [reminderJobs.length, stats?.active, stats?.appliedLast30Days, summaryView.interviewCount, summaryView.interviewPrepCount, summaryView.overdueFollowUpCount, summaryView.topSource, t, trendsView.responseRate]);
const locale = language === "nb" ? "nb-NO" : "en-GB";
const palette = theme.vars?.palette ?? theme.palette;
const number = (value: number) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
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 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);
const funnel = (overview?.funnel ?? []).filter(item => item.count > 0);
const allHidden = !Object.values(prefs).some(Boolean);
const grid = { display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", lg: "minmax(0, 1.4fr) minmax(0, 1fr)" }, gap: 3, alignItems: "start" };
const monthSpan = analytics.length ? `${monthLabel(analytics[0].month)} – ${monthLabel(analytics[analytics.length - 1].month)}` : t("dashboardNoWindowData");
const stageCount = (stage: string) => overview?.funnel.find(item => item.label === stage)?.count ?? 0;
const togglePref = useCallback((key: keyof Prefs) => {
setPrefs((current) => {
setPrefs(current => {
const next = { ...current, [key]: !current[key] };
savePrefs(next);
return next;
});
}, []);
const getReminderAction = useCallback((job: ReminderJob) => getWorkflowAction(job, {
packageWork: t("jobTablePackageWork"),
followUp: t("jobTableFollowUp"),
interviewPrep: t("jobTableInterviewStage"),
readiness: t("jobTableReadiness"),
}), [t]);
// Both series share a zero baseline and maximum; independent scaling implies false equality.
const chart = useMemo(() => {
const max = Math.max(1, ...analytics.flatMap(point => [point.applied, point.responses]));
const path = (key: "applied" | "responses") => analytics.map((point, index) =>
`${index ? "L" : "M"} ${20 + index * 720 / Math.max(1, analytics.length - 1)} ${180 - point[key] / max * 160}`).join(" ");
return { max, applied: path("applied"), responses: path("responses") };
}, [analytics]);
const openReminderJob = useCallback((job: ReminderJob) => {
navigate(buildWorkflowPath(job));
}, [navigate]);
const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1;
const hasJobs = (stats?.total ?? 0) > 0;
const hasActivityData = analytics.some((item) => item.applied > 0 || item.responses > 0);
const funnelItems = (overview?.funnel ?? []).filter((item) => item.count > 0);
const hasStageData = Boolean(overview?.timeInStage?.length);
const hasCompanies = Boolean(overview?.topCompanies?.length);
const hasSkills = tags.length > 0;
const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 0.5 }}>{t("dashboardTodayTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardTodayBody")}</Typography>
{summaryView.priorityJobs.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
) : (
<Stack spacing={1.1}>
{summaryView.priorityJobs.map((job) => {
const action = getReminderAction(job);
const tone = reminderTone(job);
const toneColor = theme.palette[tone].main;
const urgent = tone === "error";
return (
<Box
key={job.id}
sx={{
p: 1.5,
pl: 2,
borderRadius: 2,
borderLeft: `4px solid ${toneColor}`,
backgroundColor: alpha(toneColor, 0.07),
display: "flex",
justifyContent: "space-between",
gap: 2,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
</Box>
<Button
variant="contained"
disableElevation
onClick={() => openReminderJob(job)}
sx={urgent
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
>
{action?.label ?? t("remindersOpen")}
</Button>
</Box>
);
})}
</Stack>
)}
<Box sx={{ mt: 1.5 }}>
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
) : null;
const bar = (value: number, max: number, color: string) => <Box aria-hidden="true" sx={{ height: 6, mt: 0.75, bgcolor: "action.hover", borderRadius: 1 }}>
<Box sx={{ width: `${Math.max(0, Math.min(100, value / Math.max(1, max) * 100))}%`, height: "100%", bgcolor: color, borderRadius: 1 }} />
</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>;
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
<OnboardingChecklist hasJobs={hasJobs} />
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t("dashboardSnapshotScope")}</Typography>
{prefsButton}
</Box>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{([
["cards", "dashboardSummaryCards"], ["activity", "dashboardActivityChart"],
["funnel", "dashboardConversionFunnel"], ["companies", "dashboardTopCompanies"], ["skills", "dashboardSkillsInsights"],
] as const).map(([key, label]) => (
<MenuItem key={key} role="menuitemcheckbox" aria-checked={prefs[key]} onClick={() => togglePref(key)}>
{prefs[key] ? <CheckBoxIcon aria-hidden sx={{ m: 1, color: "primary.main" }} /> : <CheckBoxOutlineBlankIcon aria-hidden sx={{ m: 1, color: "text.secondary" }} />}
{t(label)}
</MenuItem>
))}
</Menu>
{hasJobs ? <Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ flexWrap: "wrap" }}>
<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>
<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")}
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}
<Box
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
alignItems: "center",
width: { xs: "100%", sm: "auto" },
'& .MuiButton-root': {
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
},
}}
>
{([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>
{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" },
{ label: t("dashboardApplied30Days"), value: stats!.appliedLast30Days, caption: t("dashboardRecordedDates") },
{ label: t("dashboardCurrentInterviews"), value: stageCount("Interview"), caption: t("dashboardCurrentStage") },
{ label: t("dashboardCurrentOffers"), value: stageCount("Offer"), caption: t("dashboardCurrentStage"), to: "/offers" },
].map(item => <Box key={item.label} sx={{ p: 1.5, minWidth: 0 }}>
<Typography component="dt" variant="body2" color="text.secondary">{item.label}</Typography>
<Typography component="dd" sx={{ m: 0, my: 0.5, fontSize: "1.75rem", fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
{item.to ? <Link component={RouterLink} to={item.to} color="text.primary" underline="hover" aria-label={`${item.label}: ${number(item.value)}`}>{number(item.value)}</Link> : number(item.value)}
</Typography>
<Typography component="dd" variant="caption" color="text.secondary" sx={{ m: 0 }}>{item.caption}</Typography>
</Box>)}
</Box>
) : null}
{summaryReady && hasJobs ? <Box sx={grid}>
<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),
preparation: number(reminderJobs.filter(job => job.workflowSignal?.actionKey === "interview-prep").length),
package: number(reminderJobs.filter(job => job.workflowSignal?.hasPackageGap).length),
})}</Typography>
<Stack spacing={1}>
{reminderJobs.slice(0, 5).map(job => {
const action = getWorkflowAction(job, { packageWork: t("jobTablePackageWork"), followUp: t("jobTableFollowUp"), interviewPrep: t("jobTableInterviewStage"), readiness: t("jobTableReadiness") });
return <Box key={job.id} sx={{ py: 1.5, pl: 1.5, borderLeft: "3px solid", borderColor: `${reminderTone(job)}.main`, display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", bgcolor: "background.paper" }}>
<Box sx={{ flex: "1 1 14rem", minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }}>{job.jobTitle}</Typography>
<Typography variant="body2" color="text.secondary">{job.company?.name ?? t("jobTableCompany")}</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
<Button variant="outlined" onClick={() => navigate(buildWorkflowPath(job))} sx={{ mr: 1.5 }}>{action?.label ?? t("remindersOpen")}</Button>
</Box>;
})}
{!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}
</Box> : null}
<ViewStateNotice
loading={summaryResource.loading}
error={summaryResource.error}
title={t("dashboardSummaryUnavailableTitle")}
description={t("dashboardSummaryUnavailableBody")}
onRetry={summaryResource.reload}
/>
<ViewStateNotice
loading={trendsResource.loading}
error={trendsResource.error}
title={t("dashboardTrendsUnavailableTitle")}
description={t("dashboardTrendsUnavailableBody")}
onRetry={trendsResource.reload}
compact
/>
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
title={t("dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
{priorityPanel ? <Box sx={{ mt: 2 }}>{priorityPanel}</Box> : null}
{/* 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")}>
<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}
{!summaryResource.loading && !summaryResource.error && hasJobs && 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}
sx={{
transition: "box-shadow .2s, transform .2s",
"&:hover": { boxShadow: 6, transform: "translateY(-2px)" },
}}
>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
<Typography variant="h3" sx={{ mt: 0.5 }}>{card.value}</Typography>
{card.trend || card.caption ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 0.75 }}>
{card.trend ? (
<>
{card.trend.tone === "success" ? (
<ArrowUpwardIcon sx={{ fontSize: 14 }} color="success" />
) : (
<ArrowDownwardIcon sx={{ fontSize: 14 }} color="error" />
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: card.trend.tone === "success" ? "success.main" : "error.main" }}>
{card.trend.text}
</Typography>
</>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{card.caption}</Typography>
)}
</Box>
) : null}
</SectionCard>
))}
{trendsReady && (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}
</Box>
) : null}
{!hasActivity ? <Typography color="text.secondary">{t("dashboardNoWindowData")}</Typography> : <>
<Box component="svg" aria-hidden="true" viewBox="0 0 760 190" preserveAspectRatio="none" sx={{ display: "block", width: "100%", height: { xs: 150, sm: 220 } }}>
{[0, 0.5, 1].map(fraction => <line key={fraction} x1="20" x2="740" y1={180 - fraction * 160} y2={180 - fraction * 160} stroke={palette.divider} />)}
<path d={chart.applied} fill="none" stroke={palette.primary.main} strokeWidth="3" />
<path d={chart.responses} fill="none" stroke={palette.success.main} strokeWidth="3" strokeDasharray="7 5" />
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, mb: 1 }}>
<Typography variant="caption" color="text.secondary">{monthLabel(analytics[0].month)}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ textAlign: "right" }}>{monthLabel(analytics[analytics.length - 1].month)}</Typography>
</Box>
<Typography variant="body2" color="text.secondary">{t("dashboardChartLegend", { max: number(chart.max) })}</Typography>
</>}
<Box component="details" 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>
<TableBody>{analytics.map(point => <TableRow key={point.month}><TableCell component="th" scope="row">{monthLabel(point.month)}</TableCell><TableCell>{number(point.applied)}</TableCell><TableCell>{number(point.responses)}</TableCell></TableRow>)}</TableBody>
</Table>
</Box>
</PageSection> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
<Typography variant="h6">{t("dashboardApplicationActivity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
{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}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}><Typography variant="body2">{statusLabel(t, item.stage)}</Typography><Typography variant="body2">{t("dashboardTimeInStageValue", { days: number(item.medianDays), count: number(item.count) })}</Typography></Box>
{bar(item.medianDays, Math.max(1, ...overview!.timeInStage!.map(stage => stage.medianDays)), "info.main")}
</Box>)}</Stack>
</PageSection> : null}
{(overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
<Stack spacing={1.5}>{overview!.responseRateBySource.map(item => <Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{item.label}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.rate)}%</Typography></Box>
<Typography variant="caption" color="text.secondary">{t("dashboardRecordedResponseSample", { responses: number(item.responses), total: number(item.total) })}</Typography>
</Box>)}</Stack>
</PageSection> : null}
</Box> : null}
{summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{overview!.salaryInsights!.map(item => {
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return <Box key={item.currency + item.period} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period === "year" ? t("salaryPeriodYear") : item.period === "month" ? t("salaryPeriodMonth") : item.period === "hour" ? t("salaryPeriodHour") : item.period, count: number(item.count) })}</Typography>
<Typography sx={{ fontSize: "1.25rem", fontWeight: 700, my: 0.75 }}>{money.format(item.averageMidpoint)}</Typography>
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
</Box>;
})}
</Box>
</PageSection> : null}
<Box sx={grid}>
{summaryReady && hasJobs && prefs.companies ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
<Stack spacing={1.5}>{(overview?.topCompanies ?? []).map(item => <Box key={item.companyId} sx={{ borderBottom: "1px solid", borderColor: "divider", pb: 1.5 }}>
<Link component={RouterLink} to={`/jobs?companyId=${item.companyId}`} sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>{item.company}</Link>
<Typography variant="body2" color="text.secondary">{t("dashboardCompanyJobsResponses", { jobs: number(item.count), responses: number(item.responses) })} ({number(item.responseRate)}%)</Typography>
</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}>
<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>
{!tags.length ? <Typography color="text.secondary">{t("dashboardNoTagsYet")}</Typography> : null}
{tagTrends?.series.length ? <Box sx={{ mt: 3 }}>
<Typography component="h3" variant="body2" sx={{ fontWeight: 700, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardTagTrendBody")}</Typography>
<Stack spacing={1.5}>{tagTrends.series.map(series => <Box key={series.tag}>
<Typography variant="body2">{series.tag} · {t("dashboardTotalCount", { count: number(series.counts.reduce((sum, count) => sum + count, 0)) })}</Typography>
<Box aria-hidden="true" sx={{ display: "flex", alignItems: "end", gap: 0.5, height: 32, mt: 0.5 }}>
{series.counts.map((count, index) => <Box key={index} sx={{ flex: 1, height: `${count / Math.max(1, ...series.counts) * 100}%`, bgcolor: "primary.main" }} />)}
</Box>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.primary.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardAppliedCount", { count: trendsView.totalApplied })}</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.success.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardResponsesCount", { count: trendsView.totalResponses })}</Typography>
</Stack>
</Stack>
</Box>
<Box sx={{ mt: 2, minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<svg width="100%" height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`} preserveAspectRatio="none" style={{ display: "block", maxWidth: "100%" }}>
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
<line
key={tick}
x1="0"
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"
/>
))}
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={theme.palette.success.main} strokeWidth="3" strokeLinecap="round" /> : null}
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={theme.palette.primary.main} 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}
{!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? (
<SectionCard>
{overview?.timeInStage?.length ? (<>
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
{overview.timeInStage.map((item, index) => (
<Box key={item.stage}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={clamp((item.medianDays / timeInStageMax) * 100, 4, 100)}
sx={{
height: 8,
borderRadius: 999,
backgroundColor: alpha(theme.palette.text.primary, 0.06),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
backgroundColor: tagColors[index % tagColors.length],
},
}}
/>
</Box>
))}
</Stack>
</>) : null}
{tags.length ? (
<Box sx={{ mt: 2.25 }}>
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
{tags.slice(0, 4).map((tag) => (
<Chip
key={tag.tag}
size="small"
label={`${tag.tag} · ${tag.count}`}
sx={{ backgroundColor: alpha(theme.palette.primary.main, 0.1), color: "primary.main", fontWeight: 700 }}
/>
))}
</Box>
</Box>
) : null}
{funnelItems.length > 0 ? <>
<Typography variant="h6" sx={{ mt: hasStageData || hasSkills ? 2.25 : 0 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Stack spacing={1.2} sx={{ mt: 1 }}>
{funnelItems.map((item) => {
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 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, 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>
</> : null}
{summaryView.topSource ? <Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
<Typography variant="h5" sx={{ mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
</Typography>
</Box>)}</Stack>
</Box> : null}
</SectionCard>
) : null}
</Box>
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
<Box sx={{ mt: 2 }}>
<SectionCard>
<Typography variant="h6">{t("dashboardSalaryInsights")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>{t("dashboardSalaryInsightsBody")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
{overview!.salaryInsights!.map((item) => {
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return (
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="caption" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })}</Typography>
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
</Box>
);
})}
</Box>
</SectionCard>
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1fr 1fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && hasCompanies && prefs.companies ? (
<SectionCard>
<Typography variant="h6" sx={{ 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 sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{item.company}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{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}
{!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopSkills")}</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, overflowWrap: "anywhere" }}>{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={{ 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, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{series.tag}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("dashboardTotalCount", { count: series.counts.reduce((sum, value) => sum + value, 0) })}</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 component="details" 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 }}>
<Typography component="h3" variant="body2" sx={{ fontWeight: 700 }}>{series.tag}</Typography>
<Table size="small" aria-label={series.tag} sx={{ tableLayout: "fixed" }}>
<TableHead><TableRow><TableCell>{t("dashboardMonth")}</TableCell><TableCell>{t("dashboardOccurrences")}</TableCell></TableRow></TableHead>
<TableBody>{tagTrends.months.map((month, index) => <TableRow key={month}><TableCell component="th" scope="row">{monthLabel(month)}</TableCell><TableCell>{number(series.counts[index])}</TableCell></TableRow>)}</TableBody>
</Table>
</Box>)}
</Box>
</PageSection> : null}
</Box>
</Box>
);
@@ -0,0 +1,101 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import DashboardView from './components/DashboardView';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
jest.mock('./components/OnboardingChecklist', () => () => <div>Setup retained</div>);
const mockedApi = api as jest.Mocked<typeof api>;
const stats = { total: 9, active: 8, deleted: 1, byStatus: { Applied: 3, Interview: 2, Offer: 1, 'Faglig vurdering': 2 }, appliedLast30Days: 4 };
const overview = { funnel: [{ label: 'Applied', count: 3 }, { label: 'Interview', count: 2 }, { label: 'Offer', count: 1 }], totalActive: 8, totalResponses: 3, responseRateBySource: [{ label: 'NAV', total: 8, responses: 3, rate: 37.5 }], topCompanies: [{ companyId: 7, company: 'Nordlys AS', count: 8, responses: 3, responseRate: 37.5 }], timeInStage: [{ stage: 'Applied', medianDays: 4, count: 3 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 600000, maximum: 800000, averageMidpoint: 700000 }] };
const monthly = [{ month: '2026-08', applied: 4, responses: 1 }, { month: '2026-09', applied: 2, responses: 1 }];
function Location() { return <output aria-label="Destination">{useLocation().pathname}{useLocation().search}</output>; }
function open() { return render(<I18nProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></I18nProvider>); }
function data(url: string) {
if (url.endsWith('/stats')) return stats;
if (url.endsWith('/analytics-overview')) return overview;
if (url.endsWith('/analytics')) return monthly;
if (url.endsWith('/tags')) return [{ tag: 'TypeScript', count: 5 }];
if (url.endsWith('/tag-trends')) return { months: ['2026-08', '2026-09'], series: [{ tag: 'TypeScript', counts: [3, 2] }] };
return [];
}
beforeEach(() => { localStorage.clear(); mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any); });
test('[PRES-S01] exact monthly values, sample, custom statuses and separate salary group remain readable', async () => {
open();
expect(await screen.findByText('Tracked jobs')).toBeVisible();
const activity = await screen.findByRole('region', { name: 'Application activity' });
expect(within(activity).getByText(/2 \/ 6 recorded responses/)).toBeInTheDocument();
fireEvent.click(within(activity).getByText('View monthly values'));
const table = within(activity).getByRole('table');
expect(within(table).getByRole('row', { name: 'August 2026 4 1' })).toBeVisible();
expect(within(table).getByRole('row', { name: 'September 2026 2 1' })).toBeVisible();
expect(screen.getByText('Faglig vurdering')).toBeInTheDocument();
expect(screen.getByText(/NOK.*2 jobs/)).toBeInTheDocument();
expect(screen.queryByText(/upcoming|conversion funnel|active applications/i)).not.toBeInTheDocument();
});
test('[PRES-S02] all five stored visibility flags hide their groups and survive remount', async () => {
let view = open(); await screen.findByText('Tracked jobs');
fireEvent.click(screen.getByRole('button', { name: 'Customize dashboard' }));
for (const item of screen.getAllByRole('menuitemcheckbox')) fireEvent.click(item);
expect(screen.getAllByRole('menuitemcheckbox').every(item => item.getAttribute('aria-checked') === 'false')).toBe(true);
view.unmount(); view = open();
await screen.findByText('Your dashboard sections are hidden.');
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
for (const name of ['Application activity', 'Current pipeline', 'Top companies by activity', 'Job tags']) expect(screen.queryByRole('region', { name })).not.toBeInTheDocument();
expect(screen.getByText('Setup retained')).toBeInTheDocument();
});
test('[PRES-S01] a trends failure retains summary and retries without showing a false zero response rate', async () => {
let fail = true;
mockedApi.get.mockImplementation((url: string) => url.endsWith('/analytics') && fail ? Promise.reject(new Error('offline')) : Promise.resolve({ data: data(url) }) as any);
open();
await screen.findByText('Unable to load dashboard trends');
expect(screen.getByText('Tracked jobs')).toBeVisible();
expect(screen.queryByText('0%')).not.toBeInTheDocument();
fail = false; fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await screen.findByRole('region', { name: 'Application activity' });
});
test('[PRES-S01] a summary failure does not suppress independent trends', async () => {
mockedApi.get.mockImplementation((url: string) => url.endsWith('/stats') ? Promise.reject(new Error('offline')) : Promise.resolve({ data: data(url) }) as any);
open(); await screen.findByText('Unable to load dashboard summary');
expect(await screen.findByRole('region', { name: 'Application activity' })).toBeVisible();
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
});
test('[PRES-J05 J09 S01] raw status and company destinations use supported exact query fields', async () => {
open(); fireEvent.click(await screen.findByRole('link', { name: /Faglig vurdering/ }));
expect(screen.getByLabelText('Destination')).toHaveTextContent('/jobs?status=Faglig+vurdering');
fireEvent.click(screen.getByRole('link', { name: 'Nordlys AS' }));
expect(screen.getByLabelText('Destination')).toHaveTextContent('/jobs?companyId=7');
});
test('[PRES-S01 S02] period changes only reload trends and retain current snapshot meaning', async () => {
open(); await screen.findByText('Tracked jobs');
mockedApi.get.mockClear(); fireEvent.click(screen.getByRole('button', { name: '6 mo' }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/analytics', { params: { months: 6 } }));
expect(mockedApi.get).not.toHaveBeenCalledWith('/jobapplications/stats');
expect(screen.getByRole('button', { name: '6 mo' })).toHaveAttribute('aria-pressed', 'true');
});
test('[PRES-S01] zero activity is an explicit window-empty state without a fabricated response percentage', async () => {
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/analytics') ? [{ month: '2026-09', applied: 0, responses: 0 }] : data(url) }) as any);
open();
await screen.findByText('No applications or dated responses in this period.');
expect(screen.queryByText(/0%/)).not.toBeInTheDocument();
expect(screen.getByText('Tracked jobs')).toBeVisible();
});
test('[PRES-S01] activity chart uses one shared zero-based scale and a readable legend', async () => {
open();
const activity = await screen.findByRole('region', { name: 'Application activity' });
expect(within(activity).getByText('Solid: applications. Dashed: dated responses. Shared scale: 0–4. Exact values below.')).toBeVisible();
// The fixture's 4 applications must plot above 1 response, not at the same independently scaled height.
const paths = activity.querySelectorAll('svg path');
const firstY = (path: Element) => Number(path.getAttribute('d')!.split(' ')[2]);
expect(firstY(paths[0])).toBeLessThan(firstY(paths[1]));
});
+80 -10
View File
@@ -31,7 +31,7 @@ export const translations = {
home: "Home",
analytics: "Analytics",
overview: "Overview",
dashboardPageSubtitle: "Your search at a glance — response rate, funnel, and what needs attention.",
dashboardPageSubtitle: "Your next actions, current pipeline and application activity.",
jobsPageSubtitle: "Filter, search, and manage every application in one workspace.",
kanbanPageSubtitle: "Drag a card between stages to update its status.",
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
@@ -1389,6 +1389,41 @@ export const translations = {
cropDialogZoom: "Zoom",
cropDialogPreviewAlt: "Crop preview",
cropDialogSave: "Save image",
dashboardSnapshotScope: "Your tracker today. Activity charts have a separate period.",
dashboardAttentionBreakdown: "{followUp} follow-ups · {preparation} interview preparations · {package} package gaps in the feed",
dashboardTrackedJobs: "Tracked jobs",
dashboardTrackedBody: "Includes prospects and closed outcomes.",
dashboardRecordedDates: "Recorded application dates from the last 30 days, including any future-dated entries.",
dashboardCurrentInterviews: "Jobs at interview",
dashboardCurrentOffers: "Current offers",
dashboardCurrentStage: "Jobs currently in this stage.",
dashboardAttentionSample: "Showing {shown} of {count} items from your reminder feed.",
dashboardCurrentPipeline: "Current pipeline",
dashboardPipelineBody: "Non-deleted jobs by their current stage. Recorded custom statuses below.",
dashboardNoPipelineData: "No jobs in the standard stages.",
dashboardRecordedStatuses: "Recorded status breakdown",
dashboardStartTitle: "Your search starts here",
dashboardStartBody: "Save a job you want to explore. Your activity and patterns will appear as you build your tracker.",
dashboardAllHidden: "Your dashboard sections are hidden.",
dashboardTrendPeriod: "Activity and tag-trend period",
dashboardActivityDefinition: "Applications from the selected months and their dated responses within the same window. Deleted jobs excluded.",
dashboardResponseSample: "{responses} / {total} recorded responses",
dashboardNoWindowData: "No applications or dated responses in this period.",
dashboardMonthlyValues: "View monthly values",
dashboardMonth: "Month",
dashboardApplications: "Applications",
dashboardChartLegend: "Solid: applications. Dashed: dated responses. Shared scale: 0–{max}. Exact values below.",
dashboardStageAge: "Median current-stage age",
dashboardStageAgeBody: "Jobs still in each stage, measured from the latest recorded transition or application/saved date.",
dashboardSourceBody: "Up to six sources by tracked-job count. Current company source; includes prospects. A response flag or date counts as a recorded response.",
dashboardRecordedResponseSample: "{responses} of {total} non-deleted jobs have a recorded response.",
dashboardSalaryDefinition: "Mean of recorded salary-range midpoints, with observed min/max and sample count. Currencies and pay periods stay separate.",
dashboardCompaniesBody: "Up to eight companies by non-deleted job count, including prospects. Responses use a recorded flag or date.",
dashboardJobTags: "Job tags",
dashboardTagsBody: "Top eight job tags across all non-deleted jobs. All time.",
dashboardTagTrendValues: "View monthly tag values",
dashboardTagTrendBody: "Top five tags on jobs with an application date in the selected period. Counts are recorded tag occurrences.",
dashboardOccurrences: "Occurrences",
dashboardOverviewTitle: "Dashboard overview",
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
@@ -1421,9 +1456,9 @@ export const translations = {
dashboardCustomize: "Customize dashboard",
dashboardSummaryCards: "Summary cards",
dashboardActivityChart: "Activity chart",
dashboardConversionFunnel: "Conversion funnel",
dashboardConversionFunnel: "Current pipeline",
dashboardTopCompanies: "Top companies",
dashboardSkillsInsights: "Skills insights",
dashboardSkillsInsights: "Job tags",
dashboardActiveApplications: "Active applications",
dashboardCurrentlyInProgress: "Currently in progress",
dashboardApplied30Days: "Applied (30 days)",
@@ -1443,7 +1478,7 @@ export const translations = {
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
dashboardConversionFunnelTitle: "Conversion funnel",
dashboardTimeInStageTitle: "Median time in stage",
dashboardTimeInStageValue: "{days}d · {count} active",
dashboardTimeInStageValue: "{days} days · {count} jobs",
dashboardResponseSources: "Response sources",
dashboardSalaryInsights: "Salary insights",
dashboardSalaryInsightsBody: "Comparable ranges from jobs in your tracker. Currencies and pay periods stay separate.",
@@ -1453,7 +1488,7 @@ export const translations = {
dashboardTopSkills: "Top skills",
dashboardNoTagsYet: "No tags yet.",
dashboardSkillTags: "skill tags",
dashboardSkillTrends: "Skill trends",
dashboardSkillTrends: "Monthly job-tag trends",
dashboardNoTagTrendData: "No tag trend data yet.",
remindersTitle: "Needs Follow-up",
remindersSubtitle: "Grouped by the most useful next action so you can fix gaps faster.",
@@ -2501,7 +2536,7 @@ export const translations = {
home: "Hjem",
analytics: "Analyse",
overview: "Oversikt",
dashboardPageSubtitle: "Søket ditt i korte trekk — svarrate, trakt og hva som trenger oppmerksomhet.",
dashboardPageSubtitle: "Neste steg, nåværende statusfordeling og søknadsaktivitet.",
jobsPageSubtitle: "Filtrer, søk og administrer alle søknader på ett sted.",
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
@@ -3859,6 +3894,41 @@ export const translations = {
cropDialogZoom: "Zoom",
cropDialogPreviewAlt: "Forhåndsvisning av beskjæring",
cropDialogSave: "Lagre bilde",
dashboardSnapshotScope: "Jobbsøket ditt nå. Aktivitetsdiagrammene har et eget periodevalg.",
dashboardAttentionBreakdown: "{followUp} oppfølginger · {preparation} intervjuforberedelser · {package} mangler i søknadspakker i oversikten",
dashboardTrackedJobs: "Registrerte jobber",
dashboardTrackedBody: "Inkluderer muligheter og avsluttede prosesser.",
dashboardRecordedDates: "Registrerte søknadsdatoer fra de siste 30 dagene, inkludert eventuelle fremtidige datoer.",
dashboardCurrentInterviews: "Jobber i intervjufasen",
dashboardCurrentOffers: "Nåværende tilbud",
dashboardCurrentStage: "Jobber som er i denne fasen nå.",
dashboardAttentionSample: "Viser {shown} av {count} elementer fra påminnelsene dine.",
dashboardCurrentPipeline: "Nåværende statusfordeling",
dashboardPipelineBody: "Jobber som ikke er slettet, etter nåværende fase. Registrerte egendefinerte statuser nedenfor.",
dashboardNoPipelineData: "Ingen jobber i standardfasene.",
dashboardRecordedStatuses: "Fordeling av registrerte statuser",
dashboardStartTitle: "Jobbsøket starter her",
dashboardStartBody: "Lagre en jobb du vil utforske. Aktivitet og mønstre vises etter hvert som du bygger oversikten.",
dashboardAllHidden: "Dashboard-seksjonene dine er skjult.",
dashboardTrendPeriod: "Periode for aktivitet og taggtrender",
dashboardActivityDefinition: "Søknader fra de valgte månedene og daterte svar på disse i samme intervall. Slettede jobber er utelatt.",
dashboardResponseSample: "{responses} / {total} registrerte svar",
dashboardNoWindowData: "Ingen søknader eller daterte svar i denne perioden.",
dashboardMonthlyValues: "Vis månedsverdier",
dashboardMonth: "Måned",
dashboardApplications: "Søknader",
dashboardChartLegend: "Heltrukket: søknader. Stiplet: daterte svar. Felles skala: 0–{max}. Nøyaktige verdier nedenfor.",
dashboardStageAge: "Median tid i nåværende fase",
dashboardStageAgeBody: "Jobber som fortsatt er i hver fase, målt fra siste registrerte overgang eller søknads-/lagringsdato.",
dashboardSourceBody: "Opptil seks kilder etter antall registrerte jobber. Nåværende selskapskilde; inkluderer muligheter. Svarmarkering eller svardato regnes som registrert svar.",
dashboardRecordedResponseSample: "{responses} av {total} jobber som ikke er slettet, har et registrert svar.",
dashboardSalaryDefinition: "Gjennomsnitt av registrerte lønnsintervallers midtpunkter, med observert min/maks og antall. Valutaer og lønnsperioder holdes adskilt.",
dashboardCompaniesBody: "Opptil åtte selskaper etter antall jobber som ikke er slettet, inkludert muligheter. Svar telles fra registrert markering eller dato.",
dashboardJobTags: "Jobbtagger",
dashboardTagsBody: "De åtte vanligste jobbtaggene på alle jobber som ikke er slettet. Alle perioder.",
dashboardTagTrendValues: "Vis månedlige taggverdier",
dashboardTagTrendBody: "De fem vanligste taggene på jobber med søknadsdato i valgt periode. Tallene viser registrerte taggforekomster.",
dashboardOccurrences: "Forekomster",
dashboardOverviewTitle: "Dashboard-oversikt",
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
@@ -3891,9 +3961,9 @@ export const translations = {
dashboardCustomize: "Tilpass dashboard",
dashboardSummaryCards: "Oppsummeringskort",
dashboardActivityChart: "Aktivitetsgraf",
dashboardConversionFunnel: "Konverteringstrakt",
dashboardConversionFunnel: "Nåværende statusfordeling",
dashboardTopCompanies: "Topp selskaper",
dashboardSkillsInsights: "Kompetanseinnsikt",
dashboardSkillsInsights: "Jobbtagger",
dashboardActiveApplications: "Aktive søknader",
dashboardCurrentlyInProgress: "Pågår nå",
dashboardApplied30Days: "Søkt (30 dager)",
@@ -3913,7 +3983,7 @@ export const translations = {
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
dashboardConversionFunnelTitle: "Konverteringstrakt",
dashboardTimeInStageTitle: "Median tid i fase",
dashboardTimeInStageValue: "{days}d · {count} aktive",
dashboardTimeInStageValue: "{days} dager · {count} jobber",
dashboardResponseSources: "Svar etter kilde",
dashboardSalaryInsights: "Lønnsinnsikt",
dashboardSalaryInsightsBody: "Sammenlignbare intervaller fra jobber i oversikten din. Valutaer og lønnsperioder holdes adskilt.",
@@ -3923,7 +3993,7 @@ export const translations = {
dashboardTopSkills: "Topp ferdigheter",
dashboardNoTagsYet: "Ingen tagger ennå.",
dashboardSkillTags: "ferdighetstagger",
dashboardSkillTrends: "Ferdighetstrender",
dashboardSkillTrends: "Månedlige jobbtaggtrender",
dashboardNoTagTrendData: "Ingen trenddata for tagger ennå.",
remindersTitle: "Trenger oppfølging",
remindersSubtitle: "Gruppert etter den mest nyttige neste handlingen slik at du kan lukke hull raskere.",