From fea00ae6569486e6e007df684c567cfa5075e2cb Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 5 Apr 2026 19:35:14 -0400 Subject: [PATCH] feat: search profiles, cover letters, discovery fixes - Add search profiles (DB, API, settings UI) and wire into scorer/pipeline search terms. - Add cover letter generation (service, job action, JobDetail UI). - Align JobSpy Indeed country with country-level search geography when settings conflict; warn in logs. - Infer country from search cities via inferCountryKeyFromSearchGeography (shared). - Ignore extractor venv/storage and local data in Biome; ignore orchestrator/storage and JobSpy .venv in git. - Vite: do not watch orchestrator/storage (prevents reloads during startup.jobs pipeline). - JobSpy: document Python 3.10+ and venv setup in README/requirements. - Onboarding and settings: local resume path handling, orchestrator .env.example for Vite. Made-with: Cursor --- .env.example | 12 +- biome.json | 6 +- extractors/jobspy/.gitignore | 2 + extractors/jobspy/README.md | 34 ++ extractors/jobspy/requirements.txt | 1 + orchestrator/.env.example | 6 + orchestrator/.gitignore | 3 + orchestrator/src/client/api/client.ts | 45 +++ .../client/components/CoverLetterDisplay.tsx | 72 ++++ .../src/client/components/FitAssessment.tsx | 154 ++++++- .../client/components/OnboardingGate.test.tsx | 21 + .../src/client/components/OnboardingGate.tsx | 12 +- orchestrator/src/client/components/index.ts | 1 + .../src/client/pages/OrchestratorPage.tsx | 3 + .../src/client/pages/SettingsPage.tsx | 46 +++ .../orchestrator/FloatingJobActionsBar.tsx | 16 + .../orchestrator/JobDetailPanel.test.tsx | 1 + .../pages/orchestrator/JobDetailPanel.tsx | 2 + .../client/pages/orchestrator/jobActions.ts | 4 + .../orchestrator/useJobSelectionActions.tsx | 8 + .../components/JobSearchProfileSection.tsx | 382 ++++++++++++++++++ .../settings/components/ProfileManager.tsx | 252 ++++++++++++ .../components/ReactiveResumeSection.tsx | 30 +- orchestrator/src/server/api/routes.ts | 2 + .../src/server/api/routes/jobs.test.ts | 2 + orchestrator/src/server/api/routes/jobs.ts | 134 ++++-- .../src/server/api/routes/manual-jobs.test.ts | 1 + .../src/server/api/routes/manual-jobs.ts | 3 +- .../src/server/api/routes/onboarding.test.ts | 4 +- .../src/server/api/routes/onboarding.ts | 55 +-- .../src/server/api/routes/profiles.ts | 124 ++++++ orchestrator/src/server/db/migrate.ts | 21 +- orchestrator/src/server/db/schema.ts | 12 + .../server/pipeline/sponsor-matching.test.ts | 6 +- .../pipeline/steps/discover-jobs.test.ts | 50 +++ .../server/pipeline/steps/discover-jobs.ts | 62 ++- .../server/pipeline/steps/score-jobs.test.ts | 14 +- .../src/server/pipeline/steps/score-jobs.ts | 7 +- orchestrator/src/server/repositories/jobs.ts | 14 +- .../src/server/repositories/profiles.ts | 95 +++++ .../src/server/services/cover-letter.ts | 131 ++++++ .../src/server/services/profile-generator.ts | 173 ++++++++ .../src/server/services/profile.test.ts | 80 +++- orchestrator/src/server/services/profile.ts | 90 ++++- orchestrator/src/server/services/scorer.ts | 200 +++++++-- orchestrator/src/server/services/settings.ts | 61 +-- orchestrator/vite.config.ts | 5 + shared/src/search-cities.test.ts | 13 + shared/src/search-cities.ts | 24 +- shared/src/settings-registry.ts | 54 +++ shared/src/testing/factories.ts | 34 ++ shared/src/types/jobs.ts | 4 + shared/src/types/pipeline.ts | 8 +- shared/src/types/settings.ts | 46 +++ 54 files changed, 2449 insertions(+), 193 deletions(-) create mode 100644 extractors/jobspy/.gitignore create mode 100644 extractors/jobspy/README.md create mode 100644 orchestrator/.env.example create mode 100644 orchestrator/src/client/components/CoverLetterDisplay.tsx create mode 100644 orchestrator/src/client/pages/settings/components/JobSearchProfileSection.tsx create mode 100644 orchestrator/src/client/pages/settings/components/ProfileManager.tsx create mode 100644 orchestrator/src/server/api/routes/profiles.ts create mode 100644 orchestrator/src/server/repositories/profiles.ts create mode 100644 orchestrator/src/server/services/cover-letter.ts create mode 100644 orchestrator/src/server/services/profile-generator.ts diff --git a/.env.example b/.env.example index ffa5fa3..a1e14a4 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ MODEL=google/gemini-3-flash-preview # Defaults to https://v4.rxresu.me # RXRESUME_URL= +# Optional: load resume JSON from disk instead of the RxResume API (scoring, tailoring, cover letters). +# Path is absolute or relative to the orchestrator process cwd (often `orchestrator/` when using `npm run dev` there). +# Takes precedence over Settings → local path. PDF export still uses RxResume when enabled. +# Example (monorepo): hand-authored v5 JSON may live under `data/resumes/` (that folder is gitignored by default). +# JOBOPS_LOCAL_RESUME_PATH=../data/resumes/ilia-dobkin.json + # RXResume credentials for PDF generation # Create an account at: https://v4.rxresu.me RXRESUME_EMAIL=your_email@example.com @@ -24,8 +30,10 @@ BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= # Optional: client build only — skip RxResume steps in the onboarding wizard (search without PDF export). -# - Local dev: set here or export before `npm run dev` / `npm run build:client` in orchestrator. -# - Docker: set at IMAGE BUILD time (Dockerfile ARG / docker-compose build args), not runtime .env. +# Prefer setting `JOBOPS_LOCAL_RESUME_PATH` above: the API tells the UI to skip RxResume onboarding automatically. +# Otherwise: copy `orchestrator/.env.example` → `orchestrator/.env` and set VITE_SKIP_RXRESUME_ONBOARDING=true +# (Vite only reads `orchestrator/.env`, not this root file.) +# Docker: Vite vars need IMAGE BUILD time (Dockerfile ARG / docker-compose build args), not runtime .env. # VITE_SKIP_RXRESUME_ONBOARDING=true # Public base URL used to generate tracer links when PDFs are created by diff --git a/biome.json b/biome.json index 731b43d..0303422 100644 --- a/biome.json +++ b/biome.json @@ -9,8 +9,12 @@ "includes": [ "**", "!!**/dist", + "!!**/.venv", "!!docs-site/.docusaurus", - "!!docs-site/build" + "!!docs-site/build", + "!!extractors/jobspy/storage", + "!!orchestrator/storage", + "!!data" ] }, "css": { diff --git a/extractors/jobspy/.gitignore b/extractors/jobspy/.gitignore new file mode 100644 index 0000000..90a6b83 --- /dev/null +++ b/extractors/jobspy/.gitignore @@ -0,0 +1,2 @@ +.venv/ +storage/ diff --git a/extractors/jobspy/README.md b/extractors/jobspy/README.md new file mode 100644 index 0000000..dafa5ea --- /dev/null +++ b/extractors/jobspy/README.md @@ -0,0 +1,34 @@ +# JobSpy extractor (Indeed / LinkedIn / Glassdoor) + +## Requirements + +- **Python 3.10 or newer** (`python-jobspy` has no compatible wheels on older versions). +- A virtualenv in this directory (`.venv`) is recommended. + +```bash +# Example: Homebrew (Apple Silicon paths may use /opt/homebrew) +python3.12 --version # or python3.11 / python3.10 — must be ≥ 3.10 + +rm -rf .venv +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +pip install -r requirements.txt +``` + +The orchestrator uses `.venv/bin/python3` when present, otherwise system `python3`. + +## Manual test + +```bash +source .venv/bin/activate +export JOBSPY_SITES=indeed +export JOBSPY_SEARCH_TERM="software engineer" +export JOBSPY_LOCATION="Toronto" +export JOBSPY_COUNTRY_INDEED=canada +export JOBSPY_OUTPUT_JSON=/tmp/jobspy-out.json +export JOBSPY_OUTPUT_CSV=/tmp/jobspy-out.csv +python3 scrape_jobs.py +``` + +Adjust env vars to match what the app passes (see `extractors/jobspy/src/run.ts`). diff --git a/extractors/jobspy/requirements.txt b/extractors/jobspy/requirements.txt index 45fa5b4..89ffd56 100644 --- a/extractors/jobspy/requirements.txt +++ b/extractors/jobspy/requirements.txt @@ -1,2 +1,3 @@ +# python-jobspy requires Python 3.10+ (wheels not published for 3.9 and below). python-jobspy pandas diff --git a/orchestrator/.env.example b/orchestrator/.env.example new file mode 100644 index 0000000..51297b4 --- /dev/null +++ b/orchestrator/.env.example @@ -0,0 +1,6 @@ +# Copy to `.env` in this folder if you want Vite-only overrides. +# The dev server still loads the monorepo root `.env` for API settings. +# +# Optional: skip Reactive Resume steps in the onboarding wizard (client build / `npm run dev`). +# Not required if `JOBOPS_LOCAL_RESUME_PATH` is set in the root `.env` — the API exposes that to the UI. +# VITE_SKIP_RXRESUME_ONBOARDING=true diff --git a/orchestrator/.gitignore b/orchestrator/.gitignore index bfdfb16..5cf7282 100644 --- a/orchestrator/.gitignore +++ b/orchestrator/.gitignore @@ -7,6 +7,9 @@ dist/ # Data (local database and generated files) data/ +# Extractor / Apify-style KV written under cwd during pipeline runs +storage/ + # Environment .env .env.local diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 37ab50c..bf326e7 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -20,6 +20,7 @@ import type { JobChatThread, JobListItem, JobOutcome, + JobSearchProfile, JobSource, JobsListResponse, JobsRevisionResponse, @@ -39,6 +40,7 @@ import type { ResumeProfile, ResumeProjectCatalogItem, RxResumeMode, + SearchProfile, StageEvent, StageEventMetadata, StageTransitionTarget, @@ -1509,3 +1511,46 @@ export async function deleteBackup(filename: string): Promise { method: "DELETE", }); } + +// Profiles API +export async function listProfiles(): Promise { + return fetchApi("/profiles"); +} + +export async function createProfile(input: { + name: string; + data: JobSearchProfile; +}): Promise { + return fetchApi("/profiles", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function updateProfile( + id: string, + input: { name?: string; data?: JobSearchProfile }, +): Promise { + return fetchApi(`/profiles/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export async function deleteProfile(id: string): Promise { + await fetchApi(`/profiles/${id}`, { + method: "DELETE", + }); +} + +export async function activateProfile(id: string): Promise { + await fetchApi(`/profiles/${id}/activate`, { + method: "POST", + }); +} + +export async function generateProfileFromResume(): Promise { + return fetchApi("/profiles/generate-from-resume", { + method: "POST", + }); +} diff --git a/orchestrator/src/client/components/CoverLetterDisplay.tsx b/orchestrator/src/client/components/CoverLetterDisplay.tsx new file mode 100644 index 0000000..72fc9e6 --- /dev/null +++ b/orchestrator/src/client/components/CoverLetterDisplay.tsx @@ -0,0 +1,72 @@ +import type { Job } from "@shared/types.js"; +import { Check, Copy, FileText } from "lucide-react"; +import type React from "react"; +import { useCallback, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface CoverLetterDisplayProps { + job: Job; + className?: string; +} + +export const CoverLetterDisplay: React.FC = ({ + job, + className, +}) => { + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(false); + + const handleCopy = useCallback(async () => { + if (!job.coverLetter) return; + await navigator.clipboard.writeText(job.coverLetter); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [job.coverLetter]); + + if (!job.coverLetter) return null; + + const lines = job.coverLetter.split("\n"); + const isLong = lines.length > 8; + const displayText = expanded ? job.coverLetter : lines.slice(0, 6).join("\n"); + + return ( +
+
+
+
+ + Cover Letter +
+ +
+

