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
+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>