@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "description": "Search Hacker News jobs matched against your JSON Resume",
6
6
  "bin": {
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
 
@@ -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'
@@ -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 { stateIcon, formatSalary, formatLocation } from '../formatters.js';
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', d.posted_at || '—'],
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
 
@@ -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')) {
@@ -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