@jsonresume/jobs 0.13.0 → 0.14.1
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/package.json +1 -1
- package/src/api.js +8 -0
- package/src/formatters.js +12 -0
- package/src/localApi.js +1 -0
- package/src/tui/FilterManager.js +7 -0
- package/src/tui/JobDetail.js +25 -3
- package/src/tui/JobList.js +3 -1
- package/src/tui/useAI.js +23 -1
- package/src/tui/useJobs.js +4 -0
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -30,6 +30,7 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
30
30
|
qs.set('top', String(params.top || 50));
|
|
31
31
|
qs.set('days', String(params.days || 30));
|
|
32
32
|
if (params.remote) qs.set('remote', 'true');
|
|
33
|
+
if (params.globalRemote) qs.set('global_remote', 'true');
|
|
33
34
|
if (params.minSalary) qs.set('min_salary', String(params.minSalary));
|
|
34
35
|
if (params.search) qs.set('search', params.search);
|
|
35
36
|
if (params.searchId) qs.set('search_id', params.searchId);
|
|
@@ -58,6 +59,13 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
58
59
|
}),
|
|
59
60
|
deleteSearch: (id) => request(`/searches/${id}`, { method: 'DELETE' }),
|
|
60
61
|
|
|
62
|
+
// Enrichment
|
|
63
|
+
enrichJob: (id, enriched) =>
|
|
64
|
+
request(`/jobs/${id}`, {
|
|
65
|
+
method: 'PATCH',
|
|
66
|
+
body: JSON.stringify({ enriched }),
|
|
67
|
+
}),
|
|
68
|
+
|
|
61
69
|
// Dossiers
|
|
62
70
|
fetchDossier: (id) => request(`/jobs/${id}/dossier`),
|
|
63
71
|
saveDossier: (id, content) =>
|
package/src/formatters.js
CHANGED
|
@@ -39,6 +39,18 @@ export function truncate(str, len) {
|
|
|
39
39
|
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function formatAge(postedAt) {
|
|
43
|
+
if (!postedAt) return '';
|
|
44
|
+
const days = Math.floor(
|
|
45
|
+
(Date.now() - new Date(postedAt).getTime()) / 86400000
|
|
46
|
+
);
|
|
47
|
+
if (days === 0) return 'today';
|
|
48
|
+
if (days === 1) return '1d ago';
|
|
49
|
+
if (days < 7) return `${days}d ago`;
|
|
50
|
+
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
|
51
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
export function stateLabel(state) {
|
|
43
55
|
const labels = {
|
|
44
56
|
interested: 'Interested',
|
package/src/localApi.js
CHANGED
|
@@ -18,6 +18,7 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
18
18
|
days: params.days || 30,
|
|
19
19
|
};
|
|
20
20
|
if (params.remote) body.remote = true;
|
|
21
|
+
if (params.globalRemote) body.global_remote = true;
|
|
21
22
|
if (params.minSalary) body.min_salary = params.minSalary;
|
|
22
23
|
if (params.search) body.search = params.search;
|
|
23
24
|
|
package/src/tui/FilterManager.js
CHANGED
|
@@ -5,6 +5,11 @@ import { h } from './h.js';
|
|
|
5
5
|
|
|
6
6
|
const FILTER_TYPES = [
|
|
7
7
|
{ type: 'remote', label: 'Remote only', hasValue: false },
|
|
8
|
+
{
|
|
9
|
+
type: 'globalRemote',
|
|
10
|
+
label: 'Global remote (work from anywhere)',
|
|
11
|
+
hasValue: false,
|
|
12
|
+
},
|
|
8
13
|
{
|
|
9
14
|
type: 'search',
|
|
10
15
|
label: 'Keyword search',
|
|
@@ -233,6 +238,8 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
233
238
|
const label =
|
|
234
239
|
f.type === 'remote'
|
|
235
240
|
? 'Remote only'
|
|
241
|
+
: f.type === 'globalRemote'
|
|
242
|
+
? 'Global remote'
|
|
236
243
|
: f.type === 'search'
|
|
237
244
|
? `Search: "${f.value}"`
|
|
238
245
|
: f.type === 'minSalary'
|
package/src/tui/JobDetail.js
CHANGED
|
@@ -2,7 +2,12 @@ import { useState, useEffect } from 'react';
|
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import Spinner from 'ink-spinner';
|
|
4
4
|
import { h } from './h.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
stateIcon,
|
|
7
|
+
formatSalary,
|
|
8
|
+
formatLocation,
|
|
9
|
+
formatAge,
|
|
10
|
+
} from '../formatters.js';
|
|
6
11
|
|
|
7
12
|
export default function JobDetail({
|
|
8
13
|
job,
|
|
@@ -79,17 +84,25 @@ export default function JobDetail({
|
|
|
79
84
|
lines.push({ text: '' });
|
|
80
85
|
|
|
81
86
|
// Meta as key-value pairs
|
|
87
|
+
const age = formatAge(d.posted_at || job.posted_at);
|
|
88
|
+
const postedStr = d.posted_at
|
|
89
|
+
? `${d.posted_at.slice(0, 10)}${age ? ` (${age})` : ''}`
|
|
90
|
+
: '—';
|
|
91
|
+
|
|
82
92
|
const meta = [
|
|
83
93
|
['📍 Location', loc],
|
|
84
94
|
['💰 Salary', sal],
|
|
85
95
|
['📋 Type', d.type || '—'],
|
|
86
96
|
['📊 Experience', d.experience || '—'],
|
|
87
|
-
['📅 Posted',
|
|
97
|
+
['📅 Posted', postedStr],
|
|
88
98
|
[
|
|
89
99
|
'🎯 Match',
|
|
90
100
|
typeof d.similarity === 'number' ? `${d.similarity.toFixed(3)}` : '—',
|
|
91
101
|
],
|
|
92
102
|
];
|
|
103
|
+
if (d.decayed_similarity && d.decayed_similarity !== d.similarity) {
|
|
104
|
+
meta.push(['⏳ Recency', d.decayed_similarity.toFixed(3)]);
|
|
105
|
+
}
|
|
93
106
|
if (d.rerank_score) meta.push(['🧠 AI Score', `${d.rerank_score}/10`]);
|
|
94
107
|
if (d.combined_score) meta.push(['📈 Combined', d.combined_score.toFixed(3)]);
|
|
95
108
|
meta.push(['📌 Status', state ? `${stateIcon(state)} ${state}` : 'unmarked']);
|
|
@@ -104,11 +117,20 @@ export default function JobDetail({
|
|
|
104
117
|
lines.push({ text: '' });
|
|
105
118
|
}
|
|
106
119
|
|
|
107
|
-
// Skills
|
|
120
|
+
// Skills with match indicators
|
|
108
121
|
if (d.skills?.length) {
|
|
109
122
|
lines.push({ text: 'Skills', bold: true, color: 'yellow' });
|
|
110
123
|
const skillNames = d.skills.map((s) => s.name || s).join(' · ');
|
|
111
124
|
lines.push({ text: ` ${skillNames}` });
|
|
125
|
+
// Show keywords for each skill
|
|
126
|
+
for (const sk of d.skills) {
|
|
127
|
+
if (sk.keywords?.length) {
|
|
128
|
+
lines.push({
|
|
129
|
+
text: ` ${sk.name}: ${sk.keywords.join(', ')}`,
|
|
130
|
+
dimColor: true,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
112
134
|
lines.push({ text: '' });
|
|
113
135
|
}
|
|
114
136
|
|
package/src/tui/JobList.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
truncate,
|
|
7
7
|
formatSalary,
|
|
8
8
|
formatLocation,
|
|
9
|
+
formatAge,
|
|
9
10
|
} from '../formatters.js';
|
|
10
11
|
|
|
11
12
|
// Column gap between each column
|
|
@@ -138,6 +139,7 @@ function JobRow({
|
|
|
138
139
|
const sal = formatSalary(job.salary, job.salary_usd);
|
|
139
140
|
const score =
|
|
140
141
|
typeof job.similarity === 'number' ? job.similarity.toFixed(2) : '—';
|
|
142
|
+
const age = formatAge(job.posted_at);
|
|
141
143
|
const icon = stateIcon(job.state);
|
|
142
144
|
const dossierIcon =
|
|
143
145
|
dossierStatus === 'generating' ? '◌' : dossierStatus === 'done' ? '📋' : '';
|
|
@@ -227,7 +229,7 @@ function JobRow({
|
|
|
227
229
|
h(
|
|
228
230
|
Box,
|
|
229
231
|
{ width: locW, marginRight: GAP },
|
|
230
|
-
h(Text, props, truncate(loc, locW - 1))
|
|
232
|
+
h(Text, props, truncate(age ? `${loc} · ${age}` : loc, locW - 1))
|
|
231
233
|
),
|
|
232
234
|
h(Box, { width: 12, marginRight: GAP }, h(Text, props, truncate(sal, 11))),
|
|
233
235
|
h(
|
package/src/tui/useAI.js
CHANGED
|
@@ -262,7 +262,19 @@ Research everything you can and produce a complete dossier covering:
|
|
|
262
262
|
- Questions the candidate should ask
|
|
263
263
|
- Topics to research before interviewing
|
|
264
264
|
|
|
265
|
-
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations
|
|
265
|
+
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.
|
|
266
|
+
|
|
267
|
+
### IMPORTANT: Structured Data Block
|
|
268
|
+
At the very end of your response, output a JSON code block tagged \`\`\`enrichment with corrected/discovered job metadata. Only include fields where you found better data than what's in the posting. This helps improve the job database:
|
|
269
|
+
\`\`\`enrichment
|
|
270
|
+
{
|
|
271
|
+
"salary": "$X - $Y" or null if unknown,
|
|
272
|
+
"remote": "Full" | "Hybrid" | "None" | null,
|
|
273
|
+
"location": { "city": "...", "countryCode": "XX", "region": "..." } or null,
|
|
274
|
+
"experience": "Junior" | "Mid" | "Senior" | "Staff" | "Lead" | null,
|
|
275
|
+
"type": "Full-time" | "Contract" | "Part-time" | null
|
|
276
|
+
}
|
|
277
|
+
\`\`\``;
|
|
266
278
|
|
|
267
279
|
try {
|
|
268
280
|
const { spawn } = await import('child_process');
|
|
@@ -371,6 +383,16 @@ Be thorough, specific, and opinionated. Reference the candidate's actual experie
|
|
|
371
383
|
try {
|
|
372
384
|
await api.saveDossier(job.id, finalResult);
|
|
373
385
|
} catch {}
|
|
386
|
+
// Extract and save enrichment data if present
|
|
387
|
+
try {
|
|
388
|
+
const enrichMatch = finalResult.match(
|
|
389
|
+
/```enrichment\s*\n([\s\S]*?)\n```/
|
|
390
|
+
);
|
|
391
|
+
if (enrichMatch) {
|
|
392
|
+
const enriched = JSON.parse(enrichMatch[1]);
|
|
393
|
+
await api.enrichJob(job.id, enriched);
|
|
394
|
+
}
|
|
395
|
+
} catch {}
|
|
374
396
|
}
|
|
375
397
|
} catch (err) {
|
|
376
398
|
if (err.message?.includes('exited with code')) {
|
package/src/tui/useJobs.js
CHANGED
|
@@ -16,6 +16,7 @@ export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
|
|
|
16
16
|
for (const f of activeFilters || []) {
|
|
17
17
|
if (f.type === 'days') p.days = Number(f.value) || 30;
|
|
18
18
|
if (f.type === 'remote') p.remote = true;
|
|
19
|
+
if (f.type === 'globalRemote') p.globalRemote = true;
|
|
19
20
|
if (f.type === 'minSalary') p.minSalary = Number(f.value) || 0;
|
|
20
21
|
if (f.type === 'search') p.search = f.value || '';
|
|
21
22
|
}
|
|
@@ -122,6 +123,9 @@ export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
|
|
|
122
123
|
(j) => j.remote === 'Full' || /remote/i.test(j.location || '')
|
|
123
124
|
);
|
|
124
125
|
}
|
|
126
|
+
if (f.type === 'globalRemote') {
|
|
127
|
+
filtered = filtered.filter((j) => j.global_remote === true);
|
|
128
|
+
}
|
|
125
129
|
if (f.type === 'minSalary' && f.value) {
|
|
126
130
|
filtered = filtered.filter(
|
|
127
131
|
(j) => !j.salary_usd || j.salary_usd >= Number(f.value) * 1000
|