@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 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')) || 30));
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: 30)
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.0",
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 (loc?.city) parts.push(loc.city);
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
+ });