feat(ui): add A1 dashboard and resumable onboarding
This commit is contained in:
@@ -321,7 +321,12 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">{t("loading")}</Typography></Box>;
|
||||
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: `${path}${location.search}${location.hash}`, fromState: location.state }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
const greetingName = me?.firstName?.trim() || me?.displayName?.trim();
|
||||
const hour = new Date().getHours();
|
||||
const pageTitle = path === "/dashboard" && greetingName
|
||||
? hour < 12 ? t("dashboardGoodMorning", { name: greetingName })
|
||||
: hour < 18 ? t("dashboardGoodAfternoon", { name: greetingName }) : t("dashboardGoodEvening", { name: greetingName })
|
||||
: titleFor(path, t);
|
||||
const pageSubtitle = subtitleFor(path, t);
|
||||
const breadcrumbs = breadcrumbsFor(path, t);
|
||||
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
|
||||
|
||||
@@ -42,7 +42,7 @@ test('mount and compaction do not save; completed resize keeps hidden geometry a
|
||||
expect(change).toHaveBeenCalledTimes(1);
|
||||
const saved = change.mock.calls[0][0];
|
||||
expect(saved.revision).toBe(8);
|
||||
expect(saved.widgets[1]).toMatchObject({ w: 8, h: 12, size: 'standard' });
|
||||
expect(saved.widgets.find((widget: { id: string }) => widget.id === 'attention')).toMatchObject({ w: 8, h: 12, size: 'standard' });
|
||||
expect(saved.widgets[2]).toEqual(layout.widgets[2]);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ test('narrow viewport retains desktop geometry and keyboard height changes are b
|
||||
fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '25' } });
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '12' } });
|
||||
expect(change.mock.calls[0][0].widgets[1].h).toBe(12);
|
||||
expect(change.mock.calls[0][0].widgets.find((widget: { id: string }) => widget.id === 'attention').h).toBe(12);
|
||||
mockMeasuredWidth = 1200;
|
||||
view.rerender(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
|
||||
expect(change).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
|
||||
import { useDashboardLayout } from "../hooks/useDashboardLayout";
|
||||
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
|
||||
import DashboardGrid from "./DashboardGrid";
|
||||
import DashboardWeek from "./DashboardWeek";
|
||||
import { useOptionalToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel, statusTone } from "../pipeline";
|
||||
@@ -177,7 +178,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</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 = !layoutState.editing ? <Button ref={customizeButton} variant="outlined" startIcon={<TuneIcon />} disabled={layoutState.loading || Boolean(layoutState.loadError)} onClick={() => { setSavedMessage(false); layoutState.startEditing(); }}>{t("dashboardCustomize")}</Button> : null;
|
||||
const pipelineReport = <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
|
||||
const pipelineReport = <PageSection surface={reporting} title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
|
||||
<Box sx={reporting ? grid : undefined}>
|
||||
<Stack spacing={1.5}>
|
||||
{funnel.map(item => <Box key={item.label}>
|
||||
@@ -199,6 +200,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</PageSection>;
|
||||
|
||||
const reports: Record<WidgetId, React.ReactNode> = {
|
||||
agenda: summaryReady && hasJobs ? <DashboardWeek jobs={reminderJobs} /> : null,
|
||||
cards: summaryReady && hasJobs ? <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" },
|
||||
@@ -324,7 +326,6 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
|
||||
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
|
||||
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
|
||||
@@ -371,6 +372,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
if (!content && !(ready && (hasJobs || layoutState.editing))) return null;
|
||||
return content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>;
|
||||
}} />}
|
||||
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box, Link, Typography } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { PageSection } from '../layout/PageLayout';
|
||||
import type { JobApplication } from '../types';
|
||||
|
||||
// Date-only deadlines remain local calendar dates; never infer a time from free text.
|
||||
function storedDate(value: string | undefined): Date | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const parts = /^(\d{4})-(\d{2})-(\d{2})(?:$|T)/.exec(value);
|
||||
if (!parts) return null;
|
||||
const [year, month, day] = parts.slice(1).map(Number);
|
||||
const calendar = new Date(0); calendar.setFullYear(year, month - 1, day); calendar.setHours(0, 0, 0, 0);
|
||||
if (calendar.getFullYear() !== year || calendar.getMonth() !== month - 1 || calendar.getDate() !== day) return null;
|
||||
if (value.length === 10) return calendar;
|
||||
const time = /T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,7})?)?(?:Z|[+-]\d{2}:\d{2})?$/.exec(value);
|
||||
if (!time || Number(time[1]) > 23 || Number(time[2]) > 59 || Number(time[3] ?? 0) > 59) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export default function DashboardWeek({ jobs }: { jobs: JobApplication[] }) {
|
||||
const { t, language } = useI18n();
|
||||
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start); end.setDate(end.getDate() + 7);
|
||||
const events = jobs.flatMap(job => ([
|
||||
{ kind: 'deadline', raw: job.deadline, label: t('dashboardWeekDeadline') },
|
||||
{ kind: 'followUp', raw: job.followUpAt, label: t('dashboardWeekFollowUp') },
|
||||
]).flatMap(event => {
|
||||
const date = storedDate(event.raw);
|
||||
return date && date >= start && date < end ? [{ ...event, raw: event.raw!, date, job }] : [];
|
||||
})).sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
const format = new Intl.DateTimeFormat(language === 'nb' ? 'nb-NO' : 'en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
return <PageSection title={t('dashboardYourWeek')} description={t('dashboardWeekScope')}
|
||||
actions={<Link component={RouterLink} to="/reminders">{t('reminders')}</Link>}>
|
||||
{events.length ? <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, minmax(0, 1fr))' }, gap: 2 }}>
|
||||
{events.slice(0, 3).map(event => <Box key={`${event.job.id}:${event.kind}`} sx={{ borderInlineStart: '3px solid', borderColor: 'primary.main', pl: 1.5, minWidth: 0 }}>
|
||||
<Typography variant="caption" component="time" dateTime={event.raw}>{format.format(event.date)}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{event.label}</Typography>
|
||||
<Link component={RouterLink} to={`/jobs/${event.job.id}`} sx={{ display: 'inline-block', py: 1, overflowWrap: 'anywhere' }}>{event.job.jobTitle}</Link>
|
||||
<Typography variant="body2" color="text.secondary">{event.job.company?.name}</Typography>
|
||||
</Box>)}
|
||||
</Box> : <Typography color="text.secondary">{t('dashboardWeekEmpty')}</Typography>}
|
||||
</PageSection>;
|
||||
}
|
||||
@@ -42,12 +42,54 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
|
||||
try { return window.localStorage.getItem(dismissalKey) === "true"; }
|
||||
catch { return false; }
|
||||
});
|
||||
const skippedKey = dismissalKey.replace('onboardingDismissed:', 'onboardingSkipped:');
|
||||
const [skipped, setSkipped] = useState<string[]>(() => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(skippedKey) ?? '[]');
|
||||
return Array.isArray(value) ? ['/profile', '/career', '/settings/connected-accounts', '/jobs'].filter(path => value.includes(path)) : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||
const guideTitle = useRef<HTMLHeadingElement>(null);
|
||||
const guideEntry = useRef<HTMLButtonElement>(null);
|
||||
const resumeEntry = useRef<HTMLButtonElement>(null);
|
||||
const returnToEntry = useRef(false);
|
||||
const stepActions = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const pendingStepFocus = useRef<string | null>(null);
|
||||
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<{ cause: unknown } | null>(null);
|
||||
const [readAttempt, setReadAttempt] = useState(0);
|
||||
const dismissRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (guideStep !== null) guideTitle.current?.focus();
|
||||
else if (returnToEntry.current) {
|
||||
returnToEntry.current = false;
|
||||
(resumeEntry.current ?? guideEntry.current)?.focus();
|
||||
}
|
||||
}, [guideStep, dismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingStepFocus.current) return;
|
||||
(stepActions.current[pendingStepFocus.current] ?? guideEntry.current)?.focus();
|
||||
pendingStepFocus.current = null;
|
||||
}, [skipped]);
|
||||
|
||||
const resume = (walkthrough: boolean) => {
|
||||
try { localStorage.setItem(dismissalKey, 'false'); } catch { /* Session-only continuation remains available. */ }
|
||||
if (!walkthrough) returnToEntry.current = true;
|
||||
setDismissed(false); setGuideStep(walkthrough ? 0 : null);
|
||||
};
|
||||
const skip = (path: string, value: boolean) => {
|
||||
const next = skipped.filter(item => item !== path);
|
||||
if (value) next.push(path);
|
||||
try { localStorage.setItem(skippedKey, JSON.stringify(next)); } catch { /* Keep this session's choice. */ }
|
||||
pendingStepFocus.current = path;
|
||||
setSkipped(next);
|
||||
};
|
||||
const closeGuide = () => { returnToEntry.current = true; setGuideStep(null); };
|
||||
|
||||
useEffect(() => {
|
||||
if (dismissed) return;
|
||||
let active = true;
|
||||
@@ -87,8 +129,6 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
|
||||
return () => { active = false; };
|
||||
}, [dismissed, readAttempt]);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
// These four recorded steps are guidance, not a completeness score or proof of
|
||||
// verified email, an imported CV, a rendered PDF, or a live mailbox grant.
|
||||
const steps = [
|
||||
@@ -98,7 +138,17 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
|
||||
{ done: hasJobs, label: t("onboardingStepJob"), path: "/jobs", actionLabel: t("onboardingStepJobAction") },
|
||||
];
|
||||
const completed = steps.filter((step) => step.done === true).length;
|
||||
if (status && hasJobs !== null && completed === steps.length) return null;
|
||||
const allDone = Boolean(status && hasJobs !== null && completed === steps.length);
|
||||
if (dismissed || (allDone && guideStep === null)) return <Box component="section" aria-label={t('onboardingResumeTitle')}
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2, borderTop: '1px solid', borderColor: 'divider', pt: 2 }}>
|
||||
<Box sx={{ minWidth: 0 }}><Typography sx={{ fontWeight: 700 }}>{t('onboardingResumeTitle')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('onboardingResumeBody')}</Typography></Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{!allDone ? <Button ref={resumeEntry} onClick={() => resume(false)}>{t('onboardingResume')}</Button> : null}
|
||||
<Button ref={guideEntry} onClick={() => resume(true)}>{t('onboardingWalkthrough')}</Button>
|
||||
</Box>
|
||||
</Box>;
|
||||
const guideHelp = [t('onboardingGuideProfile'), t('onboardingGuideCareer'), t('onboardingGuideEmail'), t('onboardingGuideJob')];
|
||||
|
||||
return (
|
||||
<PageSection surface title={t("onboardingTitle")} description={t("onboardingBody")} actions={
|
||||
@@ -109,12 +159,14 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
|
||||
onClick={() => {
|
||||
try { window.localStorage.setItem(dismissalKey, "true"); }
|
||||
catch { /* This dismissal still applies while the page remains open. */ }
|
||||
setDismissed(true);
|
||||
returnToEntry.current = true;
|
||||
setGuideStep(null); setDismissed(true);
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}>
|
||||
<Button ref={guideEntry} onClick={() => setGuideStep(0)} sx={{ mb: 1 }}>{t('onboardingWalkthrough')}</Button>
|
||||
{loading ? <>
|
||||
<Typography role="status" variant="body2" color="text.secondary">{t("loading")}</Typography>
|
||||
<LinearProgress aria-label={t("loading")} sx={{ mt: 1.5 }} />
|
||||
@@ -145,11 +197,31 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
|
||||
{step.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{step.done === false ? <Button size="small" variant="text" sx={{ maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(step.path)}>{step.actionLabel}</Button> : null}
|
||||
{step.done === false ? <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{skipped.includes(step.path) ? <>
|
||||
<Typography variant="caption" sx={{ alignSelf: 'center' }}>{t('onboardingSkipped')}</Typography>
|
||||
<Button ref={node => { stepActions.current[step.path] = node; }} aria-label={t('onboardingResumeStep', { name: step.label })} onClick={() => skip(step.path, false)}>{t('onboardingResumeStepAction')}</Button>
|
||||
</> : <>
|
||||
<Button ref={node => { stepActions.current[step.path] = node; }} size="small" variant="text" sx={{ maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(step.path)}>{step.actionLabel}</Button>
|
||||
<Button aria-label={t('onboardingSkipStep', { name: step.label })} onClick={() => skip(step.path, true)}>{t('onboardingSkipAction')}</Button>
|
||||
</>}
|
||||
</Box> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{skipped.length ? <Typography variant="caption" sx={{ display: 'block', mt: 1 }}>{t('onboardingSkipHelp')}</Typography> : null}
|
||||
</> : null}
|
||||
{guideStep !== null ? <Box sx={{ mt: 3, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<PageSection title={steps[guideStep].label} titleRef={guideTitle} level="h3" description={guideHelp[guideStep]}>
|
||||
<Typography role="status" variant="body2">{t('onboardingGuideCount', { current: guideStep + 1, total: steps.length })}</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
|
||||
<Button onClick={() => navigate(steps[guideStep].path)}>{steps[guideStep].actionLabel}</Button>
|
||||
<Button disabled={guideStep === 0} onClick={() => setGuideStep(guideStep - 1)}>{t('onboardingGuidePrevious')}</Button>
|
||||
<Button onClick={() => guideStep === steps.length - 1 ? closeGuide() : setGuideStep(guideStep + 1)}>{guideStep === steps.length - 1 ? t('onboardingGuideFinish') : t('onboardingGuideNext')}</Button>
|
||||
<Button onClick={closeGuide}>{t('onboardingGuideClose')}</Button>
|
||||
</Box>
|
||||
</PageSection>
|
||||
</Box> : null}
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,9 +80,13 @@ test('all hidden widgets leave an accessible recovery and reset remains cancella
|
||||
test('keyboard ordering keeps focus and announces the resulting position without refetching reports', async () => {
|
||||
open(); const editor = await edit(); const before = mocked.get.mock.calls.length;
|
||||
const preview = widgetOptions('Today');
|
||||
const move = within(preview).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move);
|
||||
const move = within(preview).getByRole('button', { name: 'Move Today later' }); act(() => move.focus()); fireEvent.click(move);
|
||||
const earlier = within(preview).getByRole('button', { name: 'Move Today earlier' });
|
||||
await waitFor(() => expect(earlier).toHaveFocus());
|
||||
expect(screen.getByText('Today moved to position 2 of 10.')).toBeInTheDocument();
|
||||
fireEvent.click(earlier);
|
||||
await waitFor(() => expect(within(preview).getByRole('button', { name: 'Move Today later' })).toHaveFocus());
|
||||
expect(screen.getByText('Today moved to position 1 of 9.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Today moved to position 1 of 10.')).toBeInTheDocument();
|
||||
expect(mocked.get.mock.calls).toHaveLength(before);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,21 @@ beforeEach(() => {
|
||||
mockedApi.put.mockImplementation((_url: string, body: any) => { layout = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: layout }) as any; });
|
||||
});
|
||||
|
||||
test('A1 week glance shows recorded upcoming dates from attention data without inventing missing dates', async () => {
|
||||
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const deadline = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||
const jobs = [
|
||||
{ id: 91, jobTitle: 'School vacancy', company: { name: 'Linden School' }, deadline },
|
||||
{ id: 92, jobTitle: 'No recorded date', company: { name: 'Care centre' }, deadline: 'as soon as possible' },
|
||||
];
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/reminders') ? jobs : data(url) }) as any);
|
||||
open();
|
||||
const week = await screen.findByRole('region', { name: 'Your week' });
|
||||
expect(within(week).getByText('School vacancy')).toBeVisible();
|
||||
expect(within(week).queryByText('No recorded date')).not.toBeInTheDocument();
|
||||
expect(within(week).getByRole('link', { name: /School vacancy/ })).toHaveAttribute('href', '/jobs/91');
|
||||
});
|
||||
|
||||
test('[PRES-S01] exact monthly values, sample, custom statuses and separate salary group remain readable', async () => {
|
||||
open();
|
||||
expect(await screen.findByText('Tracked jobs')).toBeVisible();
|
||||
@@ -41,7 +56,7 @@ test('[PRES-S01] exact monthly values, sample, custom statuses and separate sala
|
||||
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();
|
||||
expect(screen.queryByText(/upcoming interviews|conversion funnel|active applications/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('[PRES-S02] all widget visibility choices save and survive remount', async () => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import DashboardWeek from './components/DashboardWeek';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import type { JobApplication } from './types';
|
||||
|
||||
beforeEach(() => { localStorage.clear(); jest.useFakeTimers(); jest.setSystemTime(new Date(2026, 10, 3, 12)); });
|
||||
afterEach(() => { jest.useRealTimers(); localStorage.clear(); });
|
||||
function open(deadline?: string, followUpAt?: string) {
|
||||
const jobs = [{ id: 3, jobTitle: 'Recorded role', company: { name: 'Example employer' }, deadline, followUpAt }] as JobApplication[];
|
||||
const original = JSON.stringify(jobs);
|
||||
const view = render(<MemoryRouter><I18nProvider><DashboardWeek jobs={jobs} /></I18nProvider></MemoryRouter>);
|
||||
expect(JSON.stringify(jobs)).toBe(original);
|
||||
return view;
|
||||
}
|
||||
test.each([
|
||||
['2026-11-03', true], ['2026-11-09', true], ['2026-11-10', false], ['2026-11-02', false],
|
||||
['as soon as possible', false], ['2026-11-04T24:00:00Z', false], ['2026-11-04T10:99:00Z', false],
|
||||
])('deadline %s respects the local seven-day boundary and recorded precision', (value, visible) => {
|
||||
const view = open(value);
|
||||
expect(screen.queryByRole('link', { name: 'Recorded role' }) !== null).toBe(visible);
|
||||
if (visible) expect(view.container.querySelector('time')).toHaveAttribute('datetime', value);
|
||||
});
|
||||
test('invalid calendar dates do not silently roll into a real upcoming day', () => {
|
||||
jest.setSystemTime(new Date(2026, 2, 1, 12)); open('2026-02-30');
|
||||
expect(screen.queryByRole('link', { name: 'Recorded role' })).not.toBeInTheDocument();
|
||||
});
|
||||
test('recorded follow-up timestamps and date-only deadlines stay distinct and untranslated', () => {
|
||||
localStorage.setItem('uiLanguage', 'nb'); const view = open('2026-11-04', '2026-11-05T10:30:00Z');
|
||||
expect(screen.getByRole('heading', { name: 'Uken din' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Søknadsfrist')).toBeInTheDocument(); expect(screen.getByText('Oppfølging')).toBeInTheDocument();
|
||||
expect([...view.container.querySelectorAll('time')].map(node => node.getAttribute('datetime'))).toEqual(['2026-11-04', '2026-11-05T10:30:00Z']);
|
||||
});
|
||||
@@ -31,13 +31,23 @@ test('schema two preserves chosen hidden geometry without resetting the owner re
|
||||
|
||||
test('default catalogue preserves every section and supported widths', () => {
|
||||
const layout = defaultDashboardLayout();
|
||||
expect(layout.widgets.map(w => w.id)).toEqual(['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
|
||||
expect(new Set(layout.widgets.map(w => w.id))).toEqual(new Set(WIDGETS.map(w => w.id)));
|
||||
expect(layout.widgets.every(w => w.visible)).toBe(true);
|
||||
expect(WIDGETS[0].sizes).toEqual(['full']);
|
||||
layout.widgets[0].visible = false;
|
||||
expect(defaultDashboardLayout().widgets[0].visible).toBe(true);
|
||||
});
|
||||
|
||||
test.each([1, 2])('new schema %s preferences use task-first A1 defaults without omitting widgets', version => {
|
||||
const layout = normalizeDashboardLayout({ version, revision: 0, widgets: [] });
|
||||
expect(layout.widgets[0]).toMatchObject({ id: 'attention', visible: true, x: 0, y: 0, w: 12 });
|
||||
expect(layout.widgets.findIndex(widget => widget.id === 'cards')).toBeGreaterThan(layout.widgets.findIndex(widget => widget.id === 'funnel'));
|
||||
expect(layout.widgets).toHaveLength(WIDGETS.length);
|
||||
const visible = layout.widgets.filter(widget => widget.visible);
|
||||
expect(visible.every(widget => widget.x >= 0 && widget.x + widget.w <= 12 && widget.h <= 24 && widget.y + widget.h <= 256)).toBe(true);
|
||||
expect(visible.some((a, index) => visible.slice(index + 1).some(b => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y))).toBe(false);
|
||||
});
|
||||
|
||||
test('saved layouts retain known order, reject duplicates and append missing sections hidden', () => {
|
||||
const result = normalizeDashboardLayout({ version: 1, revision: 7, widgets: [
|
||||
{ id: 'skills', visible: false, size: 'full' },
|
||||
@@ -49,7 +59,7 @@ test('saved layouts retain known order, reject duplicates and append missing sec
|
||||
expect(result.widgets[0]).toMatchObject({ id: 'skills', visible: false, size: 'full' });
|
||||
expect(result.widgets[1]).toMatchObject({ id: 'cards', visible: false, size: 'full' });
|
||||
expect(result.widgets.slice(2).every(w => !w.visible)).toBe(true);
|
||||
expect(result.widgets).toHaveLength(9);
|
||||
expect(result.widgets).toHaveLength(WIDGETS.length);
|
||||
});
|
||||
|
||||
test.each([null, {}, { version: 3, revision: 8, widgets: [] }, { version: 1, revision: -1, widgets: [] }])('invalid input has safe defaults: %j', input => {
|
||||
|
||||
@@ -3,6 +3,7 @@ export type WidgetSize = 'standard' | 'full';
|
||||
export const WIDGETS = [
|
||||
{ id: 'cards', titleKey: 'dashboardSummaryCards', defaultSize: 'full', sizes: ['full'] },
|
||||
{ id: 'attention', titleKey: 'dashboardTodayTitle', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'agenda', titleKey: 'dashboardYourWeek', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'funnel', titleKey: 'dashboardCurrentPipeline', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'activity', titleKey: 'dashboardApplicationActivity', defaultSize: 'full', sizes: ['standard', 'full'] },
|
||||
{ id: 'stageAge', titleKey: 'dashboardStageAge', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
@@ -29,8 +30,12 @@ export function arrangeDashboardWidgets(widgets: DashboardWidget[]): DashboardWi
|
||||
}
|
||||
|
||||
export function defaultDashboardLayout(): DashboardLayout {
|
||||
return { version: 2, revision: 0, widgets: arrangeDashboardWidgets(WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize,
|
||||
x: 0, y: 0, w: w.defaultSize === 'full' ? 12 : 6, h: w.id === 'cards' ? 5 : 8 }))) };
|
||||
const order: WidgetId[] = ['attention', 'agenda', 'funnel', 'cards', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills'];
|
||||
return { version: 2, revision: 0, widgets: arrangeDashboardWidgets(order.map(id => {
|
||||
const definition = WIDGETS.find(widget => widget.id === id)!;
|
||||
const w = id === 'attention' ? 12 : id === 'agenda' ? 8 : id === 'funnel' ? 4 : definition.defaultSize === 'full' ? 12 : 6;
|
||||
return { id, visible: true, size: w === 12 ? 'full' : 'standard', x: 0, y: 0, w, h: id === 'cards' ? 5 : id === 'attention' ? 9 : 8 };
|
||||
})) };
|
||||
}
|
||||
|
||||
export function normalizeDashboardLayout(input: unknown): DashboardLayout {
|
||||
@@ -54,8 +59,8 @@ export function normalizeDashboardLayout(input: unknown): DashboardLayout {
|
||||
size: w === 12 ? 'full' : 'standard', x: valid ? item.x : 0, y: valid ? item.y : 0, w,
|
||||
h: valid ? item.h : definition.id === 'cards' ? 5 : 8 });
|
||||
}
|
||||
for (const definition of WIDGETS) {
|
||||
if (!seen.has(definition.id)) widgets.push({ ...defaultDashboardLayout().widgets.find(w => w.id === definition.id)!, visible: revision === 0 });
|
||||
for (const widget of defaultDashboardLayout().widgets) {
|
||||
if (!seen.has(widget.id)) widgets.push({ ...widget, visible: revision === 0 });
|
||||
}
|
||||
return { version: 2, revision, widgets: value.version === 1 ? arrangeDashboardWidgets(widgets) : widgets };
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ test('loads once, migrates only owner legacy flags, and cancels without writing'
|
||||
localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false, activity: false }));
|
||||
const { result, rerender } = renderHook(() => useDashboardLayout(true));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.layout.widgets[0].visible).toBe(false);
|
||||
expect(result.current.layout.widgets[1].visible).toBe(true);
|
||||
expect(result.current.layout.widgets.find(widget => widget.id === 'cards')?.visible).toBe(false);
|
||||
expect(result.current.layout.widgets.find(widget => widget.id === 'attention')?.visible).toBe(true);
|
||||
act(() => result.current.startEditing());
|
||||
act(() => result.current.setDraft(d => d && ({ ...d, widgets: [...d.widgets].reverse() })));
|
||||
rerender();
|
||||
|
||||
@@ -1960,6 +1960,25 @@ export const translations = {
|
||||
dashboardOverviewTitle: "Dashboard overview",
|
||||
dashboardHeroLabel: "Job search overview",
|
||||
onboardingTitle: "Get set up",
|
||||
onboardingResumeTitle: "Make Jobbjakt yours",
|
||||
onboardingResumeBody: "Optional setup. Pick up whenever you are ready.",
|
||||
onboardingResume: "Continue setup",
|
||||
onboardingWalkthrough: "Show walkthrough",
|
||||
onboardingSkipAction: "Skip for now",
|
||||
onboardingSkipStep: "Skip for now: {name}",
|
||||
onboardingResumeStep: "Resume step: {name}",
|
||||
onboardingResumeStepAction: "Resume",
|
||||
onboardingSkipped: "Skipped for now",
|
||||
onboardingSkipHelp: "Skipped steps stay optional and do not count as completed.",
|
||||
onboardingGuideProfile: "Review your account details and security. Your account identity is separate from your Career Profile.",
|
||||
onboardingGuideCareer: "Record experience you want to reuse. Import is optional; review changes before applying them to your Career Profile.",
|
||||
onboardingGuideEmail: "Connect a mailbox only if you want to. Sign-in identity and mailbox access are separate permissions.",
|
||||
onboardingGuideJob: "Add a job manually or discover an opportunity. Setup is never required before you start tracking.",
|
||||
onboardingGuidePrevious: "Previous step",
|
||||
onboardingGuideNext: "Next step",
|
||||
onboardingGuideFinish: "Finish walkthrough",
|
||||
onboardingGuideClose: "Close walkthrough",
|
||||
onboardingGuideCount: "Walkthrough step {current} of {total}",
|
||||
onboardingBody: "Setup guidance based on your saved account details, Career information, mailbox connections and tracked jobs.",
|
||||
onboardingLoadFailed: "Could not load setup status.",
|
||||
onboardingJobStatusUnknown: "Tracked-job status is unavailable. Retry the dashboard summary to check your jobs.",
|
||||
@@ -1976,6 +1995,14 @@ export const translations = {
|
||||
onboardingStepEmailAction: "Connect email",
|
||||
dashboardResponseRate: "{rate}% response rate",
|
||||
dashboardTodayTitle: "Today",
|
||||
dashboardYourWeek: "Your week",
|
||||
dashboardGoodMorning: "Good morning, {name}",
|
||||
dashboardGoodAfternoon: "Good afternoon, {name}",
|
||||
dashboardGoodEvening: "Good evening, {name}",
|
||||
dashboardWeekScope: "Upcoming deadlines and follow-ups recorded in your current attention list. More dates may exist outside this list.",
|
||||
dashboardWeekDeadline: "Application deadline",
|
||||
dashboardWeekFollowUp: "Follow-up",
|
||||
dashboardWeekEmpty: "No recorded dates in these priorities for the next seven days.",
|
||||
dashboardTodayBody: "Your highest-priority application actions, ordered by urgency.",
|
||||
dashboardSummaryUnavailableTitle: "Unable to load dashboard summary",
|
||||
dashboardSummaryUnavailableBody: "The dashboard summary is unavailable right now.",
|
||||
@@ -5085,6 +5112,25 @@ export const translations = {
|
||||
dashboardOverviewTitle: "Dashboard-oversikt",
|
||||
dashboardHeroLabel: "Oversikt over jobbsøket",
|
||||
onboardingTitle: "Kom i gang",
|
||||
onboardingResumeTitle: "Gjør Jobbjakt til ditt",
|
||||
onboardingResumeBody: "Valgfritt oppsett. Fortsett når du er klar.",
|
||||
onboardingResume: "Fortsett oppsett",
|
||||
onboardingWalkthrough: "Vis veiledning",
|
||||
onboardingSkipAction: "Hopp over foreløpig",
|
||||
onboardingSkipStep: "Hopp over foreløpig: {name}",
|
||||
onboardingResumeStep: "Fortsett steg: {name}",
|
||||
onboardingResumeStepAction: "Fortsett",
|
||||
onboardingSkipped: "Hoppet over foreløpig",
|
||||
onboardingSkipHelp: "Steg du hopper over, er fortsatt valgfrie og telles ikke som fullført.",
|
||||
onboardingGuideProfile: "Se gjennom kontodetaljer og sikkerhet. Kontoidentiteten er atskilt fra karriereprofilen din.",
|
||||
onboardingGuideCareer: "Registrer erfaring du vil gjenbruke. Import er valgfritt; se gjennom endringer før du tar dem inn i karriereprofilen.",
|
||||
onboardingGuideEmail: "Koble til en postboks bare hvis du ønsker det. Innloggingsidentitet og postbokstilgang er ulike tillatelser.",
|
||||
onboardingGuideJob: "Legg til en jobb manuelt eller finn en stilling. Oppsett er aldri påkrevd før du begynner å følge opp jobber.",
|
||||
onboardingGuidePrevious: "Forrige steg",
|
||||
onboardingGuideNext: "Neste steg",
|
||||
onboardingGuideFinish: "Fullfør veiledning",
|
||||
onboardingGuideClose: "Lukk veiledning",
|
||||
onboardingGuideCount: "Veiledning, steg {current} av {total}",
|
||||
onboardingBody: "Veiledning basert på lagrede kontoopplysninger, karriereinformasjon, tilkoblede postkasser og stillinger du følger.",
|
||||
onboardingLoadFailed: "Kunne ikke laste oppsettstatus.",
|
||||
onboardingJobStatusUnknown: "Status for stillinger er ikke tilgjengelig. Last oversikten på dashbordet på nytt for å sjekke stillingene dine.",
|
||||
@@ -5101,6 +5147,14 @@ export const translations = {
|
||||
onboardingStepEmailAction: "Koble til e-post",
|
||||
dashboardResponseRate: "{rate}% svarrate",
|
||||
dashboardTodayTitle: "I dag",
|
||||
dashboardYourWeek: "Uken din",
|
||||
dashboardGoodMorning: "God morgen, {name}",
|
||||
dashboardGoodAfternoon: "God ettermiddag, {name}",
|
||||
dashboardGoodEvening: "God kveld, {name}",
|
||||
dashboardWeekScope: "Kommende søknadsfrister og oppfølginger i listen over det du bør følge opp. Flere datoer kan finnes utenfor listen.",
|
||||
dashboardWeekDeadline: "Søknadsfrist",
|
||||
dashboardWeekFollowUp: "Oppfølging",
|
||||
dashboardWeekEmpty: "Ingen registrerte datoer blant disse prioriteringene de neste sju dagene.",
|
||||
dashboardTodayBody: "De viktigste handlingene for søknadene dine, sortert etter hvor mye de haster.",
|
||||
dashboardSummaryUnavailableTitle: "Kunne ikke laste dashboard-oppsummeringen",
|
||||
dashboardSummaryUnavailableBody: "Dashboard-oppsummeringen er ikke tilgjengelig akkurat nå.",
|
||||
|
||||
@@ -56,6 +56,43 @@ afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test('a dismissed optional checklist resumes without losing source-backed progress', async () => {
|
||||
localStorage.setItem('onboardingDismissed:owner-a', 'true');
|
||||
const view = render(<Checklist />);
|
||||
expect(mockedApi.get).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue setup' }));
|
||||
expect(await screen.findByText('3 / 4')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('onboardingDismissed:owner-a')).toBe('false');
|
||||
view.unmount(); render(<Checklist />);
|
||||
expect(await screen.findByText('3 / 4')).toBeInTheDocument();
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
test('individual skips persist for their owner but do not count as completed data', async () => {
|
||||
const view = render(<Checklist />); await screen.findByText('3 / 4');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Skip for now:/ }));
|
||||
expect(screen.getByRole('progressbar', { name: '3 of 4 done' })).toHaveAttribute('aria-valuenow', '75');
|
||||
expect(localStorage.getItem('onboardingSkipped:owner-a')).toBe('["/jobs"]');
|
||||
expect(screen.getByRole('button', { name: /^Resume step:/ })).toHaveFocus();
|
||||
view.unmount(); render(<Checklist />); await screen.findByText('3 / 4');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Resume step:/ }));
|
||||
expect(screen.getByRole('button', { name: translations.en.onboardingStepJobAction })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: translations.en.onboardingStepJobAction })).toHaveFocus();
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
test('on-demand walkthrough changes guidance and focus without changing completion or connecting providers', async () => {
|
||||
render(<Checklist />); await screen.findByText('3 / 4');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show walkthrough' }));
|
||||
const guide = screen.getByRole('region', { name: translations.en.onboardingStepProfile });
|
||||
expect(within(guide).getByRole('heading')).toHaveFocus();
|
||||
fireEvent.click(within(guide).getByRole('button', { name: 'Next step' }));
|
||||
expect(screen.getByRole('heading', { name: translations.en.onboardingStepCv })).toHaveFocus();
|
||||
expect(screen.getByText('3 / 4')).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(5);
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
test("uses structured career data and connected inbox status for four source-backed onboarding steps", async () => {
|
||||
render(<Checklist />);
|
||||
|
||||
@@ -145,7 +182,7 @@ test.each(sources)("failed %s status stays unknown and retries only the same sou
|
||||
|
||||
rejectFirst = false;
|
||||
const retry = within(alert).getByRole("button", { name: translations.en.retry });
|
||||
retry.focus();
|
||||
act(() => retry.focus());
|
||||
fireEvent.click(retry);
|
||||
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: translations.en.onboardingDismiss })).toHaveFocus();
|
||||
|
||||
Reference in New Issue
Block a user