Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ export default function App() {
setOptimizerSectionType(sectionType || 'work_history');
if (targetResume) {
setSelectedResumeForJob(targetResume);
} else if (!selectedResumeForJob && resumes.length > 0) {
setSelectedResumeForJob(resumes[0]);
}
setIsOptimizerOpen(true);
};
Expand Down Expand Up @@ -464,7 +466,7 @@ export default function App() {
setIsAuthOpen(true);
return;
}
handleOpenOptimizer(kw);
handleOpenOptimizer(kw, selectedResumeForJob || resumes[0] || null);
}}
onOpenCoverLetter={() => {
if (!currentUser) {
Expand Down Expand Up @@ -596,17 +598,19 @@ export default function App() {
}}
/>

<BulletOptimizerModal
key={`optimizer-${currentUser?.id || 'anon'}`}
isOpen={isOptimizerOpen}
onClose={() => setIsOptimizerOpen(false)}
initialKeywords={optimizerKeywords}
initialSectionType={optimizerSectionType}
targetJobTitle={currentJob?.title || 'Software Engineer'}
selectedResume={selectedResumeForJob}
onMarkSkillsAdded={(skills) => handleAdoptSkills(skills, selectedResumeForJob?.id)}
onAiError={handleAiError}
/>
{isOptimizerOpen && (
<BulletOptimizerModal
key={`optimizer-${currentUser?.id || 'anon'}-${optimizerKeywords.join('_')}-${optimizerSectionType}`}
isOpen={isOptimizerOpen}
onClose={() => setIsOptimizerOpen(false)}
initialKeywords={optimizerKeywords}
initialSectionType={optimizerSectionType}
targetJobTitle={currentJob?.title || 'Software Engineer'}
selectedResume={selectedResumeForJob}
onMarkSkillsAdded={(skills) => handleAdoptSkills(skills, selectedResumeForJob?.id)}
onAiError={handleAiError}
/>
)}

<CoverLetterModal
key={`cover-${currentUser?.id || 'anon'}`}
Expand Down
61 changes: 35 additions & 26 deletions frontend/src/components/BulletOptimizerModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ export default function BulletOptimizerModal({
onMarkSkillsAdded = null,
onAiError = null,
}) {
const [keywords, setKeywords] = useState([]);
const getInitialKeywords = () => {
if (initialKeywords && initialKeywords.length > 0) {
return [...initialKeywords];
}
if (initialKeyword) {
return [initialKeyword];
}
return [];
};

const [keywords, setKeywords] = useState(getInitialKeywords);
const [newSkillInput, setNewSkillInput] = useState('');
const [sectionType, setSectionType] = useState('work_history'); // 'work_history' or 'project'
const [sectionType, setSectionType] = useState(initialSectionType || 'work_history');
const [existingBullet, setExistingBullet] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
Expand All @@ -37,23 +47,12 @@ export default function BulletOptimizerModal({
const [addedConfirmed, setAddedConfirmed] = useState(false);

useEffect(() => {
let kwList = [];
if (initialKeywords && initialKeywords.length > 0) {
kwList = [...initialKeywords];
} else if (initialKeyword) {
kwList = [initialKeyword];
const initKws = getInitialKeywords();
const initSec = initialSectionType || 'work_history';
if (initKws.length > 0) {
handleGenerate(null, null, initKws, initSec);
}
setKeywords(kwList);
setSectionType(initialSectionType || 'work_history');
setResult(null);
setExistingBullet('');
}, [initialKeyword, initialKeywords, initialSectionType, isOpen]);

useEffect(() => {
if (isOpen && keywords.length > 0 && !result && !loading) {
handleGenerate();
}
}, [isOpen, keywords]);
}, []);

if (!isOpen) return null;

Expand All @@ -70,9 +69,17 @@ export default function BulletOptimizerModal({
}
};

const handleGenerate = async (e, overrideBullet = null) => {
const handleGenerate = async (
e = null,
overrideBullet = null,
targetKeywords = null,
targetSectionType = null
) => {
e?.preventDefault();
if (keywords.length === 0) {
const effectiveKeywords = targetKeywords !== null ? targetKeywords : keywords;
const effectiveSectionType = targetSectionType !== null ? targetSectionType : sectionType;

if (!effectiveKeywords || effectiveKeywords.length === 0) {
setError('Please add at least one target skill to incorporate.');
return;
}
Expand All @@ -89,9 +96,9 @@ export default function BulletOptimizerModal({

const res = await optimizeBullet({
target_job_title: targetJobTitle || 'Software Engineer',
section_type: sectionType,
target_keyword: keywords.join(', '),
target_keywords: keywords,
section_type: effectiveSectionType,
target_keyword: effectiveKeywords.join(', '),
target_keywords: effectiveKeywords,
existing_bullet: bulletToUse.trim(),
evidence_context: evidenceContext,
});
Expand All @@ -103,7 +110,7 @@ export default function BulletOptimizerModal({
} catch (err) {
setError(err.message || 'Failed to generate optimized bullet points.');
if (onAiError && (err.canSwitchOffline || err.status === 502)) {
onAiError(err, () => handleGenerate(null, bulletToUse));
onAiError(err, () => handleGenerate(null, bulletToUse, effectiveKeywords, effectiveSectionType));
}
} finally {
setLoading(false);
Expand All @@ -113,7 +120,7 @@ export default function BulletOptimizerModal({
const handleSelectDifferentBullet = (bulletObj) => {
setExistingBullet(bulletObj.bullet);
setShowBulletPicker(false);
handleGenerate(null, bulletObj.bullet);
handleGenerate(null, bulletObj.bullet, keywords, sectionType);
};

const handleCopy = (text, idx) => {
Expand All @@ -140,7 +147,7 @@ export default function BulletOptimizerModal({
</div>
<div>
<h3 className="text-base font-bold text-[#000000] tracking-tight">
BulletSkill 2.0 Resume Bullet Optimizer
BulletCraft Resume Optimizer
</h3>
<p className="text-xs text-[#666666]">
Incorporate target skills into <strong>{sectionType === 'project' ? 'Project' : 'Work History'}</strong> bullet points
Expand Down Expand Up @@ -217,6 +224,7 @@ export default function BulletOptimizerModal({
onClick={() => {
setSectionType('project');
setResult(null);
handleGenerate(null, null, keywords, 'project');
}}
className={`flex items-center gap-1.5 py-1 px-3.5 rounded-full font-semibold transition-all ${
sectionType === 'project'
Expand All @@ -232,6 +240,7 @@ export default function BulletOptimizerModal({
onClick={() => {
setSectionType('work_history');
setResult(null);
handleGenerate(null, null, keywords, 'work_history');
}}
className={`flex items-center gap-1.5 py-1 px-3.5 rounded-full font-semibold transition-all ${
sectionType === 'work_history'
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/JobAnalyzer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,18 @@ export default function JobAnalyzer({
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-2 pt-2 lg:pt-0">
<button
onClick={() => onOpenBulletOptimizer && onOpenBulletOptimizer(currentJob.required_skills?.[0] || 'Target Skill')}
onClick={() => {
if (!onOpenBulletOptimizer) return;
const skills = currentJob.required_skills?.length > 0
? currentJob.required_skills
: (currentJob.ats_keywords?.length > 0 ? currentJob.ats_keywords.slice(0, 3) : ['Key Qualification']);
onOpenBulletOptimizer(skills);
}}
className="btn-secondary-corporate text-xs"
title="Craft high-impact resume bullets with BulletCraft"
>
<Wand2 size={13} />
<span>Bullet Optimizer</span>
<span>BulletCraft</span>
</button>

<button
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/SettingsModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ export default function SettingsModal({ isOpen, onClose, currentUser = null, isO
)}
</div>
<p className="text-[11px] text-[#666666]">
Zero API keys required, zero network dependencies. Runs locally on your machine with high accuracy rule-based ATS matching and BulletSkill templates.
Zero API keys required, zero network dependencies. Runs locally on your machine with high accuracy rule-based ATS matching and BulletCraft templates.
</p>
</div>

Expand Down
186 changes: 186 additions & 0 deletions frontend/src/test/BulletOptimizerFlow.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import BulletOptimizerModal from '../components/BulletOptimizerModal'
import * as apiClient from '../api/client'

vi.mock('../api/client', () => ({
optimizeBullet: vi.fn(),
}))

describe('BulletCraft Resume Optimizer Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})

const mockResponse = {
status: 'rewritten',
target_keyword: 'Docker',
target_keywords: ['Docker'],
claim_status: 'VERIFIED',
selected_bullet_index: 0,
target_project_name: 'Cloud Infrastructure',
original_bullet_to_replace: 'Deployed microservices to servers',
replacement_rationale: 'Incorporates Docker to highlight containerization skills.',
alternatives: [
{
variant_name: 'Candidate A (ATS-focused)',
bullet: 'Containerized microservices with Docker to streamline deployments and enhance system scalability.',
what: 'Docker',
how: 'Containerized microservices',
result_or_reason: 'Streamline deployments and enhance scalability',
claim_status: 'VERIFIED',
requires_confirmation: false,
},
],
validation: {
past_tense: true,
one_sentence: true,
one_period_max: true,
what_how_result_present: true,
keyword_stuffing: false,
},
}

it('renders BulletCraft branding in the modal header', async () => {
apiClient.optimizeBullet.mockResolvedValue(mockResponse)

render(
<BulletOptimizerModal
isOpen={true}
onClose={vi.fn()}
initialKeywords={['Docker']}
initialSectionType="work_history"
/>
)

expect(screen.getByText('BulletCraft Resume Optimizer')).toBeInTheDocument()
})

it('generates recommendations immediately for the selected skill on mount', async () => {
apiClient.optimizeBullet.mockResolvedValue(mockResponse)

render(
<BulletOptimizerModal
isOpen={true}
onClose={vi.fn()}
initialKeywords={['Docker']}
initialSectionType="work_history"
targetJobTitle="DevOps Engineer"
/>
)

await waitFor(() => {
expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(1)
})

expect(apiClient.optimizeBullet).toHaveBeenCalledWith(
expect.objectContaining({
target_keyword: 'Docker',
target_keywords: ['Docker'],
section_type: 'work_history',
target_job_title: 'DevOps Engineer',
})
)
})

it('passes selected resume content as evidence context to the optimizer', async () => {
apiClient.optimizeBullet.mockResolvedValue(mockResponse)

const resume = {
id: 'res-1',
name: 'Senior Resume',
content: 'Experienced engineer with Docker and Kubernetes background.',
}

render(
<BulletOptimizerModal
isOpen={true}
onClose={vi.fn()}
initialKeywords={['Docker']}
selectedResume={resume}
/>
)

await waitFor(() => {
expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(1)
})

expect(apiClient.optimizeBullet).toHaveBeenCalledWith(
expect.objectContaining({
evidence_context: [resume.content],
})
)
})

it('re-generates with project section type when user toggles to Project Bullet', async () => {
apiClient.optimizeBullet.mockResolvedValue(mockResponse)

render(
<BulletOptimizerModal
isOpen={true}
onClose={vi.fn()}
initialKeywords={['PostgreSQL']}
initialSectionType="work_history"
/>
)

await waitFor(() => {
expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(1)
})

const projectTabBtn = screen.getByRole('button', { name: /Project Bullet/i })
fireEvent.click(projectTabBtn)

await waitFor(() => {
expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(2)
})

expect(apiClient.optimizeBullet).toHaveBeenLastCalledWith(
expect.objectContaining({
target_keywords: ['PostgreSQL'],
section_type: 'project',
})
)
})

it('properly targets sequential skills when opening skill A then skill B', async () => {
apiClient.optimizeBullet.mockResolvedValue(mockResponse)

// First open for Python
const { unmount } = render(
<BulletOptimizerModal
key="opt-Python-work_history"
isOpen={true}
onClose={vi.fn()}
initialKeywords={['Python']}
/>
)

await waitFor(() => {
expect(apiClient.optimizeBullet).toHaveBeenCalledWith(
expect.objectContaining({ target_keywords: ['Python'] })
)
})

// Simulate modal close and unmount
unmount()

// Second open for Kubernetes
render(
<BulletOptimizerModal
key="opt-Kubernetes-work_history"
isOpen={true}
onClose={vi.fn()}
initialKeywords={['Kubernetes']}
/>
)

await waitFor(() => {
expect(apiClient.optimizeBullet).toHaveBeenLastCalledWith(
expect.objectContaining({ target_keywords: ['Kubernetes'] })
)
})

expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(2)
})
})
Loading