@jsonresume/jobs 0.14.0 → 0.14.2
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/LICENSE +21 -0
- package/bin/cli.js +2 -2
- package/package.json +9 -2
- package/src/api.js +7 -0
- package/src/formatters.js +66 -2
- package/src/formatters.test.js +107 -0
- package/src/tui/App.js +108 -265
- package/src/tui/JobDetail.js +25 -3
- package/src/tui/JobList.js +3 -1
- package/src/tui/SplitPane.js +58 -0
- package/src/tui/aiHelpers.js +129 -0
- package/src/tui/aiHelpers.test.js +200 -0
- package/src/tui/appKeyHandler.js +89 -0
- package/src/tui/appKeyHandler.test.js +144 -0
- package/src/tui/claudeDossier.js +97 -0
- package/src/tui/claudeDossier.test.js +40 -0
- package/src/tui/dossierCache.js +26 -0
- package/src/tui/dossierCache.test.js +40 -0
- package/src/tui/dossierPrompt.js +80 -0
- package/src/tui/dossierStream.js +48 -0
- package/src/tui/dossierStream.test.js +81 -0
- package/src/tui/gptReview.js +47 -0
- package/src/tui/jobFilters.js +69 -0
- package/src/tui/jobFilters.test.js +80 -0
- package/src/tui/useAI.js +37 -282
- package/src/tui/useJobs.js +7 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2014-2026 JSON Resume contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/cli.js
CHANGED
|
@@ -120,7 +120,7 @@ async function cmdSearch() {
|
|
|
120
120
|
const params = new URLSearchParams();
|
|
121
121
|
const topArg = parseInt(getArg('--top')) || 20;
|
|
122
122
|
params.set('top', String(Math.min(Math.max(1, topArg), 100)));
|
|
123
|
-
params.set('days', String(parseInt(getArg('--days')) ||
|
|
123
|
+
params.set('days', String(parseInt(getArg('--days')) || 90));
|
|
124
124
|
if (hasFlag('--remote')) params.set('remote', 'true');
|
|
125
125
|
const minSalary = parseInt(getArg('--min-salary'));
|
|
126
126
|
if (minSalary > 0) params.set('min_salary', String(minSalary));
|
|
@@ -340,7 +340,7 @@ COMMANDS
|
|
|
340
340
|
|
|
341
341
|
SEARCH OPTIONS
|
|
342
342
|
--top N Number of results (default: 20, max: 100)
|
|
343
|
-
--days N How far back to look (default:
|
|
343
|
+
--days N How far back to look (default: 90)
|
|
344
344
|
--remote Remote jobs only
|
|
345
345
|
--min-salary N Minimum salary in thousands (e.g. 150)
|
|
346
346
|
--search TERM Keyword filter (searches title, company, skills)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsonresume/jobs",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Search Hacker News jobs matched against your JSON Resume",
|
|
6
6
|
"bin": {
|
|
@@ -43,5 +43,12 @@
|
|
|
43
43
|
"ink-spinner": "^5.0.0",
|
|
44
44
|
"ink-text-input": "^6.0.0",
|
|
45
45
|
"react": "^19.2.4"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"vitest": "^3.2.6"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest"
|
|
46
53
|
}
|
|
47
|
-
}
|
|
54
|
+
}
|
package/src/api.js
CHANGED
|
@@ -59,6 +59,13 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
59
59
|
}),
|
|
60
60
|
deleteSearch: (id) => request(`/searches/${id}`, { method: 'DELETE' }),
|
|
61
61
|
|
|
62
|
+
// Enrichment
|
|
63
|
+
enrichJob: (id, enriched) =>
|
|
64
|
+
request(`/jobs/${id}`, {
|
|
65
|
+
method: 'PATCH',
|
|
66
|
+
body: JSON.stringify({ enriched }),
|
|
67
|
+
}),
|
|
68
|
+
|
|
62
69
|
// Dossiers
|
|
63
70
|
fetchDossier: (id) => request(`/jobs/${id}/dossier`),
|
|
64
71
|
saveDossier: (id, content) =>
|
package/src/formatters.js
CHANGED
|
@@ -4,10 +4,62 @@ export function formatSalary(salary, salaryUsd) {
|
|
|
4
4
|
return '—';
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Defensive location normalizer for job records.
|
|
9
|
+
*
|
|
10
|
+
* The registry API serves `job.location` straight from the parsed
|
|
11
|
+
* `gpt_content`, whose canonical shape is an OBJECT
|
|
12
|
+
* (`{ address, postalCode, city, region, countryCode }`, see the
|
|
13
|
+
* extraction schema in apps/registry/scripts/jobs/job-parser/jobSchema.js).
|
|
14
|
+
* Remote status is a SEPARATE field (`job.remote` → 'Full' | 'Hybrid' | 'None').
|
|
15
|
+
*
|
|
16
|
+
* Historic rows may still carry `location` as a plain string, so this
|
|
17
|
+
* normalizer accepts strings, objects, or nullish input and always returns a
|
|
18
|
+
* stable shape:
|
|
19
|
+
* { city, region, countryCode, display, remote }
|
|
20
|
+
* where `remote` is a boolean derived from either the separate `remote`
|
|
21
|
+
* field or a "remote" hint inside a string location.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object|string|null} job - a job record, or a bare location value.
|
|
24
|
+
* @returns {{city:string|null, region:string|null, countryCode:string|null, display:string|null, remote:boolean}}
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeLocation(job) {
|
|
27
|
+
// Allow passing either a full job record or a bare location value.
|
|
28
|
+
const isJobRecord = job && typeof job === 'object' && 'location' in job;
|
|
29
|
+
const loc = isJobRecord ? job.location : job;
|
|
30
|
+
const remoteField = isJobRecord ? job.remote : undefined;
|
|
31
|
+
|
|
32
|
+
let city = null;
|
|
33
|
+
let region = null;
|
|
34
|
+
let countryCode = null;
|
|
35
|
+
let display = null;
|
|
36
|
+
let stringRemote = false;
|
|
37
|
+
|
|
38
|
+
if (typeof loc === 'string') {
|
|
39
|
+
const trimmed = loc.trim();
|
|
40
|
+
display = trimmed || null;
|
|
41
|
+
stringRemote = /remote/i.test(trimmed);
|
|
42
|
+
} else if (loc && typeof loc === 'object') {
|
|
43
|
+
city = loc.city || null;
|
|
44
|
+
region = loc.region || null;
|
|
45
|
+
countryCode = loc.countryCode || null;
|
|
46
|
+
const parts = [city, region, countryCode].filter(Boolean);
|
|
47
|
+
display = parts.length ? parts.join(', ') : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mirror the server-side remote filter (matchingHelpers.js), which treats
|
|
51
|
+
// only fully-remote roles as "remote" (j.remote === 'Full'). For historic
|
|
52
|
+
// string locations with no separate `remote` field, fall back to the
|
|
53
|
+
// "remote" hint in the location text.
|
|
54
|
+
const remote = remoteField === 'Full' || stringRemote;
|
|
55
|
+
|
|
56
|
+
return { city, region, countryCode, display, remote };
|
|
57
|
+
}
|
|
58
|
+
|
|
7
59
|
export function formatLocation(loc, remote) {
|
|
60
|
+
const norm = normalizeLocation(loc);
|
|
8
61
|
const parts = [];
|
|
9
|
-
if (
|
|
10
|
-
if (loc?.countryCode) parts.push(loc.countryCode);
|
|
62
|
+
if (norm.display) parts.push(norm.display);
|
|
11
63
|
if (remote) parts.push(`(${remote})`);
|
|
12
64
|
return parts.join(', ') || '—';
|
|
13
65
|
}
|
|
@@ -39,6 +91,18 @@ export function truncate(str, len) {
|
|
|
39
91
|
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
40
92
|
}
|
|
41
93
|
|
|
94
|
+
export function formatAge(postedAt) {
|
|
95
|
+
if (!postedAt) return '';
|
|
96
|
+
const days = Math.floor(
|
|
97
|
+
(Date.now() - new Date(postedAt).getTime()) / 86400000
|
|
98
|
+
);
|
|
99
|
+
if (days === 0) return 'today';
|
|
100
|
+
if (days === 1) return '1d ago';
|
|
101
|
+
if (days < 7) return `${days}d ago`;
|
|
102
|
+
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
|
103
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
104
|
+
}
|
|
105
|
+
|
|
42
106
|
export function stateLabel(state) {
|
|
43
107
|
const labels = {
|
|
44
108
|
interested: 'Interested',
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { normalizeLocation, formatLocation } from './formatters.js';
|
|
3
|
+
|
|
4
|
+
describe('normalizeLocation', () => {
|
|
5
|
+
it('normalizes the canonical object location shape', () => {
|
|
6
|
+
const job = {
|
|
7
|
+
location: { city: 'Berlin', region: 'BE', countryCode: 'DE' },
|
|
8
|
+
remote: 'None',
|
|
9
|
+
};
|
|
10
|
+
const norm = normalizeLocation(job);
|
|
11
|
+
expect(norm).toEqual({
|
|
12
|
+
city: 'Berlin',
|
|
13
|
+
region: 'BE',
|
|
14
|
+
countryCode: 'DE',
|
|
15
|
+
display: 'Berlin, BE, DE',
|
|
16
|
+
remote: false,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('builds display from whichever object fields are present', () => {
|
|
21
|
+
expect(normalizeLocation({ location: { city: 'Seattle' } }).display).toBe(
|
|
22
|
+
'Seattle'
|
|
23
|
+
);
|
|
24
|
+
expect(normalizeLocation({ location: { countryCode: 'US' } }).display).toBe(
|
|
25
|
+
'US'
|
|
26
|
+
);
|
|
27
|
+
expect(normalizeLocation({ location: {} }).display).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('handles a historic plain-string location', () => {
|
|
31
|
+
const norm = normalizeLocation({ location: 'San Francisco, CA' });
|
|
32
|
+
expect(norm.display).toBe('San Francisco, CA');
|
|
33
|
+
expect(norm.city).toBeNull();
|
|
34
|
+
expect(norm.remote).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('detects "remote" inside a string location (historic rows)', () => {
|
|
38
|
+
expect(normalizeLocation({ location: 'Remote (US)' }).remote).toBe(true);
|
|
39
|
+
expect(normalizeLocation({ location: 'Fully remote' }).remote).toBe(true);
|
|
40
|
+
expect(normalizeLocation({ location: 'remote' }).remote).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('uses the separate remote field for object locations', () => {
|
|
44
|
+
expect(
|
|
45
|
+
normalizeLocation({ location: { city: 'NYC' }, remote: 'Full' }).remote
|
|
46
|
+
).toBe(true);
|
|
47
|
+
expect(
|
|
48
|
+
normalizeLocation({ location: { city: 'NYC' }, remote: 'Hybrid' }).remote
|
|
49
|
+
).toBe(false);
|
|
50
|
+
expect(
|
|
51
|
+
normalizeLocation({ location: { city: 'NYC' }, remote: 'None' }).remote
|
|
52
|
+
).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does NOT match remote on an object location (regression guard)', () => {
|
|
56
|
+
// The old `/remote/i.test(j.location || '')` stringified objects to
|
|
57
|
+
// "[object Object]" and never matched — but an object whose city happened
|
|
58
|
+
// to contain "remote" should not flip the flag either.
|
|
59
|
+
const job = { location: { city: 'Remoteville' }, remote: 'None' };
|
|
60
|
+
expect(normalizeLocation(job).remote).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles null / undefined / empty location', () => {
|
|
64
|
+
expect(normalizeLocation({ location: null })).toEqual({
|
|
65
|
+
city: null,
|
|
66
|
+
region: null,
|
|
67
|
+
countryCode: null,
|
|
68
|
+
display: null,
|
|
69
|
+
remote: false,
|
|
70
|
+
});
|
|
71
|
+
expect(normalizeLocation({ location: undefined }).display).toBeNull();
|
|
72
|
+
expect(normalizeLocation({ location: '' }).display).toBeNull();
|
|
73
|
+
expect(normalizeLocation(null).display).toBeNull();
|
|
74
|
+
expect(normalizeLocation(undefined).remote).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('accepts a bare location value (not wrapped in a job)', () => {
|
|
78
|
+
expect(
|
|
79
|
+
normalizeLocation({ city: 'Austin', countryCode: 'US' }).display
|
|
80
|
+
).toBe('Austin, US');
|
|
81
|
+
expect(normalizeLocation('Remote').remote).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('trims whitespace from string locations', () => {
|
|
85
|
+
expect(normalizeLocation({ location: ' London ' }).display).toBe(
|
|
86
|
+
'London'
|
|
87
|
+
);
|
|
88
|
+
expect(normalizeLocation({ location: ' ' }).display).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('formatLocation', () => {
|
|
93
|
+
it('formats object locations with an explicit remote suffix', () => {
|
|
94
|
+
expect(formatLocation({ city: 'Berlin', countryCode: 'DE' }, 'Full')).toBe(
|
|
95
|
+
'Berlin, DE, (Full)'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('formats string locations', () => {
|
|
100
|
+
expect(formatLocation('San Francisco, CA')).toBe('San Francisco, CA');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('falls back to an em dash when empty', () => {
|
|
104
|
+
expect(formatLocation(null)).toBe('—');
|
|
105
|
+
expect(formatLocation({})).toBe('—');
|
|
106
|
+
});
|
|
107
|
+
});
|