feat(ui): add A1 dashboard and resumable onboarding
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user