From 58868fc2b62ff1a390f145cc9bd4acd32fa446c0 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 04:05:32 +0200 Subject: [PATCH 1/3] feat(ux): first-time onboarding, empty states, and copy fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the six "propose first" items from the product/UX review: - "/" now redirects to /dashboard instead of the empty /jobs table -- a new user's first screen is now an overview with orientation, not a data table with zero rows and four filter dropdowns. - Jobs table gets a real first-time empty state (distinct from "no results match your filters") pointing at Add Job and the bookmarklet, instead of a bare "No jobs found." - Match Score card and Candidate Fit tab now each get a one-line caption explaining what they are and how they differ (deterministic keyword coverage vs. AI opinion) -- they previously sat side by side with no explanation of why there are two. - Google sign-in hint now reflects self-serve signup when Auth:AllowRegistration is on, instead of always implying you need an existing linked account. - Quick Search button now shows its keyboard shortcut (Ctrl+K / ⌘K) inline instead of being undiscoverable. --- job-tracker-ui/src/App.tsx | 14 +++++++--- .../src/components/GoogleAuthCard.tsx | 6 ++++- .../src/components/JobDetailsDialog.tsx | 1 + job-tracker-ui/src/components/JobTable.tsx | 27 +++++++++++++++++-- job-tracker-ui/src/i18n/translations.ts | 14 ++++++++-- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 855155d..0210bd3 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -123,6 +123,9 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const path = location.pathname; const isJobs = path.startsWith("/jobs"); + const shortcutHint = useMemo(() => ( + typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K" + ), []); useEffect(() => { api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); @@ -246,14 +249,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo setQuickOpen(true)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }} > ) : ( - + )} {isJobs ? ( + + ); +} + function generateOverview(job: JobApplication): string { if (job.fullSummary) return job.fullSummary; if (job.shortSummary) return job.shortSummary; @@ -220,6 +233,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col return jobs.filter((job) => needsWorkflowWork(job)); }, [jobs, readinessFilter]); + // Distinguishes "you have zero jobs, period" from "no results match your filters" so the + // empty state can actually help a first-time user instead of just saying "nothing here". + const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All" + && !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all"; + const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive; + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id)); @@ -629,7 +648,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ); })} - {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? {t("jobTableNoJobsFound")} : null} + {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? ( + navigate("/settings")} t={t} /> + ) : null} ) : ( @@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ); })} - {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? {t("jobTableNoJobsFound")} : null} + {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? ( + navigate("/settings")} t={t} /> + ) : null} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 84c091f..76432ce 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -606,6 +606,7 @@ export const translations = { googleAvailableToLink: "Available to link", googleLinkedDate: "Linked {date}", googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.", + googleSignInHintSelfServe: "Continue with Google. New here? We'll create your account automatically.", continueWithGoogle: "Continue with Google", signInWithGoogle: "Sign in with Google", linkWithGoogle: "Link with Google", @@ -750,6 +751,9 @@ export const translations = { jobTableOverview: "Overview", jobTableNoSummaryYet: "No summary yet.", jobTableNoJobsFound: "No jobs found.", + jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.", + jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.", + jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet", jobTableSetStatus: "Set {status}", editJobTitle: "Edit job", editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.", @@ -894,6 +898,7 @@ export const translations = { jobDetailsFollowUpSent: "Follow-up sent and logged.", jobDetailsFollowUpSendFailed: "Failed to send follow-up.", jobDetailsHowYouMatch: "How you match", + jobDetailsAiFitHint: "AI opinion — strengths, gaps, and a tailored pitch based on your CV and this posting.", matchScoreTitle: "Match score", matchScoreLoading: "Scoring your CV against this role…", matchScoreBand_Strong: "Strong match", @@ -902,7 +907,7 @@ export const translations = { matchScoreBand_Unknown: "Not enough signal", matchScoreKeywordsCovered: "{matched}/{total} keywords", matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.", - matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.", + matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.", matchScoreMatched: "Matched keywords", matchScoreMissing: "Missing keywords", matchScoreNoneYet: "No matches found yet.", @@ -1564,6 +1569,7 @@ export const translations = { googleAvailableToLink: "Tilgjengelig for kobling", googleLinkedDate: "Koblet {date}", googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.", + googleSignInHintSelfServe: "Fortsett med Google. Ny her? Vi oppretter kontoen din automatisk.", continueWithGoogle: "Fortsett med Google", signInWithGoogle: "Logg inn med Google", linkWithGoogle: "Koble til med Google", @@ -1708,6 +1714,9 @@ export const translations = { jobTableOverview: "Oversikt", jobTableNoSummaryYet: "Ingen oppsummering ennå.", jobTableNoJobsFound: "Ingen jobber funnet.", + jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.", + jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.", + jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket", jobTableSetStatus: "Sett {status}", editJobTitle: "Rediger jobb", editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.", @@ -1852,6 +1861,7 @@ export const translations = { jobDetailsFollowUpSent: "Oppfølging sendt og loggført.", jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.", jobDetailsHowYouMatch: "Slik matcher du", + jobDetailsAiFitHint: "AI-vurdering — styrker, svakheter og et skreddersydd pitch basert på CV-en din og denne annonsen.", matchScoreTitle: "Match-score", matchScoreLoading: "Vurderer CV-en mot denne stillingen…", matchScoreBand_Strong: "Sterk match", @@ -1860,7 +1870,7 @@ export const translations = { matchScoreBand_Unknown: "For lite grunnlag", matchScoreKeywordsCovered: "{matched}/{total} nøkkelord", matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.", - matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.", + matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.", matchScoreMatched: "Treff på nøkkelord", matchScoreMissing: "Manglende nøkkelord", matchScoreNoneYet: "Ingen treff ennå.", From 9615ee3f4121bb9e210256064f678b58db1d1504 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 04:14:37 +0200 Subject: [PATCH 2/3] feat(ux): per-view subtitles, correspondence cross-links, mobile kanban, a11y Continuing the product/UX review's deferred items: - Every top-level view now gets a one-line subtitle under its title (Dashboard/Jobs/Kanban/Reminders/Correspondence/Gmail review) stating what that specific view is for, instead of navigation being the only signal of what each page does. - Correspondence inbox and Gmail review queue cross-link to each other instead of being two unexplained flat sidebar items -- kept both nav entries (renaming/nesting risked breaking muscle memory) but made the relationship between them explicit in the UI itself. - Kanban board switches to a horizontal scroll-snap row on phone-width viewports instead of stacking all 5 columns vertically, which meant a lot of scrolling to see anything past "Applied". - Match-score ring gets an aria-label with the actual percentage -- it was two nested decorative CircularProgress elements with no accessible text. (Keyboard-accessible status changes on kanban cards were already covered by the existing "..." menu -- no gap there.) --- job-tracker-ui/src/App.tsx | 12 ++++++++++++ .../src/components/JobDetailsDialog.tsx | 5 +++-- job-tracker-ui/src/components/KanbanBoard.tsx | 15 ++++++++++++++- job-tracker-ui/src/i18n/translations.ts | 12 ++++++++++++ job-tracker-ui/src/layout/AppShell.tsx | 7 +++++++ .../src/views/CorrespondenceInboxPage.tsx | 3 ++- job-tracker-ui/src/views/GmailReviewPage.tsx | 1 + 7 files changed, 51 insertions(+), 4 deletions(-) diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 0210bd3..bb8e0cd 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -100,6 +100,16 @@ function titleFor(path: string, t: (k: any) => string): string { return t("appTitle"); } +function subtitleFor(path: string, t: (k: any) => string): string | undefined { + if (path === "/dashboard") return t("dashboardPageSubtitle"); + if (path.startsWith("/jobs")) return t("jobsPageSubtitle"); + if (path.startsWith("/kanban")) return t("kanbanPageSubtitle"); + if (path.startsWith("/reminders")) return t("remindersPageSubtitle"); + if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle"); + if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle"); + return undefined; +} + function PageLoader() { return Loading...; } @@ -209,6 +219,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo if (requireAuth && !me) return ; const pageTitle = 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)); }; const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); }; @@ -275,6 +286,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo <> {score.hasEnoughSignal ? ( - - + + - + } label={`${items.length} items`} variant="outlined" /> 0 ? "success" : "default"} /> + diff --git a/job-tracker-ui/src/views/GmailReviewPage.tsx b/job-tracker-ui/src/views/GmailReviewPage.tsx index ea6fc33..4b058e9 100644 --- a/job-tracker-ui/src/views/GmailReviewPage.tsx +++ b/job-tracker-ui/src/views/GmailReviewPage.tsx @@ -138,6 +138,7 @@ export default function GmailReviewPage() { + From d5d82cb5289e729c0de519dd849c361f9fdfe7cc Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 04:26:17 +0200 Subject: [PATCH 3/3] feat(ux): onboarding checklist, dashboard-first landing (fixed) Dashboard onboarding checklist: a dismissible card with 3 steps (add CV, import first job, check match score), each linking straight to where you'd do it. Auto-hides once both CV and a job exist; otherwise persists per-user via localStorage until dismissed. Fixes the actual authenticated-landing redirect to /dashboard: my earlier commit changed App.tsx's inner Shell route for "/", which turned out to be dead code -- the outer router claims "/" for LandingPage first, so Shell's own "/" route is never reached on a direct hit. The real redirect lives in LandingPage.tsx's post-auth-check navigate() and LoginPage.tsx's post-login nextPath default; both now point at /dashboard. Verified live: an authenticated visitor hitting "/" now lands on Dashboard with the onboarding checklist visible, confirmed via rendered page text and screenshot. --- .../src/components/DashboardView.tsx | 2 + .../src/components/OnboardingChecklist.tsx | 81 +++++++++++++++++++ job-tracker-ui/src/i18n/translations.ts | 18 +++++ job-tracker-ui/src/views/LandingPage.tsx | 2 +- job-tracker-ui/src/views/LoginPage.tsx | 2 +- 5 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 job-tracker-ui/src/components/OnboardingChecklist.tsx diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index a99d21c..957b615 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph"; 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"; @@ -287,6 +288,7 @@ export default function DashboardView() { return ( + 0} /> (null); + const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1"); + + useEffect(() => { + let active = true; + api.get("/auth/me") + .then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); }) + .catch(() => { if (active) setHasCv(false); }); + return () => { active = false; }; + }, []); + + const allDone = hasCv === true && hasJobs; + if (dismissed || allDone || hasCv === null) return null; + + const dismiss = () => { + window.localStorage.setItem(dismissKey(), "1"); + setDismissed(true); + }; + + const steps = [ + { done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") }, + { done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") }, + { done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") }, + ]; + + return ( + + + + + {t("onboardingTitle")} + {t("onboardingBody")} + + {steps.map((step) => ( + + + {step.done ? : } + + {step.label} + + + {!step.done ? : null} + + ))} + + + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 15a1855..e43bf39 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -312,6 +312,15 @@ export const translations = { cropDialogSave: "Save image", dashboardOverviewTitle: "Dashboard overview", dashboardHeroLabel: "Job search overview", + onboardingTitle: "Get set up", + onboardingBody: "A few steps to get the most out of Jobbjakt.", + onboardingDismiss: "Dismiss", + onboardingStepCv: "Add your CV", + onboardingStepCvAction: "Add CV", + onboardingStepJob: "Import your first job", + onboardingStepJobAction: "Add job", + onboardingStepMatch: "Check your CV match score on a job", + onboardingStepMatchAction: "Open jobs", dashboardResponseRate: "{rate}% response rate", dashboardMonthsShort: "{count} mo", dashboardAppliedCount: "{count} applied", @@ -1281,6 +1290,15 @@ export const translations = { cropDialogSave: "Lagre bilde", dashboardOverviewTitle: "Dashboard-oversikt", dashboardHeroLabel: "Oversikt over jobbsøket", + onboardingTitle: "Kom i gang", + onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.", + onboardingDismiss: "Lukk", + onboardingStepCv: "Legg til CV-en din", + onboardingStepCvAction: "Legg til CV", + onboardingStepJob: "Importer din første jobb", + onboardingStepJobAction: "Legg til jobb", + onboardingStepMatch: "Sjekk CV-matchscoren på en jobb", + onboardingStepMatchAction: "Åpne jobber", dashboardResponseRate: "{rate}% svarrate", dashboardMonthsShort: "{count} md", dashboardAppliedCount: "{count} søkt", diff --git a/job-tracker-ui/src/views/LandingPage.tsx b/job-tracker-ui/src/views/LandingPage.tsx index 7029a26..34a4ccb 100644 --- a/job-tracker-ui/src/views/LandingPage.tsx +++ b/job-tracker-ui/src/views/LandingPage.tsx @@ -50,7 +50,7 @@ export default function LandingPage() { let active = true; api .get("/auth/me") - .then(() => { if (active) navigate("/jobs", { replace: true }); }) + .then(() => { if (active) navigate("/dashboard", { replace: true }); }) .catch(() => { if (active) setChecking(false); }); return () => { active = false; }; }, [navigate]); diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index ebab1a2..6c2817e 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -33,7 +33,7 @@ export default function LoginPage() { const [rememberMe, setRememberMe] = useState(() => getRememberMePref()); const [loading, setLoading] = useState(false); - const nextPath = (location?.state?.from as string | undefined) ?? "/jobs"; + const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard"; useEffect(() => { api