@jsonresume/jobs 0.9.0 → 0.10.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/bin/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Just run: npx @jsonresume/jobs
7
7
  */
8
8
 
9
- const VERSION = '0.9.0';
9
+ const VERSION = '0.10.0';
10
10
 
11
11
  const BASE_URL =
12
12
  getArg('--base-url') ||
@@ -118,10 +118,12 @@ function stateIcon(state) {
118
118
 
119
119
  async function cmdSearch() {
120
120
  const params = new URLSearchParams();
121
- params.set('top', getArg('--top') || '20');
122
- params.set('days', getArg('--days') || '30');
121
+ const topArg = parseInt(getArg('--top')) || 20;
122
+ params.set('top', String(Math.min(Math.max(1, topArg), 100)));
123
+ params.set('days', String(parseInt(getArg('--days')) || 30));
123
124
  if (hasFlag('--remote')) params.set('remote', 'true');
124
- if (getArg('--min-salary')) params.set('min_salary', getArg('--min-salary'));
125
+ const minSalary = parseInt(getArg('--min-salary'));
126
+ if (minSalary > 0) params.set('min_salary', String(minSalary));
125
127
  if (getArg('--search')) params.set('search', getArg('--search'));
126
128
 
127
129
  const { jobs } = await api(`/jobs?${params}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Search Hacker News jobs matched against your JSON Resume",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -40,7 +40,7 @@ export function createApiClient({ baseUrl, apiKey }) {
40
40
  markJob: (id, state, feedback) =>
41
41
  request(`/jobs/${id}`, {
42
42
  method: 'PUT',
43
- body: JSON.stringify({ state, feedback: feedback || state }),
43
+ body: JSON.stringify({ state, feedback: feedback || undefined }),
44
44
  }),
45
45
  fetchMe: () => request('/me'),
46
46
 
package/src/formatters.js CHANGED
@@ -39,26 +39,6 @@ export function truncate(str, len) {
39
39
  return str.length > len ? str.slice(0, len - 1) + '…' : str;
40
40
  }
41
41
 
42
- /**
43
- * Render a score as a sparkline bar: ████░░░░ 0.72
44
- * Uses Unicode block characters for sub-character precision.
45
- */
46
- const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
47
-
48
- export function scoreBar(score, width = 6) {
49
- if (typeof score !== 'number' || isNaN(score)) return '░'.repeat(width);
50
- const clamped = Math.max(0, Math.min(1, score));
51
- const filled = clamped * width;
52
- const full = Math.floor(filled);
53
- const frac = Math.round((filled - full) * 8);
54
- const empty = width - full - (frac > 0 ? 1 : 0);
55
- return (
56
- '█'.repeat(full) +
57
- (frac > 0 ? BLOCKS[frac] : '') +
58
- '░'.repeat(Math.max(0, empty))
59
- );
60
- }
61
-
62
42
  export function stateLabel(state) {
63
43
  const labels = {
64
44
  interested: 'Interested',
package/src/localApi.js CHANGED
@@ -25,7 +25,13 @@ export function createLocalApiClient({ baseUrl, resume }) {
25
25
  headers: { 'Content-Type': 'application/json' },
26
26
  body: JSON.stringify(body),
27
27
  });
28
- const data = await res.json();
28
+ const text = await res.text();
29
+ let data;
30
+ try {
31
+ data = JSON.parse(text);
32
+ } catch {
33
+ throw new Error(`Non-JSON response: ${res.status}`);
34
+ }
29
35
  if (!res.ok) throw new Error(data.error || res.statusText);
30
36
 
31
37
  // Overlay local marks onto results
@@ -44,9 +50,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
44
50
  return { id };
45
51
  },
46
52
 
47
- markJob: async (id, state) => {
53
+ markJob: async (id, state, feedback) => {
48
54
  setMark(id, state);
49
- return { id, state };
55
+ return { id, state, feedback };
50
56
  },
51
57
 
52
58
  fetchMe: async () => ({ resume, username: 'local' }),
package/src/tui/App.js CHANGED
@@ -36,9 +36,10 @@ const TAB_LABELS = {
36
36
  function InlineSearch({ query, onChange, onSubmit }) {
37
37
  return h(
38
38
  Box,
39
- { paddingX: 1 },
40
- h(Text, { color: 'yellow', bold: true }, 'Find: '),
41
- h(TextInput, { value: query, onChange, onSubmit })
39
+ { paddingX: 1, gap: 1 },
40
+ h(Text, { color: 'yellow', bold: true }, 'Find:'),
41
+ h(TextInput, { value: query, onChange, onSubmit }),
42
+ h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
42
43
  );
43
44
  }
44
45
 
@@ -106,7 +107,18 @@ function App({ baseUrl, apiKey, apiClient }) {
106
107
  const jobs = useMemo(() => {
107
108
  if (!appliedQuery) return rawJobs;
108
109
  const q = appliedQuery.toLowerCase();
109
- return rawJobs.filter((j) => JSON.stringify(j).toLowerCase().includes(q));
110
+ return rawJobs.filter((j) => {
111
+ const fields = [
112
+ j.title,
113
+ j.company,
114
+ j.description,
115
+ j.remote,
116
+ j.location?.city,
117
+ j.location?.countryCode,
118
+ ...(j.skills || []).map((s) => s.name || s),
119
+ ];
120
+ return fields.some((f) => f && String(f).toLowerCase().includes(q));
121
+ });
110
122
  }, [rawJobs, appliedQuery]);
111
123
 
112
124
  useEffect(() => {
@@ -215,7 +227,7 @@ function App({ baseUrl, apiKey, apiClient }) {
215
227
  const handleExport = () => {
216
228
  try {
217
229
  const filename = exportShortlist(allJobs);
218
- showToast(`Exported to ${filename}`, 'export');
230
+ showToast(`Saved ./${filename}`, 'export');
219
231
  } catch (err) {
220
232
  showToast(`Export failed: ${err.message}`, 'error');
221
233
  }
@@ -124,7 +124,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
124
124
  ...next[editIdx],
125
125
  value:
126
126
  filter.type === 'minSalary' || filter.type === 'days'
127
- ? parseInt(val) || val
127
+ ? parseInt(val) || 0
128
128
  : val,
129
129
  };
130
130
  onUpdate({ ...filterState, active: next });
@@ -132,11 +132,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
132
132
  },
133
133
  })
134
134
  ),
135
- h(
136
- Text,
137
- { dimColor: true, marginTop: 1 },
138
- 'Enter to save, Ctrl+C to cancel'
139
- )
135
+ h(Text, { dimColor: true, marginTop: 1 }, 'Enter to save, Esc to cancel')
140
136
  );
141
137
  }
142
138
 
@@ -165,7 +161,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
165
161
  if (val.trim()) {
166
162
  const value =
167
163
  ft.type === 'minSalary' || ft.type === 'days'
168
- ? parseInt(val) || val
164
+ ? parseInt(val) || 0
169
165
  : val;
170
166
  onUpdate({
171
167
  ...filterState,
@@ -7,10 +7,12 @@ const SECTIONS = [
7
7
  keys: [
8
8
  ['j / ↓', 'Move down'],
9
9
  ['k / ↑', 'Move up'],
10
- ['Enter', 'Open job details'],
10
+ ['g / G', 'Jump to first / last'],
11
+ ['Ctrl+U / D', 'Page up / page down'],
12
+ ['Enter', 'Open split-pane detail view'],
11
13
  ['Esc / q', 'Back / quit'],
12
- ['Tab', 'Next section tab'],
13
- ['Shift+Tab', 'Previous section tab'],
14
+ ['Tab', 'Next tab'],
15
+ ['Shift+Tab', 'Previous tab'],
14
16
  ],
15
17
  },
16
18
  {
@@ -20,28 +22,31 @@ const SECTIONS = [
20
22
  ['x', 'Mark applied'],
21
23
  ['m', 'Mark maybe'],
22
24
  ['p', 'Mark passed'],
25
+ ['v', 'Toggle batch selection'],
23
26
  ],
24
27
  },
25
28
  {
26
29
  title: 'Search & Filter',
27
30
  keys: [
31
+ ['n', 'Inline keyword search'],
28
32
  ['/', 'Search profiles'],
29
33
  ['f', 'Manage filters'],
30
- ['R', 'Force refresh'],
34
+ ['e', 'Export shortlist to markdown'],
35
+ ['R', 'Force refresh (bypass cache)'],
31
36
  ],
32
37
  },
33
38
  {
34
- title: 'AI Features',
39
+ title: 'Detail View',
35
40
  keys: [
36
- ['Space', 'AI summary of job'],
37
- ['S', 'AI batch review'],
41
+ ['J / K', 'Scroll detail content'],
42
+ ['o', 'Open HN post in browser'],
38
43
  ],
39
44
  },
40
45
  {
41
- title: 'Detail View',
46
+ title: 'AI Features (requires OPENAI_API_KEY)',
42
47
  keys: [
43
- ['o', 'Open HN post in browser'],
44
- ['e', 'Export shortlist to markdown'],
48
+ ['Space', 'AI summary of current job'],
49
+ ['S', 'AI batch review of visible jobs'],
45
50
  ],
46
51
  },
47
52
  ];
@@ -68,7 +73,7 @@ export default function HelpModal({ onClose }) {
68
73
  h(
69
74
  Box,
70
75
  { justifyContent: 'center', marginBottom: 1 },
71
- h(Text, { bold: true, color: 'cyan' }, 'Keyboard Shortcuts')
76
+ h(Text, { bold: true, color: 'cyan' }, 'Keyboard Shortcuts')
72
77
  ),
73
78
  ...SECTIONS.flatMap((section) => [
74
79
  h(
@@ -73,7 +73,7 @@ export default function SearchManager({
73
73
  h(
74
74
  Text,
75
75
  { color: 'magenta' },
76
- ' Creating search profile... (AI is blending your resume with the prompt)'
76
+ ' Creating profile generating a custom embedding from your resume + search prompt'
77
77
  )
78
78
  )
79
79
  );
@@ -110,7 +110,7 @@ export default function SearchManager({
110
110
  h(
111
111
  Text,
112
112
  { dimColor: true, marginTop: 1 },
113
- 'Enter to continue, Ctrl+C to cancel'
113
+ 'Enter to continue, Esc to cancel'
114
114
  )
115
115
  );
116
116
  }
@@ -153,7 +153,7 @@ export default function SearchManager({
153
153
  h(
154
154
  Text,
155
155
  { dimColor: true, marginTop: 1 },
156
- 'Enter to create, Ctrl+C to cancel'
156
+ 'Enter to create, Esc to cancel'
157
157
  )
158
158
  );
159
159
  }
@@ -21,6 +21,10 @@ const KEYS = {
21
21
  ['space', 'AI'],
22
22
  ['esc', 'back'],
23
23
  ],
24
+ search: [
25
+ ['esc', 'clear search'],
26
+ ['enter', 'apply'],
27
+ ],
24
28
  filters: [
25
29
  ['j/k', 'nav'],
26
30
  ['enter', 'edit'],
@@ -67,14 +71,21 @@ export default function StatusBar({
67
71
  h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
68
72
  );
69
73
 
70
- const rightInfo = h(
71
- Box,
72
- { gap: 1 },
73
- loading ? h(Text, { color: 'yellow' }, 'loading…') : null,
74
- reranking ? h(Text, { color: 'magenta' }, 'reranking…') : null,
75
- h(Text, { dimColor: true }, `${jobCount}/${totalCount}`),
76
- aiEnabled ? null : h(Text, { dimColor: true }, 'no-AI')
74
+ const rightParts = [];
75
+ if (loading)
76
+ rightParts.push(h(Text, { key: 'l', color: 'yellow' }, 'loading…'));
77
+ if (reranking)
78
+ rightParts.push(h(Text, { key: 'r', color: 'magenta' }, 'reranking…'));
79
+ rightParts.push(
80
+ h(Text, { key: 'c', dimColor: true }, `${jobCount}/${totalCount}`)
77
81
  );
82
+ if (!aiEnabled) {
83
+ rightParts.push(
84
+ h(Text, { key: 'ai', dimColor: true }, 'set OPENAI_API_KEY for AI')
85
+ );
86
+ }
87
+
88
+ const rightInfo = h(Box, { gap: 1 }, ...rightParts);
78
89
 
79
90
  const content = toast
80
91
  ? h(Box, { paddingX: 1, justifyContent: 'space-between' }, toast, rightInfo)
package/src/tui/useAI.js CHANGED
@@ -11,7 +11,9 @@ export function useAI(resume) {
11
11
  const summarizeJob = useCallback(
12
12
  async (job) => {
13
13
  if (!hasKey) {
14
- setError('Set OPENAI_API_KEY to enable AI features');
14
+ setError(
15
+ 'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
16
+ );
15
17
  return;
16
18
  }
17
19
  setLoading(true);
@@ -57,7 +59,9 @@ export function useAI(resume) {
57
59
  const batchReview = useCallback(
58
60
  async (jobs) => {
59
61
  if (!hasKey) {
60
- setError('Set OPENAI_API_KEY to enable AI features');
62
+ setError(
63
+ 'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
64
+ );
61
65
  return;
62
66
  }
63
67
  setLoading(true);
@@ -1,68 +0,0 @@
1
- import { Box, Text } from 'ink';
2
- import { h } from './h.js';
3
- import { formatSalary, formatLocation, stateIcon } from '../formatters.js';
4
-
5
- export default function PreviewPane({ job }) {
6
- if (!job) return null;
7
-
8
- const cols = process.stdout.columns || 80;
9
- const loc = formatLocation(job.location, job.remote);
10
- const sal = formatSalary(job.salary, job.salary_usd);
11
- const icon = stateIcon(job.state);
12
-
13
- const skills = job.skills
14
- ? job.skills
15
- .slice(0, 8)
16
- .map((s) => s.name || s)
17
- .join(' · ')
18
- : null;
19
-
20
- const desc = job.description
21
- ? job.description.replace(/\n/g, ' ').slice(0, cols * 2)
22
- : null;
23
-
24
- return h(
25
- Box,
26
- {
27
- flexDirection: 'column',
28
- borderStyle: 'single',
29
- borderColor: 'gray',
30
- borderTop: true,
31
- borderBottom: false,
32
- borderLeft: false,
33
- borderRight: false,
34
- paddingX: 1,
35
- },
36
- // Title line
37
- h(
38
- Box,
39
- { gap: 1 },
40
- h(Text, { bold: true, color: 'white' }, job.title || '—'),
41
- h(Text, { dimColor: true }, 'at'),
42
- h(Text, { bold: true, color: 'cyan' }, job.company || '—'),
43
- job.state ? h(Text, null, ` ${icon}`) : null
44
- ),
45
- // Meta line
46
- h(
47
- Box,
48
- { gap: 2 },
49
- h(Text, { dimColor: true }, `📍 ${loc}`),
50
- h(Text, { dimColor: true }, `💰 ${sal}`),
51
- job.experience
52
- ? h(Text, { dimColor: true }, `📊 ${job.experience}`)
53
- : null,
54
- job.url ? h(Text, { color: 'blue', dimColor: true }, 'o:open') : null
55
- ),
56
- // Skills
57
- skills
58
- ? h(
59
- Box,
60
- null,
61
- h(Text, { color: 'yellow' }, 'Skills: '),
62
- h(Text, { dimColor: true }, skills)
63
- )
64
- : null,
65
- // Description snippet
66
- desc ? h(Text, { dimColor: true, wrap: 'truncate-end' }, desc) : null
67
- );
68
- }