merge: reconcile perf/wave1-perf with main (Wave 0 features)
CI and Deploy / test (pull_request) Successful in 2m13s
CI and Deploy / deploy (pull_request) Has been skipped

Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut:

- useViewResource.ts: main's e352aae already fixes the render loop the same way
  (load in a ref, dropped from deps) — took main's canonical version. My
  independent fix is superseded (my branch predated e352aae, which is why the
  loop reproduced live).
- JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my
  AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays
  delegated to AnalyticsService.
- Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven
  funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add
  StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API
  contract the frontend expects.

Build clean; backend suite 135/135 green.
This commit is contained in:
cesnimda
2026-07-05 20:16:40 +02:00
67 changed files with 3254 additions and 498 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
FROM nginx:1.29.8-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html
+38 -23
View File
@@ -1,25 +1,40 @@
{
"short_name": "JobTrack",
"name": "JobTrack — Job Application Tracker",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0b1224",
"background_color": "#0b1224"
"short_name": "Jobbjakt",
"name": "Jobbjakt — Job Application Tracker",
"description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.",
"id": "/",
"scope": "/",
"start_url": ".",
"display": "standalone",
"orientation": "portrait-primary",
"categories": ["productivity", "business"],
"theme_color": "#15803d",
"background_color": "#0b1224",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"share_target": {
"action": "/",
"method": "GET",
"params": {
"url": "add",
"text": "addtext"
}
}
}
+16 -1
View File
@@ -32,6 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import RouteErrorPage from "./pages/RouteErrorPage";
import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
@@ -109,6 +110,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
const [addOpen, setAddOpen] = useState(false);
const [captureUrl, setCaptureUrl] = useState<string | undefined>(undefined);
const [quickOpen, setQuickOpen] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
@@ -124,6 +126,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
useEffect(() => {
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
}, []);
// Quick-capture target: bookmarklet (/?add=<url>) or PWA share (url in `add`, or a link
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
useEffect(() => {
const url = resolveCaptureUrl(location.search);
if (!url) return;
setCaptureUrl(url);
setAddOpen(true);
const params = new URLSearchParams(location.search);
params.delete("add");
params.delete("addtext");
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
}, [location.search, location.pathname, navigate]);
useEffect(() => {
let active = true;
api.get<MeResponse>("/auth/me")
@@ -288,7 +303,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
</AppShell>
<Suspense fallback={null}>
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
</Suspense>
</>
+22
View File
@@ -0,0 +1,22 @@
import { resolveCaptureUrl } from './captureUrl';
describe('resolveCaptureUrl', () => {
test('reads the bookmarklet add param', () => {
expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job');
});
test('extracts a url embedded in shared text', () => {
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now')))
.toBe('https://example.com/job/42');
});
test('prefers add over addtext', () => {
expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com')))
.toBe('https://a.com');
});
test('returns null when there is no url', () => {
expect(resolveCaptureUrl('')).toBeNull();
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull();
});
});
+10
View File
@@ -0,0 +1,10 @@
// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`)
// or the PWA share-target (a link in `add`, or embedded in shared `addtext`).
export function resolveCaptureUrl(search: string): string | null {
const params = new URLSearchParams(search);
const add = params.get("add");
if (add) return add;
const addText = params.get("addtext");
if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null;
return null;
}
+43 -21
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
@@ -30,12 +30,14 @@ import { Company, JobImportResult } from "../types";
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline";
import TagsInput from "./TagsInput";
interface Props {
open: boolean;
onClose: () => void;
onCreated: () => void;
initialUrl?: string;
}
type DuplicateCandidate = {
@@ -60,7 +62,6 @@ type CreatedJobResponse = {
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
@@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) {
return raw;
}
export default function AddJobModal({ open, onClose, onCreated }: Props) {
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
const { toast } = useToast();
const { t, language } = useI18n();
@@ -115,9 +116,13 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
const [dateApplied, setDateApplied] = useState(() => getTodayIso());
const [jobTitle, setJobTitle] = useState("");
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
const [status, setStatus] = useState<(typeof PIPELINE_STATUSES)[number]>("Applied");
const [location, setLocation] = useState("");
const [salary, setSalary] = useState("");
const [salaryMin, setSalaryMin] = useState("");
const [salaryMax, setSalaryMax] = useState("");
const [salaryCurrency, setSalaryCurrency] = useState("");
const [salaryPeriod, setSalaryPeriod] = useState("");
const [jobUrl, setJobUrl] = useState("");
const [deadline, setDeadline] = useState("");
@@ -133,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
setCompanies(cachedCompanies);
}, [cachedCompanies]);
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
const autoImportedUrlRef = useRef<string | null>(null);
useEffect(() => {
if (!open) {
autoImportedUrlRef.current = null;
return;
}
const url = initialUrl?.trim();
if (!url || autoImportedUrlRef.current === url) return;
autoImportedUrlRef.current = url;
setJobUrl(url);
void importFromUrl(url);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialUrl]);
const resetForm = () => {
setCompany(null);
setCompanyInput("");
@@ -219,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
}
};
const importFromUrl = async () => {
const importFromUrl = async (urlArg?: string) => {
if (importing) return;
if (!jobUrl.trim()) {
const url = (urlArg ?? jobUrl).trim();
if (!url) {
toast(t("addJobModalPasteUrlFirst"), "warning");
return;
}
setImporting(true);
try {
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
const res = await api.post<JobImportResult>("/jobimport/preview", { url });
const r = res.data;
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
@@ -291,6 +312,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
status,
location,
salary,
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
salaryCurrency: salaryCurrency.trim() || null,
salaryPeriod: salaryPeriod || null,
nextAction: null,
followUpAt: null,
jobUrl,
@@ -342,18 +367,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
}));
};
const statusLabel = (value: typeof STATUS_OPTIONS[number]) => {
const map = {
Applied: t("statusApplied"),
Waiting: t("statusWaiting"),
Interview: t("statusInterview"),
Offer: t("statusOffer"),
Rejected: t("statusRejected"),
Ghosted: t("statusGhosted"),
} as const;
return map[value];
};
const filesLabel = (files: File[]) => {
if (files.length === 0) return t("addJobModalNoFilesSelected");
if (files.length === 1) return files[0].name;
@@ -471,9 +484,9 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
/>
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => (
{PIPELINE_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{statusLabel(s)}
{pipelineStatusLabel(t, s)}
</MenuItem>
))}
</TextField>
@@ -482,6 +495,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<option value=""></option>
<option value="year">{t("salaryPeriodYear")}</option>
<option value="month">{t("salaryPeriodMonth")}</option>
<option value="hour">{t("salaryPeriodHour")}</option>
</TextField>
<DatePicker
label={t("addJobModalDeadline")}
value={parsePickerDate(deadline)}
@@ -25,6 +25,7 @@ import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
@@ -49,6 +50,7 @@ type OverviewAnalytics = {
medianDaysToFirstResponse?: number | null;
totalResponses: number;
totalActive: number;
timeInStage?: { stage: string; medianDays: number; count: number }[];
};
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
@@ -453,7 +455,7 @@ export default function DashboardView() {
return (
<Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
</Box>
<LinearProgress
@@ -474,6 +476,22 @@ export default function DashboardView() {
})}
</Stack>
{overview?.timeInStage?.length ? (
<Box sx={{ mt: 2.25 }}>
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTimeInStageTitle")}</Typography>
<Stack spacing={0.75}>
{overview.timeInStage.map((item) => (
<Box key={item.stage} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", 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>
))}
</Stack>
</Box>
) : null}
<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={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
@@ -24,6 +24,7 @@ import { useToast } from "../toast";
import { useCompanies } from "../hooks/useCompanies";
import TagsInput from "./TagsInput";
import { useI18n } from "../i18n/I18nProvider";
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
interface Props {
open: boolean;
@@ -32,7 +33,6 @@ interface Props {
onSaved: () => void;
}
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
@@ -80,6 +80,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
const [location, setLocation] = useState("");
const [salary, setSalary] = useState("");
const [salaryMin, setSalaryMin] = useState("");
const [salaryMax, setSalaryMax] = useState("");
const [salaryCurrency, setSalaryCurrency] = useState("");
const [salaryPeriod, setSalaryPeriod] = useState("");
const [nextAction, setNextAction] = useState("");
const [followUpAt, setFollowUpAt] = useState<string>("");
const [jobUrl, setJobUrl] = useState("");
@@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
setDateApplied(toDateInputValue(j.dateApplied));
setLocation(j.location ?? "");
setSalary(j.salary ?? "");
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : "");
setSalaryCurrency(j.salaryCurrency ?? "");
setSalaryPeriod(j.salaryPeriod ?? "");
setNextAction((j as any).nextAction ?? "");
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
setJobUrl(j.jobUrl ?? "");
@@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
responseDate: responseReceived && responseDate ? responseDate : null,
location: location.trim() || null,
salary: salary.trim() || null,
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
salaryCurrency: salaryCurrency.trim() || null,
salaryPeriod: salaryPeriod || null,
nextAction: nextAction.trim() || null,
followUpAt: followUpAt || null,
hasResume,
@@ -195,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
{PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
</TextField>
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
@@ -210,6 +222,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<option value=""></option>
<option value="year">{t("salaryPeriodYear")}</option>
<option value="month">{t("salaryPeriodMonth")}</option>
<option value="hour">{t("salaryPeriodHour")}</option>
</TextField>
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
@@ -10,6 +10,7 @@ import {
DialogTitle,
FormControl,
InputLabel,
LinearProgress,
MenuItem,
Select,
Tab,
@@ -17,9 +18,11 @@ import {
TextField,
Typography,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import { api, getApiErrorMessage } from "../api";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
import { statusLabel } from "../pipeline";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
@@ -130,6 +133,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const { confirmAction } = useDialogActions();
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
const matchScoreCache = useWorkspaceTabCache<MatchScore | null>();
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
@@ -168,6 +172,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [sendingDraft, setSendingDraft] = useState(false);
const [refreshingAi, setRefreshingAi] = useState(false);
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
@@ -200,6 +208,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
if (!open || !jobId) return;
setFollowUpDraft(null);
setCandidateFit(null);
setMatchScore(null);
setStatusSuggestion(null);
setFocusPlan(null);
setInterviewPrep(null);
setReadiness(null);
@@ -280,6 +290,49 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
// Match score is deterministic and cheap: load it on the Candidate Fit tab
// independently of the slow AI narrative so users see the number instantly.
useEffect(() => {
if (!open || !jobId || tab !== 5 || matchScore) return;
const cacheKey = `${jobId}:match-score`;
const cached = matchScoreCache.getCached(cacheKey);
if (cached) {
setMatchScore(cached);
return;
}
setLoadingMatchScore(true);
api.get<MatchScore>(`/jobapplications/${jobId}/match-score`).then((r) => {
matchScoreCache.setCached(cacheKey, r.data);
setMatchScore(r.data);
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
}, [open, jobId, tab, matchScore, matchScoreCache]);
// Suggest a status move from the latest inbound email when the workspace opens.
useEffect(() => {
if (!open || !jobId) return;
let cancelled = false;
api.get<StatusSuggestion>(`/jobapplications/${jobId}/status-suggestion`)
.then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); })
.catch(() => { if (!cancelled) setStatusSuggestion(null); });
return () => { cancelled = true; };
}, [open, jobId]);
const applyStatusSuggestion = async () => {
if (!jobId || !statusSuggestion?.suggestedStatus) return;
setApplyingStatusSuggestion(true);
try {
await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus });
setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev);
setStatusSuggestion(null);
toast(t("statusSuggestionApplied"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error");
} finally {
setApplyingStatusSuggestion(false);
}
};
useEffect(() => {
if (!open || !jobId || tab !== 6 || focusPlan) return;
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
@@ -598,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{attachmentPicker}
{statusSuggestion?.hasSuggestion ? (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
</Button>
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
</Box>
</Box>
) : null}
{tab === 0 && (
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
@@ -1058,6 +1130,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 5 && (
<Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
@@ -1136,6 +1209,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
);
}
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
const { t } = useI18n();
if (loading && !score) {
return (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={18} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
</Box>
);
}
if (!score) return null;
const color: "success" | "warning" | "error" | "inherit" =
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
</Box>
{score.hasEnoughSignal ? (
<LinearProgress
variant="determinate"
value={score.score}
color={color === "inherit" ? "primary" : color}
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
/>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
)}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
</Box>
</Box>
<Box>
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
</Box>
</Box>
</Box>
{score.sectionCoverage.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
</Box>
</Box>
) : null}
</Box>
);
}
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
const { t } = useI18n();
+6 -23
View File
@@ -44,6 +44,8 @@ import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { useCompanies } from "../hooks/useCompanies";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { formatSalary } from "../salary";
import { statusLabel, statusTone } from "../pipeline";
import JobDetailsDialog from "./JobDetailsDialog";
import EditJobDialog from "./EditJobDialog";
import { useToast } from "../toast";
@@ -97,10 +99,6 @@ interface Props {
mode?: "jobs" | "trash";
}
function normalizeStatus(status: string): string {
return status === "Interviewing" ? "Interview" : status;
}
function parseTags(raw?: string | null): string[] {
if (!raw) return [];
try {
@@ -111,21 +109,6 @@ function parseTags(raw?: string | null): string[] {
}
}
function statusTone(status: string): string {
switch (normalizeStatus(status)) {
case "Offer":
return "success";
case "Rejected":
return "error";
case "Waiting":
case "Ghosted":
return "warning";
case "Interview":
return "info";
default:
return "primary";
}
}
function generateOverview(job: JobApplication): string {
if (job.fullSummary) return job.fullSummary;
@@ -546,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Typography>
</Box>
</Box>
{columns.status ? <Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
@@ -584,7 +567,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{job.salary ?? "-"}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
</Box>
</Box>
@@ -694,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
))}
</Box>
</TableCell>
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
@@ -727,7 +710,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{formatSalary(job) ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{detailTags.length ? detailTags.map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{overview || t("jobTableNoSummaryYet")}</Typography></Box>
+12 -31
View File
@@ -19,41 +19,22 @@ import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
type Status = (typeof STATUSES)[number];
const STATUSES = PIPELINE_STATUSES;
type Status = PipelineStatus;
function normalizeStatus(status: string): Status | "Other" {
if (status === "Interviewing") return "Interview";
if ((STATUSES as readonly string[]).includes(status)) return status as Status;
return "Other";
}
const TONE_PALETTE: Record<string, (theme: any) => string> = {
error: (theme) => theme.palette.error.main,
warning: (theme) => theme.palette.warning.main,
success: (theme) => theme.palette.success.main,
info: (theme) => alpha(theme.palette.primary.main, 0.95),
primary: (theme) => theme.palette.primary.main,
default: (theme) => theme.palette.primary.main,
};
function toneColor(theme: any, status: Status | "Other"): string {
if (status === "Rejected") return theme.palette.error.main;
if (status === "Waiting" || status === "Ghosted") return theme.palette.warning.main;
if (status === "Offer") return theme.palette.success.main;
if (status === "Interview") return alpha(theme.palette.primary.main, 0.95);
return theme.palette.primary.main;
}
function statusLabel(t: (key: any, params?: any) => string, status: Status): string {
switch (status) {
case "Applied":
return t("statusApplied");
case "Waiting":
return t("statusWaiting");
case "Interview":
return t("statusInterview");
case "Offer":
return t("statusOffer");
case "Rejected":
return t("statusRejected");
case "Ghosted":
return t("statusGhosted");
default:
return status;
}
return TONE_PALETTE[statusTone(status)](theme);
}
export default function KanbanBoard() {
@@ -0,0 +1,69 @@
import React, { useEffect, useRef } from "react";
import { Box, Paper, TextField, Typography } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
import { useToast } from "../toast";
/** The bookmarklet opens the app at /?add=<current page url>, which triggers quick-capture. */
function buildBookmarklet(origin: string): string {
// Kept as a single minified expression; opens a small popup so the user's tab is undisturbed.
return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`;
}
export default function QuickCaptureCard() {
const { t } = useI18n();
const { toast } = useToast();
const linkRef = useRef<HTMLAnchorElement>(null);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const bookmarklet = buildBookmarklet(origin);
// React refuses to render javascript: hrefs, so set it directly on the DOM node.
useEffect(() => {
if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet);
}, [bookmarklet]);
return (
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
<Box
component="a"
ref={linkRef}
onClick={(e: React.MouseEvent) => {
// Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar.
e.preventDefault();
toast(t("settingsQuickCaptureDragHint"), "info");
}}
sx={{
display: "inline-block",
px: 2,
py: 1,
borderRadius: 2,
border: "1px solid",
borderColor: "primary.main",
color: "primary.main",
fontWeight: 800,
textDecoration: "none",
cursor: "grab",
userSelect: "none",
}}
>
{t("settingsQuickCaptureButton")}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsQuickCaptureDragHint")}</Typography>
</Box>
<TextField
label={t("settingsQuickCaptureManual")}
value={bookmarklet}
fullWidth
size="small"
InputProps={{ readOnly: true }}
onFocus={(e) => e.target.select()}
/>
</Paper>
);
}
@@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard";
import AuthStatusCard from "./AuthStatusCard";
import { ThemeModePref } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
@@ -297,6 +298,8 @@ export default function SettingsView({
<ImportExportJobs />
</Paper>
<QuickCaptureCard />
</Box>
</TabPanel>
@@ -110,6 +110,20 @@ describe('end-to-end trust loop', () => {
if (url === '/jobapplications/42') return Promise.resolve({ data: jobRecord } as any);
if (url === '/auth/me') return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/42/tailored-cv-draft') {
return Promise.resolve({
data: {
templateId: 'ats-minimal',
headline: 'Backend Developer',
summary: ['Tailored for the Acme backend role'],
selectedSkills: [],
experience: [],
education: [],
customSections: [],
status: 'saved',
},
} as any);
}
if (url === '/attachments/42') return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
if (url === '/correspondence/42') return Promise.resolve({ data: correspondenceMessages } as any);
if (url === '/gmail/status') return Promise.resolve({ data: { connected: true, gmailAddress: 'user@example.test', lastSyncedAt: new Date().toISOString() } } as any);
@@ -207,7 +221,7 @@ describe('end-to-end trust loop', () => {
fireEvent.click(screen.getByRole('tab', { name: /tailored cv/i }));
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
expect((await screen.findAllByDisplayValue(/tailored for the acme backend role/i)).length).toBeGreaterThan(0);
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
+2 -8
View File
@@ -65,21 +65,15 @@ export function useViewResource<T>(
const [hasLoaded, setHasLoaded] = useState(false);
const [error, setError] = useState<ViewResourceError | null>(null);
const hasLoadedRef = useRef(hasLoaded);
const loadRef = useRef(load);
useEffect(() => {
hasLoadedRef.current = hasLoaded;
}, [hasLoaded]);
// Hold `load` in a ref so `reload` (and the fetch effect that depends on it)
// keep a stable identity across renders. Callers routinely pass an inline
// `load` closure; if `load` were a dependency, every render would create a new
// `reload`, re-run the effect, setState, and re-render — an infinite loop
// ("Maximum update depth exceeded"). Re-fetching is driven by `deps`/`enabled`
// instead, and the ref always points at the latest closure.
const loadRef = useRef(load);
useEffect(() => {
loadRef.current = load;
});
}, [load]);
const reload = useCallback(async () => {
if (!enabled) return;
+68
View File
@@ -77,6 +77,13 @@ export const translations = {
addJobModalStatus: "Status",
addJobModalJobTitle: "Job title",
addJobModalSalary: "Salary",
salaryMinLabel: "Salary min",
salaryMaxLabel: "Salary max",
salaryCurrencyLabel: "Currency",
salaryPeriodLabel: "Per",
salaryPeriodYear: "Year",
salaryPeriodMonth: "Month",
salaryPeriodHour: "Hour",
addJobModalDeadline: "Deadline",
addJobModalDescriptionOriginal: "Description (original)",
addJobModalTranslatedDescription: "Translated description ({language})",
@@ -150,6 +157,11 @@ export const translations = {
settingsOpenReminderInbox: "Open reminders",
settingsReviewJobs: "Review jobs",
settingsNotificationsTitle: "Notification settings",
settingsQuickCaptureTitle: "Quick capture bookmarklet",
settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.",
settingsQuickCaptureButton: " Save to Jobbjakt",
settingsQuickCaptureDragHint: "Drag me to your bookmarks bar",
settingsQuickCaptureManual: "Or copy the bookmarklet code",
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
@@ -328,6 +340,8 @@ export const translations = {
dashboardApplicationActivity: "Application activity",
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
dashboardConversionFunnelTitle: "Conversion funnel",
dashboardTimeInStageTitle: "Median time in stage",
dashboardTimeInStageValue: "{days}d · {count} active",
dashboardResponseSources: "Response sources",
dashboardTopCompaniesByActivity: "Top companies by activity",
dashboardTopSkills: "Top skills",
@@ -772,6 +786,12 @@ export const translations = {
jobDetailsTabFocusPlan: "Focus plan",
jobDetailsTabInterviewPrep: "Interview prep",
jobDetailsTabHistory: "History",
statusSuggestionTitle: "This email looks like a move to {status}",
statusSuggestionReason: "Matched \"{signal}\" · currently {current}",
statusSuggestionApply: "Move to {status}",
statusSuggestionDismiss: "Dismiss",
statusSuggestionApplied: "Status updated.",
statusSuggestionFailed: "Could not update status.",
jobDetailsTailoredCvMode: "Generation mode",
jobDetailsGenerationDefault: "Balanced",
jobDetailsGenerationConcise: "Concise",
@@ -860,6 +880,20 @@ export const translations = {
jobDetailsFollowUpSent: "Follow-up sent and logged.",
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
jobDetailsHowYouMatch: "How you match",
matchScoreTitle: "Match score",
matchScoreLoading: "Scoring your CV against this role…",
matchScoreBand_Strong: "Strong match",
matchScoreBand_Partial: "Partial match",
matchScoreBand_Low: "Low match",
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.",
matchScoreMatched: "Matched keywords",
matchScoreMissing: "Missing keywords",
matchScoreNoneYet: "No matches found yet.",
matchScoreAllCovered: "Every keyword is covered.",
matchScoreSectionCoverage: "Where your CV covers this role",
jobDetailsStrategySnapshot: "Strategy snapshot",
jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot",
jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.",
@@ -987,6 +1021,13 @@ export const translations = {
addJobModalStatus: "Status",
addJobModalJobTitle: "Stillingstittel",
addJobModalSalary: "Lønn",
salaryMinLabel: "Lønn fra",
salaryMaxLabel: "Lønn til",
salaryCurrencyLabel: "Valuta",
salaryPeriodLabel: "Per",
salaryPeriodYear: "År",
salaryPeriodMonth: "Måned",
salaryPeriodHour: "Time",
addJobModalDeadline: "Frist",
addJobModalDescriptionOriginal: "Beskrivelse (original)",
addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})",
@@ -1060,6 +1101,11 @@ export const translations = {
settingsOpenReminderInbox: "Åpne påminnelser",
settingsReviewJobs: "Gå til jobber",
settingsNotificationsTitle: "Varslingsinnstillinger",
settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)",
settingsQuickCaptureButton: " Lagre til Jobbjakt",
settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.",
settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen",
settingsQuickCaptureManual: "Eller kopier bokmerkekoden",
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
@@ -1238,6 +1284,8 @@ export const translations = {
dashboardApplicationActivity: "Søknadsaktivitet",
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
dashboardConversionFunnelTitle: "Konverteringstrakt",
dashboardTimeInStageTitle: "Median tid i fase",
dashboardTimeInStageValue: "{days}d · {count} aktive",
dashboardResponseSources: "Svar etter kilde",
dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet",
dashboardTopSkills: "Topp ferdigheter",
@@ -1682,6 +1730,12 @@ export const translations = {
jobDetailsTabFocusPlan: "Fokusplan",
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
jobDetailsTabHistory: "Historikk",
statusSuggestionTitle: "Denne e-posten ser ut som en overgang til {status}",
statusSuggestionReason: "Traff \"{signal}\" · nå {current}",
statusSuggestionApply: "Flytt til {status}",
statusSuggestionDismiss: "Avvis",
statusSuggestionApplied: "Status oppdatert.",
statusSuggestionFailed: "Kunne ikke oppdatere status.",
jobDetailsTailoredCvMode: "Genereringsmodus",
jobDetailsGenerationDefault: "Balansert",
jobDetailsGenerationConcise: "Kortfattet",
@@ -1770,6 +1824,20 @@ export const translations = {
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
jobDetailsHowYouMatch: "Slik matcher du",
matchScoreTitle: "Match-score",
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
matchScoreBand_Strong: "Sterk match",
matchScoreBand_Partial: "Delvis match",
matchScoreBand_Low: "Lav match",
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.",
matchScoreMatched: "Treff på nøkkelord",
matchScoreMissing: "Manglende nøkkelord",
matchScoreNoneYet: "Ingen treff ennå.",
matchScoreAllCovered: "Alle nøkkelord er dekket.",
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
jobDetailsStrategySnapshot: "Strategioversikt",
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
@@ -0,0 +1,113 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
const matchScore = {
score: 82,
band: 'Strong',
matchedCount: 4,
totalKeywords: 6,
matchedKeywords: ['C#', '.NET', 'SQL', 'Docker'],
missingKeywords: ['Kubernetes', 'GraphQL'],
sectionCoverage: [
{ section: 'Skills', matched: 4, total: 6 },
{ section: 'Experience', matched: 3, total: 6 },
],
hasEnoughSignal: true,
};
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={5} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/match-score') {
return Promise.resolve({ data: matchScore } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
// Candidate-fit AI narrative: leave pending-ish/empty so we only assert on the fast panel.
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('match score panel shows the score, matched and missing keywords', async () => {
renderDialog();
expect(await screen.findByText('82%')).toBeInTheDocument();
expect(await screen.findByText(/strong match/i)).toBeInTheDocument();
expect(await screen.findByText('4/6 keywords')).toBeInTheDocument();
// Matched keyword chips
expect(await screen.findByText('C#')).toBeInTheDocument();
expect(await screen.findByText('Docker')).toBeInTheDocument();
// Missing keyword chips
expect(await screen.findByText('Kubernetes')).toBeInTheDocument();
expect(await screen.findByText('GraphQL')).toBeInTheDocument();
// Section coverage
expect(await screen.findByText('Skills: 4/6')).toBeInTheDocument();
});
test('match score panel degrades gracefully when there is not enough signal', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/match-score') {
return Promise.resolve({ data: { ...matchScore, score: 0, band: 'Unknown', matchedCount: 0, matchedKeywords: [], missingKeywords: [], sectionCoverage: [], hasEnoughSignal: false } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
return Promise.resolve({ data: {} } as any);
});
renderDialog();
expect(await screen.findByText('—')).toBeInTheDocument();
expect(await screen.findByText(/not enough signal/i)).toBeInTheDocument();
});
+37
View File
@@ -0,0 +1,37 @@
import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline';
describe('pipeline', () => {
test('normalizeStatus canonicalizes casing and synonyms', () => {
expect(normalizeStatus('applied')).toBe('Applied');
expect(normalizeStatus(' OFFER ')).toBe('Offer');
expect(normalizeStatus('Interviewing')).toBe('Interview');
expect(normalizeStatus('declined')).toBe('Rejected');
});
test('normalizeStatus preserves unknown as Other and empty as Applied', () => {
expect(normalizeStatus('Take-home')).toBe('Other');
expect(normalizeStatus('')).toBe('Applied');
expect(normalizeStatus(null)).toBe('Applied');
});
test('statusTone maps stages to palette keys', () => {
expect(statusTone('Offer')).toBe('success');
expect(statusTone('Rejected')).toBe('error');
expect(statusTone('Waiting')).toBe('warning');
expect(statusTone('Ghosted')).toBe('warning');
expect(statusTone('Interview')).toBe('info');
expect(statusTone('Applied')).toBe('primary');
expect(statusTone('Take-home')).toBe('default');
});
test('statusLabel localizes canonical and passes through custom', () => {
const t = (key: string) => ({ statusApplied: 'Applied', statusOffer: 'Offer' } as Record<string, string>)[key] ?? key;
expect(statusLabel(t, 'Applied')).toBe('Applied');
expect(statusLabel(t, 'Interviewing')).toBe('statusInterview'); // maps to canonical key
expect(statusLabel(t, 'Take-home assignment')).toBe('Take-home assignment');
});
test('canonical stage list is stable and ordered', () => {
expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']);
});
});
+64
View File
@@ -0,0 +1,64 @@
// Single frontend source of truth for the canonical job pipeline.
// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync.
export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
export type PipelineStatus = (typeof PIPELINE_STATUSES)[number];
export type StatusTone = "primary" | "info" | "success" | "warning" | "error" | "default";
// Legacy/synonym spellings collapse onto a canonical stage (matches the backend alias map).
const ALIASES: Record<string, PipelineStatus> = {
interviewing: "Interview",
interviews: "Interview",
interviewed: "Interview",
declined: "Rejected",
"no response": "Ghosted",
"no reply": "Ghosted",
pending: "Waiting",
"awaiting response": "Waiting",
};
/** Canonical status for a raw value, or "Other" for unknown/custom statuses. */
export function normalizeStatus(status?: string | null): PipelineStatus | "Other" {
const trimmed = (status ?? "").trim();
if (!trimmed) return "Applied";
const exact = PIPELINE_STATUSES.find((s) => s.toLowerCase() === trimmed.toLowerCase());
if (exact) return exact;
const alias = ALIASES[trimmed.toLowerCase()];
return alias ?? "Other";
}
/** MUI palette key for a status; both chip color and board accent derive from this. */
export function statusTone(status?: string | null): StatusTone {
switch (normalizeStatus(status)) {
case "Offer":
return "success";
case "Rejected":
return "error";
case "Waiting":
case "Ghosted":
return "warning";
case "Interview":
return "info";
case "Applied":
return "primary";
default:
return "default";
}
}
const LABEL_KEYS: Record<PipelineStatus, string> = {
Applied: "statusApplied",
Waiting: "statusWaiting",
Interview: "statusInterview",
Offer: "statusOffer",
Rejected: "statusRejected",
Ghosted: "statusGhosted",
};
/** Localized label for a status, falling back to the raw value for custom statuses. */
export function statusLabel(t: (key: any, params?: any) => string, status: string): string {
const normalized = normalizeStatus(status);
return normalized === "Other" ? status : t(LABEL_KEYS[normalized]);
}
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here.
jest.mock('@mui/x-date-pickers/DatePicker', () => ({
DatePicker: ({ label }: any) => <div>{label}</div>,
}));
// eslint-disable-next-line import/first
import AddJobModal from './components/AddJobModal';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(() => Promise.resolve({ data: [] })),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: jest.fn(() => 'error'),
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderModal(initialUrl?: string) {
return render(
<ToastProvider>
<I18nProvider>
<AddJobModal open initialUrl={initialUrl} onClose={() => {}} onCreated={() => {}} />
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockResolvedValue({ data: [] } as any);
mockedApi.post.mockImplementation((url: string) => {
if (url === '/jobimport/preview') {
return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any);
}
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => jest.clearAllMocks());
test('auto-imports from initialUrl and prefills the form', async () => {
renderModal('https://example.com/jobs/123');
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' });
});
expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument();
});
test('does not auto-import when no initialUrl is given', async () => {
renderModal(undefined);
// Wait for the modal to render, then confirm no import was triggered.
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
});
+21
View File
@@ -0,0 +1,21 @@
import { JobApplication } from "./types";
type SalaryFields = Pick<JobApplication, "salary" | "salaryMin" | "salaryMax" | "salaryCurrency" | "salaryPeriod">;
const PERIOD_SUFFIX: Record<string, string> = { year: "yr", month: "mo", hour: "hr" };
/** Structured salary when present ("60 00070 000 NOK/yr"), otherwise the free-text field. */
export function formatSalary(job: SalaryFields): string | null {
const { salaryMin, salaryMax, salaryCurrency, salaryPeriod } = job;
if (salaryMin == null && salaryMax == null) {
return job.salary?.trim() || null;
}
const fmt = (value: number) => value.toLocaleString();
const range = salaryMin != null && salaryMax != null && salaryMin !== salaryMax
? `${fmt(salaryMin)}${fmt(salaryMax)}`
: fmt((salaryMin ?? salaryMax) as number);
const currency = salaryCurrency ? ` ${salaryCurrency}` : "";
const period = salaryPeriod ? `/${PERIOD_SUFFIX[salaryPeriod] ?? salaryPeriod}` : "";
return `${range}${currency}${period}`;
}
+7
View File
@@ -1,4 +1,11 @@
import React from 'react';
import { configure } from '@testing-library/react';
// Heavy MUI views (job table, workspace dialog, profile page) can exceed the
// 1s default async query timeout on slower machines; findBy*/waitFor assertions
// still resolve as soon as the element appears.
configure({ asyncUtilTimeout: 4000 });
jest.setTimeout(30000);
jest.mock('./api', () => ({
api: {
@@ -0,0 +1,90 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: jest.fn(() => 'error'),
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/status-suggestion') {
return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('status suggestion banner appears and applies via PATCH', async () => {
renderDialog();
expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /move to interview/i }));
await waitFor(() => {
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' });
});
});
test('no banner when there is no suggestion', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/status-suggestion') {
return Promise.resolve({ data: { hasSuggestion: false } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
});
renderDialog();
expect(await screen.findByText(/backend developer/i)).toBeInTheDocument();
expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument();
});
+31
View File
@@ -89,6 +89,10 @@ export interface JobApplication {
dateApplied: string;
location?: string;
salary?: string;
salaryMin?: number | null;
salaryMax?: number | null;
salaryCurrency?: string | null;
salaryPeriod?: string | null;
nextAction?: string;
followUpAt?: string;
feedbackRequestedAt?: string;
@@ -128,6 +132,33 @@ export interface CandidateFitChannelGuidance {
recruiterMessage: string[];
}
export interface MatchScoreSectionCoverage {
section: string;
matched: number;
total: number;
}
export interface StatusSuggestion {
hasSuggestion: boolean;
suggestedStatus?: string | null;
currentStatus?: string | null;
signal?: string | null;
confidence?: string | null;
messageDate?: string | null;
messageSubject?: string | null;
}
export interface MatchScore {
score: number;
band: string;
matchedCount: number;
totalKeywords: number;
matchedKeywords: string[];
missingKeywords: string[];
sectionCoverage: MatchScoreSectionCoverage[];
hasEnoughSignal: boolean;
}
export interface CandidateFit {
matchSummary: string;
fitLevel: string;