@jsonresume/jobs 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -4
- package/package.json +1 -1
- package/src/api.js +8 -0
- package/src/cache.js +2 -1
- package/src/localApi.js +5 -0
- package/src/tui/AIPanel.js +118 -15
- package/src/tui/App.js +164 -12
- package/src/tui/HelpModal.js +1 -0
- package/src/tui/JobDetail.js +2 -0
- package/src/tui/JobList.js +52 -5
- package/src/tui/StatusBar.js +9 -1
- package/src/tui/useAI.js +420 -2
- package/src/tui/useJobs.js +20 -3
package/src/tui/useAI.js
CHANGED
|
@@ -1,12 +1,64 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react';
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
2
3
|
import { generateText } from 'ai';
|
|
3
4
|
import { openai } from '@ai-sdk/openai';
|
|
4
5
|
|
|
6
|
+
function buildResumeText(resume) {
|
|
7
|
+
return [
|
|
8
|
+
resume?.basics?.name,
|
|
9
|
+
resume?.basics?.label,
|
|
10
|
+
resume?.basics?.summary,
|
|
11
|
+
...(resume?.skills || []).map(
|
|
12
|
+
(s) => `${s.name}: ${(s.keywords || []).join(', ')}`
|
|
13
|
+
),
|
|
14
|
+
...(resume?.work || [])
|
|
15
|
+
.slice(0, 5)
|
|
16
|
+
.map(
|
|
17
|
+
(w) =>
|
|
18
|
+
`${w.position} at ${w.name}${
|
|
19
|
+
w.startDate ? ` (${w.startDate})` : ''
|
|
20
|
+
}: ${w.summary || ''}`
|
|
21
|
+
),
|
|
22
|
+
...(resume?.projects || [])
|
|
23
|
+
.slice(0, 3)
|
|
24
|
+
.map((p) => `Project: ${p.name} — ${p.description || ''}`),
|
|
25
|
+
]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildJobText(job, rawContent) {
|
|
31
|
+
return [
|
|
32
|
+
`Title: ${job.title}`,
|
|
33
|
+
`Company: ${job.company}`,
|
|
34
|
+
`Salary: ${job.salary || 'Not listed'}`,
|
|
35
|
+
`Remote: ${job.remote || 'Not specified'}`,
|
|
36
|
+
job.experience ? `Experience: ${job.experience}` : '',
|
|
37
|
+
job.type ? `Type: ${job.type}` : '',
|
|
38
|
+
job.url ? `HN Post: ${job.url}` : '',
|
|
39
|
+
`Description: ${job.description || ''}`,
|
|
40
|
+
`Skills: ${(job.skills || []).map((s) => s.name || s).join(', ')}`,
|
|
41
|
+
...(job.qualifications || []).map((q) => `Qualification: ${q}`),
|
|
42
|
+
...(job.responsibilities || []).map((r) => `Responsibility: ${r}`),
|
|
43
|
+
rawContent ? `\nFull original posting:\n${rawContent}` : '',
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
5
49
|
export function useAI(resume) {
|
|
6
50
|
const [text, setText] = useState('');
|
|
7
51
|
const [loading, setLoading] = useState(false);
|
|
8
52
|
const [error, setError] = useState(null);
|
|
53
|
+
const [mode, setMode] = useState('ai'); // 'ai' | 'cover'
|
|
9
54
|
const hasKey = Boolean(process.env.OPENAI_API_KEY);
|
|
55
|
+
const childRef = useRef(null);
|
|
56
|
+
const dossierJobId = useRef(null);
|
|
57
|
+
// Local cache: jobId → { text, done, loading }
|
|
58
|
+
const dossierCache = useRef(new Map());
|
|
59
|
+
// Bump to trigger re-renders when dossier status changes
|
|
60
|
+
const [, setDossierTick] = useState(0);
|
|
61
|
+
const bumpTick = () => setDossierTick((t) => t + 1);
|
|
10
62
|
|
|
11
63
|
const summarizeJob = useCallback(
|
|
12
64
|
async (job) => {
|
|
@@ -16,6 +68,7 @@ export function useAI(resume) {
|
|
|
16
68
|
);
|
|
17
69
|
return;
|
|
18
70
|
}
|
|
71
|
+
setMode('ai');
|
|
19
72
|
setLoading(true);
|
|
20
73
|
setError(null);
|
|
21
74
|
setText('');
|
|
@@ -56,6 +109,291 @@ export function useAI(resume) {
|
|
|
56
109
|
[resume, hasKey]
|
|
57
110
|
);
|
|
58
111
|
|
|
112
|
+
const dossier = useCallback(
|
|
113
|
+
async (job, api) => {
|
|
114
|
+
const cached = dossierCache.current.get(job.id);
|
|
115
|
+
|
|
116
|
+
// If dossier is already loading or loaded for this job, just show it
|
|
117
|
+
if (cached && (cached.loading || cached.done)) {
|
|
118
|
+
setMode('cover');
|
|
119
|
+
setText(cached.text || '');
|
|
120
|
+
setLoading(cached.loading || false);
|
|
121
|
+
setError(null);
|
|
122
|
+
dossierJobId.current = job.id;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setMode('cover');
|
|
127
|
+
setError(null);
|
|
128
|
+
setText('');
|
|
129
|
+
dossierJobId.current = job.id;
|
|
130
|
+
dossierCache.current.set(job.id, {
|
|
131
|
+
text: '',
|
|
132
|
+
done: false,
|
|
133
|
+
loading: true,
|
|
134
|
+
});
|
|
135
|
+
bumpTick();
|
|
136
|
+
|
|
137
|
+
// Helper to update both state and cache
|
|
138
|
+
// Only updates visible state if this job is still the active dossier
|
|
139
|
+
const updateText = (val) => {
|
|
140
|
+
const entry = dossierCache.current.get(job.id);
|
|
141
|
+
if (entry) entry.text = val;
|
|
142
|
+
if (dossierJobId.current === job.id) setText(val);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Check server for existing dossier
|
|
146
|
+
try {
|
|
147
|
+
const { content } = await api.fetchDossier(job.id);
|
|
148
|
+
if (content) {
|
|
149
|
+
updateText(content);
|
|
150
|
+
dossierCache.current.set(job.id, {
|
|
151
|
+
text: content,
|
|
152
|
+
done: true,
|
|
153
|
+
loading: false,
|
|
154
|
+
});
|
|
155
|
+
bumpTick();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
/* no cached dossier */
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if claude CLI is available
|
|
163
|
+
const { execSync } = await import('child_process');
|
|
164
|
+
let claudePath;
|
|
165
|
+
try {
|
|
166
|
+
claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
167
|
+
} catch {
|
|
168
|
+
setError(
|
|
169
|
+
'Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code'
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Kill any existing claude process
|
|
175
|
+
if (childRef.current) {
|
|
176
|
+
try {
|
|
177
|
+
childRef.current.kill();
|
|
178
|
+
} catch {}
|
|
179
|
+
childRef.current = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setLoading(true);
|
|
183
|
+
|
|
184
|
+
// Fetch resume if not available yet
|
|
185
|
+
let currentResume = resume;
|
|
186
|
+
if (!currentResume) {
|
|
187
|
+
try {
|
|
188
|
+
const me = await api.fetchMe();
|
|
189
|
+
currentResume = me.resume;
|
|
190
|
+
} catch {
|
|
191
|
+
/* proceed without resume */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Fetch full job detail for raw content
|
|
196
|
+
let rawContent = '';
|
|
197
|
+
try {
|
|
198
|
+
const detail = await api.fetchJobDetail(job.id);
|
|
199
|
+
rawContent = detail.raw_content || detail.full_description || '';
|
|
200
|
+
} catch {}
|
|
201
|
+
|
|
202
|
+
const resumeText = buildResumeText(currentResume);
|
|
203
|
+
const jobText = buildJobText(job, rawContent);
|
|
204
|
+
|
|
205
|
+
const prompt = `You are a job search research assistant. A candidate is considering applying to this role. Do thorough research and produce a comprehensive dossier.
|
|
206
|
+
|
|
207
|
+
## Candidate Resume
|
|
208
|
+
${resumeText}
|
|
209
|
+
|
|
210
|
+
## Job Posting
|
|
211
|
+
${jobText}
|
|
212
|
+
|
|
213
|
+
## Your Task
|
|
214
|
+
|
|
215
|
+
Research everything you can and produce a complete dossier covering:
|
|
216
|
+
|
|
217
|
+
### 1. Compensation Context
|
|
218
|
+
- Market rate for this role/level/location
|
|
219
|
+
- How the listed salary compares
|
|
220
|
+
- Negotiation leverage points
|
|
221
|
+
|
|
222
|
+
### 2. AI Engineering Culture
|
|
223
|
+
- Does the company use or encourage AI coding tools (Copilot, Claude Code, Cursor, agentic workflows)?
|
|
224
|
+
- Any public statements, blog posts, or job descriptions mentioning AI-assisted development?
|
|
225
|
+
- Is the engineering culture likely to embrace engineers who ship faster using AI tools, or is there resistance?
|
|
226
|
+
- Look for signals: do they mention "AI-native", "agentic", "LLM-augmented" workflows, or similar?
|
|
227
|
+
- Are there concerns about AI tool usage (IP policies, code review friction, etc.)?
|
|
228
|
+
- Rating: AI-Forward / AI-Friendly / Neutral / Unknown / AI-Resistant
|
|
229
|
+
|
|
230
|
+
### 3. Company Deep Dive
|
|
231
|
+
- What the company does, their products/services
|
|
232
|
+
- Funding stage, size, recent news
|
|
233
|
+
- Tech stack and engineering culture
|
|
234
|
+
- Leadership team
|
|
235
|
+
- Employee sentiment and Glassdoor/Blind highlights
|
|
236
|
+
- Any red flags or concerns
|
|
237
|
+
|
|
238
|
+
### 4. Role Analysis
|
|
239
|
+
- What you'd actually be doing day-to-day
|
|
240
|
+
- Team context — who you'd work with
|
|
241
|
+
- Growth trajectory for this role
|
|
242
|
+
- How this role fits into the company's current priorities
|
|
243
|
+
|
|
244
|
+
### 5. Fit Assessment
|
|
245
|
+
- Matching skills and experience (be specific, reference the candidate's actual background)
|
|
246
|
+
- Skill gaps to acknowledge or address
|
|
247
|
+
- Adjacent experience that transfers
|
|
248
|
+
- Overall fit rating: Strong / Good / Stretch
|
|
249
|
+
|
|
250
|
+
### 6. Cover Letter Talking Points
|
|
251
|
+
- 5-7 specific bullet points to mention, each referencing the candidate's real experience
|
|
252
|
+
- What angle to take (technical depth? leadership? domain expertise?)
|
|
253
|
+
- What to emphasize vs. downplay
|
|
254
|
+
|
|
255
|
+
### 7. Contact Info
|
|
256
|
+
- Email addresses from the posting
|
|
257
|
+
- Who posted (HN username from the URL if available)
|
|
258
|
+
- Best way to reach out
|
|
259
|
+
|
|
260
|
+
### 8. Interview Prep
|
|
261
|
+
- Questions they'll likely ask for this role
|
|
262
|
+
- Questions the candidate should ask
|
|
263
|
+
- Topics to research before interviewing
|
|
264
|
+
|
|
265
|
+
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.`;
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const { spawn } = await import('child_process');
|
|
269
|
+
// Remove CLAUDECODE env var to allow nested claude sessions
|
|
270
|
+
const env = { ...process.env };
|
|
271
|
+
delete env.CLAUDECODE;
|
|
272
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
273
|
+
delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
274
|
+
|
|
275
|
+
const child = spawn(
|
|
276
|
+
claudePath,
|
|
277
|
+
[
|
|
278
|
+
'--print',
|
|
279
|
+
'--output-format',
|
|
280
|
+
'stream-json',
|
|
281
|
+
'--verbose',
|
|
282
|
+
'--allowedTools',
|
|
283
|
+
'WebSearch',
|
|
284
|
+
'WebFetch',
|
|
285
|
+
'--',
|
|
286
|
+
prompt,
|
|
287
|
+
],
|
|
288
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], env }
|
|
289
|
+
);
|
|
290
|
+
childRef.current = child;
|
|
291
|
+
|
|
292
|
+
let finalResult = '';
|
|
293
|
+
let buffer = '';
|
|
294
|
+
let statusLine = '';
|
|
295
|
+
|
|
296
|
+
function processLine(line) {
|
|
297
|
+
if (!line.trim()) return;
|
|
298
|
+
try {
|
|
299
|
+
const event = JSON.parse(line);
|
|
300
|
+
|
|
301
|
+
if (event.type === 'assistant') {
|
|
302
|
+
// Extract text from assistant message content
|
|
303
|
+
const content = event.message?.content || [];
|
|
304
|
+
for (const block of content) {
|
|
305
|
+
if (block.type === 'text' && block.text) {
|
|
306
|
+
finalResult = block.text;
|
|
307
|
+
updateText(
|
|
308
|
+
statusLine ? `${statusLine}\n\n${finalResult}` : finalResult
|
|
309
|
+
);
|
|
310
|
+
} else if (block.type === 'tool_use') {
|
|
311
|
+
// Show what Claude is doing (e.g. WebSearch, WebFetch)
|
|
312
|
+
const name = block.name || 'tool';
|
|
313
|
+
const input = block.input || {};
|
|
314
|
+
if (name === 'WebSearch' || name === 'WebFetch') {
|
|
315
|
+
statusLine = `🔍 ${name}: ${
|
|
316
|
+
input.query || input.url || ''
|
|
317
|
+
}`;
|
|
318
|
+
} else {
|
|
319
|
+
statusLine = `⚙ Using ${name}…`;
|
|
320
|
+
}
|
|
321
|
+
updateText(
|
|
322
|
+
finalResult ? `${statusLine}\n\n${finalResult}` : statusLine
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (event.type === 'result' && event.result) {
|
|
329
|
+
finalResult = event.result;
|
|
330
|
+
updateText(finalResult);
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
// Not valid JSON, skip
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
child.stdout.on('data', (chunk) => {
|
|
338
|
+
buffer += chunk.toString();
|
|
339
|
+
// Process complete JSON lines
|
|
340
|
+
const lines = buffer.split('\n');
|
|
341
|
+
buffer = lines.pop() || '';
|
|
342
|
+
for (const line of lines) {
|
|
343
|
+
processLine(line);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
child.stderr.on('data', () => {});
|
|
348
|
+
|
|
349
|
+
await new Promise((resolve, reject) => {
|
|
350
|
+
child.on('close', (code) => {
|
|
351
|
+
// Only clear childRef if it's still our process
|
|
352
|
+
if (childRef.current === child) childRef.current = null;
|
|
353
|
+
// Process any remaining buffer
|
|
354
|
+
if (buffer.trim()) processLine(buffer);
|
|
355
|
+
if (code === 0) {
|
|
356
|
+
resolve();
|
|
357
|
+
} else if (code !== null) {
|
|
358
|
+
reject(new Error(`Claude exited with code ${code}`));
|
|
359
|
+
} else {
|
|
360
|
+
resolve(); // killed
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
child.on('error', (err) => {
|
|
364
|
+
if (childRef.current === child) childRef.current = null;
|
|
365
|
+
reject(err);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Save to server if we got output
|
|
370
|
+
if (finalResult.trim()) {
|
|
371
|
+
try {
|
|
372
|
+
await api.saveDossier(job.id, finalResult);
|
|
373
|
+
} catch {}
|
|
374
|
+
}
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err.message?.includes('exited with code')) {
|
|
377
|
+
setError(`Claude failed — ${err.message}`);
|
|
378
|
+
} else if (err.message !== 'killed') {
|
|
379
|
+
setError(err.message);
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
const entry = dossierCache.current.get(job.id);
|
|
383
|
+
if (entry) {
|
|
384
|
+
entry.done = true;
|
|
385
|
+
entry.loading = false;
|
|
386
|
+
}
|
|
387
|
+
bumpTick();
|
|
388
|
+
if (dossierJobId.current === job.id) {
|
|
389
|
+
childRef.current = null;
|
|
390
|
+
setLoading(false);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
[resume]
|
|
395
|
+
);
|
|
396
|
+
|
|
59
397
|
const batchReview = useCallback(
|
|
60
398
|
async (jobs) => {
|
|
61
399
|
if (!hasKey) {
|
|
@@ -64,6 +402,7 @@ export function useAI(resume) {
|
|
|
64
402
|
);
|
|
65
403
|
return;
|
|
66
404
|
}
|
|
405
|
+
setMode('ai');
|
|
67
406
|
setLoading(true);
|
|
68
407
|
setError(null);
|
|
69
408
|
setText('');
|
|
@@ -99,13 +438,92 @@ export function useAI(resume) {
|
|
|
99
438
|
[resume, hasKey]
|
|
100
439
|
);
|
|
101
440
|
|
|
441
|
+
const cancel = useCallback(() => {
|
|
442
|
+
if (childRef.current) {
|
|
443
|
+
try {
|
|
444
|
+
childRef.current.kill();
|
|
445
|
+
} catch {}
|
|
446
|
+
childRef.current = null;
|
|
447
|
+
}
|
|
448
|
+
}, []);
|
|
449
|
+
|
|
450
|
+
const textRef = useRef('');
|
|
451
|
+
textRef.current = text;
|
|
452
|
+
|
|
453
|
+
const exportDossier = useCallback((job) => {
|
|
454
|
+
// Use visible text state, or fall back to cache
|
|
455
|
+
const content =
|
|
456
|
+
textRef.current || dossierCache.current.get(job?.id)?.text || '';
|
|
457
|
+
if (!content) return null;
|
|
458
|
+
const company = (job?.company || 'unknown')
|
|
459
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
460
|
+
.replace(/-+/g, '-')
|
|
461
|
+
.replace(/^-|-$/g, '')
|
|
462
|
+
.toLowerCase()
|
|
463
|
+
.slice(0, 50);
|
|
464
|
+
const filename = `dossier-${company}.md`;
|
|
465
|
+
writeFileSync(filename, content, 'utf-8');
|
|
466
|
+
return filename;
|
|
467
|
+
}, []);
|
|
468
|
+
|
|
469
|
+
// Seed cache with server-side dossier flags from job list
|
|
470
|
+
const seedDossierFlags = useCallback((jobs) => {
|
|
471
|
+
let changed = false;
|
|
472
|
+
for (const job of jobs) {
|
|
473
|
+
if (job.has_dossier && !dossierCache.current.has(job.id)) {
|
|
474
|
+
dossierCache.current.set(job.id, {
|
|
475
|
+
text: '',
|
|
476
|
+
done: true,
|
|
477
|
+
loading: false,
|
|
478
|
+
});
|
|
479
|
+
changed = true;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (changed) bumpTick();
|
|
483
|
+
}, []);
|
|
484
|
+
|
|
485
|
+
// Expose dossier status for job list icons
|
|
486
|
+
// Returns: 'generating' | 'done' | null
|
|
487
|
+
const getDossierStatus = useCallback((jobId) => {
|
|
488
|
+
const entry = dossierCache.current.get(jobId);
|
|
489
|
+
if (!entry) return null;
|
|
490
|
+
if (entry.loading) return 'generating';
|
|
491
|
+
if (entry.done) return 'done';
|
|
492
|
+
return null;
|
|
493
|
+
}, []);
|
|
494
|
+
|
|
495
|
+
const regenerateDossier = useCallback(
|
|
496
|
+
(job, api) => {
|
|
497
|
+
// Clear cached dossier so dossier() starts fresh
|
|
498
|
+
dossierCache.current.delete(job.id);
|
|
499
|
+
cancel();
|
|
500
|
+
setText('');
|
|
501
|
+
setError(null);
|
|
502
|
+
bumpTick();
|
|
503
|
+
dossier(job, api);
|
|
504
|
+
},
|
|
505
|
+
[dossier, cancel]
|
|
506
|
+
);
|
|
507
|
+
|
|
102
508
|
return {
|
|
103
509
|
text,
|
|
104
510
|
loading,
|
|
105
511
|
error,
|
|
106
512
|
hasKey,
|
|
513
|
+
mode,
|
|
514
|
+
hasActiveProcess: Boolean(childRef.current),
|
|
515
|
+
getDossierStatus,
|
|
516
|
+
seedDossierFlags,
|
|
107
517
|
summarizeJob,
|
|
518
|
+
dossier,
|
|
519
|
+
regenerateDossier,
|
|
108
520
|
batchReview,
|
|
109
|
-
|
|
521
|
+
exportDossier,
|
|
522
|
+
cancel,
|
|
523
|
+
clear: () => {
|
|
524
|
+
cancel();
|
|
525
|
+
setText('');
|
|
526
|
+
dossierJobId.current = null;
|
|
527
|
+
},
|
|
110
528
|
};
|
|
111
529
|
}
|
package/src/tui/useJobs.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
2
2
|
import { getCached, setCache, updateCachedJob } from '../cache.js';
|
|
3
3
|
|
|
4
|
-
export function useJobs(api, activeFilters, tab, searchId) {
|
|
4
|
+
export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
|
|
5
5
|
const [allJobs, setAllJobs] = useState([]);
|
|
6
6
|
const [loading, setLoading] = useState(true);
|
|
7
7
|
const [reranking, setReranking] = useState(false);
|
|
@@ -12,6 +12,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
12
12
|
const params = useMemo(() => {
|
|
13
13
|
const p = { top: 100, days: 30 };
|
|
14
14
|
if (searchId) p.searchId = searchId;
|
|
15
|
+
if (api.mode) p.mode = api.mode;
|
|
15
16
|
for (const f of activeFilters || []) {
|
|
16
17
|
if (f.type === 'days') p.days = Number(f.value) || 30;
|
|
17
18
|
if (f.type === 'remote') p.remote = true;
|
|
@@ -19,7 +20,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
19
20
|
if (f.type === 'search') p.search = f.value || '';
|
|
20
21
|
}
|
|
21
22
|
return p;
|
|
22
|
-
}, [activeFilters, searchId]);
|
|
23
|
+
}, [activeFilters, searchId, api.mode]);
|
|
23
24
|
|
|
24
25
|
const paramsKey = JSON.stringify(params);
|
|
25
26
|
|
|
@@ -134,6 +135,22 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
if (tab === 'new')
|
|
139
|
+
return filtered.filter(
|
|
140
|
+
(j) =>
|
|
141
|
+
!j.state &&
|
|
142
|
+
!j.has_dossier &&
|
|
143
|
+
(!getDossierStatus ||
|
|
144
|
+
(getDossierStatus(j.id) !== 'done' &&
|
|
145
|
+
getDossierStatus(j.id) !== 'generating'))
|
|
146
|
+
);
|
|
147
|
+
if (tab === 'reviewed')
|
|
148
|
+
return filtered.filter(
|
|
149
|
+
(j) =>
|
|
150
|
+
!j.state &&
|
|
151
|
+
(j.has_dossier ||
|
|
152
|
+
(getDossierStatus && getDossierStatus(j.id) === 'done'))
|
|
153
|
+
);
|
|
137
154
|
if (tab === 'interested')
|
|
138
155
|
return filtered.filter((j) => j.state === 'interested');
|
|
139
156
|
if (tab === 'applied') return filtered.filter((j) => j.state === 'applied');
|
|
@@ -144,7 +161,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
144
161
|
return filtered.filter(
|
|
145
162
|
(j) => j.state !== 'not_interested' && j.state !== 'dismissed'
|
|
146
163
|
);
|
|
147
|
-
}, [allJobs, activeFilters, tab]);
|
|
164
|
+
}, [allJobs, activeFilters, tab, getDossierStatus]);
|
|
148
165
|
|
|
149
166
|
const markJob = useCallback(
|
|
150
167
|
async (id, state, feedback) => {
|