+ {displayText} + {isLong && !expanded && "..."} +

+ {isLong && ( + + )} +
+
+ ); +}; diff --git a/orchestrator/src/client/components/FitAssessment.tsx b/orchestrator/src/client/components/FitAssessment.tsx index 831bf30..1d9a697 100644 --- a/orchestrator/src/client/components/FitAssessment.tsx +++ b/orchestrator/src/client/components/FitAssessment.tsx @@ -1,6 +1,13 @@ -import type { Job } from "@shared/types.js"; -import { Sparkles } from "lucide-react"; +import type { Job, SuitabilityAnalysis } from "@shared/types.js"; +import { + AlertTriangle, + CheckCircle2, + Lightbulb, + Sparkles, + XCircle, +} from "lucide-react"; import type React from "react"; +import { useMemo } from "react"; import { cn } from "@/lib/utils"; interface FitAssessmentProps { @@ -8,23 +15,156 @@ interface FitAssessmentProps { className?: string; } +function parseAnalysis(raw: string | null): SuitabilityAnalysis | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && Array.isArray(parsed.strengths)) + return parsed as SuitabilityAnalysis; + return null; + } catch { + return null; + } +} + +function RoleMatchBadge({ score }: { score: number }) { + const color = + score >= 70 + ? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20" + : score >= 40 + ? "text-amber-500 bg-amber-500/10 border-amber-500/20" + : "text-red-500 bg-red-500/10 border-red-500/20"; + + const label = + score >= 70 + ? "Strong role match" + : score >= 40 + ? "Partial role match" + : "Weak role match"; + + return ( + + {label} ({score}%) + + ); +} + export const FitAssessment: React.FC = ({ job, className, }) => { - if (!job.suitabilityReason) return null; + const analysis = useMemo( + () => parseAnalysis(job.suitabilityAnalysis ?? null), + [job.suitabilityAnalysis], + ); + + if (!job.suitabilityReason && !analysis) return null; return ( -
+
+ {/* Summary / Reason */}
Fit Assessment + {analysis && }
-

- {job.suitabilityReason} -

+ {job.suitabilityReason && ( +

+ {job.suitabilityReason} +

+ )}
+ + {analysis && ( +
+ {/* Deal-Breaker Hits */} + {analysis.dealBreakerHits.length > 0 && ( +
+
+ + Deal-breakers triggered +
+
    + {analysis.dealBreakerHits.map((hit) => ( +
  • + + {hit} +
  • + ))} +
+
+ )} + + {/* Strengths */} + {analysis.strengths.length > 0 && ( +
+
+ + Strengths +
+
    + {analysis.strengths.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ )} + + {/* Gaps */} + {analysis.gaps.length > 0 && ( +
+
+ + Gaps +
+
    + {analysis.gaps.map((g) => ( +
  • + {g} +
  • + ))} +
+
+ )} + + {/* Suggestions */} + {analysis.suggestions.length > 0 && ( +
+
+ + Suggestions to improve fit +
+
    + {analysis.suggestions.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ )} +
+ )}
); }; diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx index c5285f5..612bc42 100644 --- a/orchestrator/src/client/components/OnboardingGate.test.tsx +++ b/orchestrator/src/client/components/OnboardingGate.test.tsx @@ -110,6 +110,8 @@ const settingsResponse = { rxresumeApiKeyHint: null, rxresumePasswordHint: null, rxresumeBaseResumeId: null, + localResumeProfilePath: null, + localResumeFileConfigured: false, }, isLoading: false, refreshSettings: vi.fn(), @@ -178,6 +180,25 @@ describe("OnboardingGate", () => { expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument(); }); + it("hides the gate for Ollama when local resume file is configured on the server", async () => { + vi.mocked(useSettings).mockReturnValue({ + ...settingsResponse, + settings: { + ...settingsResponse.settings, + llmProvider: { value: "ollama", default: "ollama", override: null }, + localResumeFileConfigured: true, + }, + } as any); + + render(); + + await waitFor(() => { + expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument(); + }); + expect(api.validateRxresume).not.toHaveBeenCalled(); + expect(api.validateResumeConfig).not.toHaveBeenCalled(); + }); + it("skips LLM key validation for providers without API keys", async () => { vi.mocked(useSettings).mockReturnValue({ ...settingsResponse, diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 9e3bb29..52a8f15 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -94,14 +94,20 @@ function getStepPrimaryLabel(input: { } export const OnboardingGate: React.FC = () => { - /** Opt-in: set `VITE_SKIP_RXRESUME_ONBOARDING=true` at build/dev time to skip RxResume steps in onboarding. */ - const skipRxResumeOnboarding = - import.meta.env.VITE_SKIP_RXRESUME_ONBOARDING === "true"; const { settings, isLoading: settingsLoading, refreshSettings, } = useSettings(); + + /** Skip RxResume onboarding when Vite flag is set, server reports a local resume file, or Settings has a local path. */ + const skipRxResumeOnboarding = useMemo( + () => + import.meta.env.VITE_SKIP_RXRESUME_ONBOARDING === "true" || + Boolean(settings?.localResumeFileConfigured) || + Boolean(settings?.localResumeProfilePath?.trim()), + [settings?.localResumeFileConfigured, settings?.localResumeProfilePath], + ); const { storedRxResume, getBaseResumeIdForMode, diff --git a/orchestrator/src/client/components/index.ts b/orchestrator/src/client/components/index.ts index d80a17d..391843e 100644 --- a/orchestrator/src/client/components/index.ts +++ b/orchestrator/src/client/components/index.ts @@ -1,3 +1,4 @@ +export { CoverLetterDisplay } from "./CoverLetterDisplay"; export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel"; export { FitAssessment } from "./FitAssessment"; export { JobHeader } from "./JobHeader"; diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index d3d9622..50e5595 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -186,6 +186,7 @@ export const OrchestratorPage: React.FC = () => { canSkipSelected, canMoveSelected, canRescoreSelected, + canGenerateCoverLetter, jobActionInFlight, toggleSelectJob, toggleSelectAll, @@ -437,10 +438,12 @@ export const OrchestratorPage: React.FC = () => { canMoveSelected={canMoveSelected} canSkipSelected={canSkipSelected} canRescoreSelected={canRescoreSelected} + canGenerateCoverLetter={canGenerateCoverLetter} jobActionInFlight={jobActionInFlight !== null} onMoveToReady={() => void runJobAction("move_to_ready")} onSkipSelected={() => void runJobAction("skip")} onRescoreSelected={() => void runJobAction("rescore")} + onGenerateCoverLetter={() => void runJobAction("generate_cover_letter")} onClear={clearSelection} /> diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index f0d9023..87ca846 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -19,6 +19,7 @@ import { ChatSettingsSection } from "@client/pages/settings/components/ChatSetti import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"; import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"; import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection"; +import { JobSearchProfileSection } from "@client/pages/settings/components/JobSearchProfileSection"; import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"; import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection"; import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection"; @@ -60,6 +61,7 @@ import { Accordion } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; const DEFAULT_FORM_VALUES: UpdateSettingsInput = { + jobSearchProfile: null, model: "", modelScorer: "", modelTailoring: "", @@ -82,6 +84,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { chatStyleManualLanguage: null, rxresumeEmail: "", rxresumeUrl: "", + localResumeProfilePath: "", rxresumePassword: "", rxresumeApiKey: "", basicAuthUser: "", @@ -137,6 +140,7 @@ const normalizeLlmProviderValue = ( ): LlmProviderValue => (value ? normalizeLlmProvider(value) : null); const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { + jobSearchProfile: null, model: null, modelScorer: null, modelTailoring: null, @@ -159,6 +163,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { chatStyleManualLanguage: null, rxresumeEmail: null, rxresumeUrl: null, + localResumeProfilePath: null, rxresumePassword: null, rxresumeApiKey: null, basicAuthUser: null, @@ -181,6 +186,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { }; const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ + jobSearchProfile: data.jobSearchProfile?.override ?? null, model: data.model.override ?? "", modelScorer: data.modelScorer.override ?? "", modelTailoring: data.modelTailoring.override ?? "", @@ -204,6 +210,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null, rxresumeEmail: data.rxresumeEmail ?? "", rxresumeUrl: data.rxresumeUrl ?? "", + localResumeProfilePath: data.localResumeProfilePath ?? "", rxresumePassword: "", rxresumeApiKey: "", basicAuthUser: data.basicAuthUser ?? "", @@ -370,6 +377,10 @@ const getDerivedSettings = (settings: AppSettings | null) => { default: settings?.backupMaxCount?.default ?? 5, }, }, + jobSearchProfile: { + effective: settings?.jobSearchProfile?.value ?? null, + default: settings?.jobSearchProfile?.default ?? null, + }, scoring: { penalizeMissingSalary: { effective: settings?.penalizeMissingSalary?.value ?? false, @@ -572,6 +583,7 @@ export const SettingsPage: React.FC = () => { profileProjects, backup, scoring, + jobSearchProfile, } = derived; const handleCreateBackup = async () => { @@ -769,6 +781,12 @@ export const SettingsPage: React.FC = () => { envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl); } + if (dirtyFields.localResumeProfilePath) { + envPayload.localResumeProfilePath = normalizeString( + data.localResumeProfilePath, + ); + } + if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) { envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail); } @@ -833,7 +851,14 @@ export const SettingsPage: React.FC = () => { if (value !== undefined) envPayload.webhookSecret = value; } + const jobSearchProfilePayload = dirtyFields.jobSearchProfile + ? data.jobSearchProfile + : undefined; + const payload: Partial = { + ...(jobSearchProfilePayload !== undefined + ? { jobSearchProfile: jobSearchProfilePayload } + : {}), model: dirtyFields.llmProvider ? dirtyFields.model ? normalizeString(data.model) @@ -986,6 +1011,21 @@ export const SettingsPage: React.FC = () => { } const updated = await updateSettingsMutation.mutateAsync(payload); + + if ( + dirtyFields.jobSearchProfile && + data.jobSearchProfile && + updated.activeProfileId + ) { + try { + await api.updateProfile(updated.activeProfileId, { + data: data.jobSearchProfile, + }); + } catch { + // Profile sync is best-effort; settings are already saved + } + } + setSettings(updated); reset(mapSettingsToForm(updated)); toast.success("Settings saved"); @@ -1167,6 +1207,12 @@ export const SettingsPage: React.FC = () => { isLoading={isLoading} isSaving={isSaving} /> + void; onSkipSelected: () => void; onRescoreSelected: () => void; + onGenerateCoverLetter: () => void; onClear: () => void; } @@ -19,10 +21,12 @@ export const FloatingJobActionsBar: React.FC = ({ canMoveSelected, canSkipSelected, canRescoreSelected, + canGenerateCoverLetter, jobActionInFlight, onMoveToReady, onSkipSelected, onRescoreSelected, + onGenerateCoverLetter, onClear, }) => { return ( @@ -76,6 +80,18 @@ export const FloatingJobActionsBar: React.FC = ({ Recalculate match )} + {canGenerateCoverLetter && ( + + )}