From 60b61ffe030f5cb8b6dcfe6ae0c1ac2abde608e4 Mon Sep 17 00:00:00 2001 From: ilia Date: Mon, 6 Apr 2026 15:55:26 -0400 Subject: [PATCH] feat: job notes, deal-breaker score cap, richer cover letters, bulk action limit bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add per-listing notes (JobNotes component, notes column, auto-save) - Enforce hard score cap (≤15) when deal-breaker hits are present; add clearance/citizenship deal-breaker rules to scoring prompt - Cover letter prompt now uses full search profile (experience level, skills, work arrangement, location, salary, industries, deal-breakers) and produces longer, name-signed output - Include candidate name + location in sanitized scorer profile - Raise MAX_JOB_ACTION_BATCH_SIZE from 100 → 2500 (shared constant) - Update README with new features Made-with: Cursor --- README.md | 5 +- .../src/client/components/JobNotes.tsx | 114 ++++++++++++++++++ .../src/client/components/ReadyPanel.tsx | 3 +- .../discovered-panel/DecideMode.tsx | 4 + .../discovered-panel/DiscoveredPanel.tsx | 1 + orchestrator/src/client/components/index.ts | 1 + .../orchestrator/JobDetailPanel.test.tsx | 1 + .../pages/orchestrator/JobDetailPanel.tsx | 2 + .../useJobSelectionActions.test.ts | 15 ++- .../orchestrator/useJobSelectionActions.tsx | 11 +- .../src/server/api/routes/jobs.test.ts | 2 +- orchestrator/src/server/api/routes/jobs.ts | 10 +- orchestrator/src/server/db/index.ts | 2 + orchestrator/src/server/db/migrate.ts | 8 +- orchestrator/src/server/db/schema.ts | 1 + orchestrator/src/server/repositories/jobs.ts | 1 + .../src/server/services/cover-letter.ts | 91 +++++++++++--- orchestrator/src/server/services/scorer.ts | 112 ++++++++++++++--- shared/src/testing/factories.ts | 1 + shared/src/types/jobs.ts | 2 + shared/src/types/pipeline.ts | 3 + 21 files changed, 333 insertions(+), 57 deletions(-) create mode 100644 orchestrator/src/client/components/JobNotes.tsx diff --git a/README.md b/README.md index 6da438e..95e95e6 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,14 @@ Root `package.json` is an npm **workspace** root; day-to-day app commands usuall ## Features (high level) - **Sources**: Multiple boards and aggregators (exact list evolves; see docs and extractor packages). -- **Scoring & tailoring**: LLM compares jobs to your resume profile; optional drafts for summary, headline, skills, and project selection. +- **Scoring & tailoring**: LLM compares jobs to your resume profile; optional drafts for summary, headline, skills, and project selection. The scorer enforces hard caps when deal-breakers fire (e.g. clearance/citizenship conflicts cap score to 0-15) and includes candidate name and location in the profile sent to the model. +- **Cover letters**: LLM-generated, profile-aware cover letters. The prompt uses the full job-search profile (experience level, target roles, must-have and nice-to-have skills, work arrangement, location, salary, industries, deal-breakers) and produces 4-5 paragraphs (~550 words) signed with the candidate's name. +- **Job notes**: Per-listing personal notes with debounced auto-save, available in Discovered, Ready, and detail panels. - **PDFs**: Tailored exports via **Reactive Resume** (v4 or v5 API). Optional **local JSON resume** (`JOBOPS_LOCAL_RESUME_PATH` or Settings) as the base document for profile/tailoring; PDF export still uses RxResume when configured. - **Pipeline**: Scheduled or manual runs (`POST /api/pipeline/run`, webhook trigger). - **Post-application**: Optional Gmail-based inbox for interview/offer/rejection signals. - **Job list filters** (orchestrator UI): Narrow the pipeline job list by **multiple sources** and **countries** (country is inferred from each listing’s location text). Filters sync to the URL (`source`, `sourceExclude`, `countries`, `countriesExclude`). Each source/country chip cycles **off → include → exclude** (exclude shows in red); listings marked **remote** still pass country include/exclude rules. +- **Bulk actions**: Select-all / batch actions support up to **2 500 jobs** per request (raised from 100). - **Data**: SQLite and generated artifacts under `./data` (default in Docker). ## Requirements diff --git a/orchestrator/src/client/components/JobNotes.tsx b/orchestrator/src/client/components/JobNotes.tsx new file mode 100644 index 0000000..114c38d --- /dev/null +++ b/orchestrator/src/client/components/JobNotes.tsx @@ -0,0 +1,114 @@ +import * as api from "@client/api"; +import type { Job } from "@shared/types.js"; +import { StickyNote } from "lucide-react"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +interface JobNotesProps { + job: Job; + onJobUpdated: () => void | Promise; + className?: string; +} + +const DEBOUNCE_MS = 800; + +export const JobNotes: React.FC = ({ + job, + onJobUpdated, + className, +}) => { + const [value, setValue] = useState(job.notes ?? ""); + const [isSaving, setIsSaving] = useState(false); + const pendingRef = useRef(null); + const timerRef = useRef | null>(null); + const jobIdRef = useRef(job.id); + + useEffect(() => { + if (jobIdRef.current !== job.id) { + jobIdRef.current = job.id; + if (timerRef.current) clearTimeout(timerRef.current); + pendingRef.current = null; + } + setValue(job.notes ?? ""); + }, [job.id, job.notes]); + + const persist = useCallback( + async (text: string) => { + try { + setIsSaving(true); + await api.updateJob(job.id, { notes: text || null }); + await onJobUpdated(); + } catch (error) { + const msg = + error instanceof Error ? error.message : "Failed to save notes"; + toast.error(msg); + } finally { + setIsSaving(false); + if (pendingRef.current !== null) { + const next = pendingRef.current; + pendingRef.current = null; + void persist(next); + } + } + }, + [job.id, onJobUpdated], + ); + + const scheduleFlush = useCallback( + (text: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + if (isSaving) { + pendingRef.current = text; + } else { + void persist(text); + } + }, DEBOUNCE_MS); + }, + [isSaving, persist], + ); + + const handleChange = (e: React.ChangeEvent) => { + const next = e.target.value; + setValue(next); + scheduleFlush(next); + }; + + const handleBlur = () => { + if (timerRef.current) clearTimeout(timerRef.current); + const trimmed = value; + if (trimmed !== (job.notes ?? "")) { + if (isSaving) { + pendingRef.current = trimmed; + } else { + void persist(trimmed); + } + } + }; + + return ( +
+
+ + + Notes + + {isSaving && ( + + Saving… + + )} +
+