@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/src/tui/App.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import React, { useState, useEffect, useMemo, useCallback } from 'react';
2
- import { render, Box, Text, useInput, useApp } from 'ink';
3
- import TextInput from 'ink-text-input';
2
+ import { render, Box, useInput, useApp } from 'ink';
4
3
  import { h } from './h.js';
5
4
  import { createApiClient } from '../api.js';
6
5
  import {
@@ -23,35 +22,15 @@ import SearchManager from './SearchManager.js';
23
22
  import StatusBar from './StatusBar.js';
24
23
  import AIPanel from './AIPanel.js';
25
24
  import HelpModal from './HelpModal.js';
26
-
27
- const TABS = [
28
- 'all',
29
- 'new',
30
- 'reviewed',
31
- 'interested',
32
- 'applied',
33
- 'maybe',
34
- 'passed',
35
- ];
36
- const TAB_LABELS = {
37
- all: 'All',
38
- new: 'New',
39
- reviewed: 'Reviewed',
40
- interested: 'Interested',
41
- applied: 'Applied',
42
- maybe: 'Maybe',
43
- passed: 'Passed',
44
- };
45
-
46
- function InlineSearch({ query, onChange, onSubmit }) {
47
- return h(
48
- Box,
49
- { paddingX: 1, gap: 1 },
50
- h(Text, { color: 'yellow', bold: true }, 'Find:'),
51
- h(TextInput, { value: query, onChange, onSubmit }),
52
- h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
53
- );
54
- }
25
+ import { InlineSearch, SplitPane } from './SplitPane.js';
26
+ import {
27
+ TABS,
28
+ TAB_LABELS,
29
+ nextTab,
30
+ filterJobsByQuery,
31
+ computeCounts,
32
+ } from './jobFilters.js';
33
+ import { createAppKeyHandler } from './appKeyHandler.js';
55
34
 
56
35
  function App({ baseUrl, apiKey, apiClient }) {
57
36
  const { exit } = useApp();
@@ -151,22 +130,10 @@ function App({ baseUrl, apiKey, apiClient }) {
151
130
  }, [ai]);
152
131
 
153
132
  // Apply inline search filter
154
- const jobs = useMemo(() => {
155
- if (!appliedQuery) return rawJobs;
156
- const q = appliedQuery.toLowerCase();
157
- return rawJobs.filter((j) => {
158
- const fields = [
159
- j.title,
160
- j.company,
161
- j.description,
162
- j.remote,
163
- j.location?.city,
164
- j.location?.countryCode,
165
- ...(j.skills || []).map((s) => s.name || s),
166
- ];
167
- return fields.some((f) => f && String(f).toLowerCase().includes(q));
168
- });
169
- }, [rawJobs, appliedQuery]);
133
+ const jobs = useMemo(
134
+ () => filterJobsByQuery(rawJobs, appliedQuery),
135
+ [rawJobs, appliedQuery]
136
+ );
170
137
 
171
138
  useEffect(() => {
172
139
  api
@@ -182,82 +149,6 @@ function App({ baseUrl, apiKey, apiClient }) {
182
149
  }
183
150
  }, [cursor, view, jobs]);
184
151
 
185
- // Inline search escape handler
186
- useInput(
187
- (input, key) => {
188
- if (key.escape) {
189
- setInlineSearch(false);
190
- setSearchQuery('');
191
- setAppliedQuery('');
192
- }
193
- },
194
- { isActive: inlineSearch }
195
- );
196
-
197
- // Main input handler
198
- useInput(
199
- (input, key) => {
200
- if (view === 'filters' || view === 'searches' || view === 'help') return;
201
- if (inlineSearch) return;
202
-
203
- if (input === 'q' && view === 'list') {
204
- if (ai.hasActiveProcess && !confirmExit) {
205
- showToast(
206
- 'Claude dossier still running โ€” press q again to quit',
207
- 'warning'
208
- );
209
- setConfirmExit(true);
210
- return;
211
- }
212
- ai.cancel();
213
- exit();
214
- }
215
- if (input !== 'q') setConfirmExit(false);
216
- if (input === 'q' && view === 'detail') setView('list');
217
- if (input === 'R' && (view === 'list' || view === 'detail')) {
218
- forceRefresh();
219
- showToast('Refreshingโ€ฆ', 'info');
220
- }
221
- if (input === 'f' && (view === 'list' || view === 'detail'))
222
- setView('filters');
223
- if (input === '/' && (view === 'list' || view === 'detail'))
224
- setView('searches');
225
- if (input === '?' && (view === 'list' || view === 'detail'))
226
- setView('help');
227
- if (input === 'n' && view === 'list') {
228
- setInlineSearch(true);
229
- setSearchQuery('');
230
- }
231
-
232
- // Enter toggles detail panel
233
- if (key.return && view === 'list' && jobs[cursor]) {
234
- setSelectedJob(jobs[cursor]);
235
- setView('detail');
236
- }
237
- if (key.escape && view === 'detail') setView('list');
238
- if (input === 'c' && view === 'detail' && selectedJob) {
239
- handleDossier(selectedJob);
240
- }
241
-
242
- if (key.escape && view === 'ai') {
243
- // Don't kill running dossier โ€” just hide the panel
244
- setView(selectedJob ? 'detail' : 'list');
245
- }
246
-
247
- if (key.tab && (view === 'list' || view === 'detail')) {
248
- const idx = TABS.indexOf(tab);
249
- setTab(TABS[(idx + 1) % TABS.length]);
250
- setCursor(0);
251
- }
252
- if (key.shift && key.tab && (view === 'list' || view === 'detail')) {
253
- const idx = TABS.indexOf(tab);
254
- setTab(TABS[(idx - 1 + TABS.length) % TABS.length]);
255
- setCursor(0);
256
- }
257
- },
258
- { isActive: view !== 'filters' && view !== 'searches' && view !== 'help' }
259
- );
260
-
261
152
  const handleSelect = (job) => {
262
153
  setSelectedJob(job);
263
154
  setView('detail');
@@ -313,28 +204,50 @@ function App({ baseUrl, apiKey, apiClient }) {
313
204
  setCursor(0);
314
205
  };
315
206
 
207
+ // Inline search escape handler
208
+ useInput(
209
+ (input, key) => {
210
+ if (key.escape) {
211
+ setInlineSearch(false);
212
+ setSearchQuery('');
213
+ setAppliedQuery('');
214
+ }
215
+ },
216
+ { isActive: inlineSearch }
217
+ );
218
+
219
+ // Main input handler
220
+ useInput(
221
+ createAppKeyHandler({
222
+ view,
223
+ tab,
224
+ jobs,
225
+ cursor,
226
+ selectedJob,
227
+ inlineSearch,
228
+ confirmExit,
229
+ ai,
230
+ exit,
231
+ forceRefresh,
232
+ showToast,
233
+ setView,
234
+ setTab,
235
+ setCursor,
236
+ setSelectedJob,
237
+ setInlineSearch,
238
+ setSearchQuery,
239
+ setConfirmExit,
240
+ handleDossier,
241
+ nextTab,
242
+ }),
243
+ { isActive: view !== 'filters' && view !== 'searches' && view !== 'help' }
244
+ );
245
+
316
246
  const activeSearch = activeSearchId
317
247
  ? searchesHook.searches.find((s) => s.id === activeSearchId)
318
248
  : null;
319
249
 
320
- const counts = {
321
- all: allJobs.length,
322
- new: allJobs.filter(
323
- (j) =>
324
- !j.state &&
325
- !j.has_dossier &&
326
- ai.getDossierStatus(j.id) !== 'done' &&
327
- ai.getDossierStatus(j.id) !== 'generating'
328
- ).length,
329
- reviewed: allJobs.filter(
330
- (j) => (j.has_dossier || ai.getDossierStatus(j.id) === 'done') && !j.state
331
- ).length,
332
- interested: allJobs.filter((j) => j.state === 'interested').length,
333
- applied: allJobs.filter((j) => j.state === 'applied').length,
334
- maybe: allJobs.filter((j) => j.state === 'maybe').length,
335
- passed: allJobs.filter((j) => j.state === 'not_interested').length,
336
- };
337
-
250
+ const counts = computeCounts(allJobs, ai.getDossierStatus);
338
251
  const toastEl = toast ? h(Toast, { toast }) : null;
339
252
 
340
253
  // โ”€โ”€ Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -360,130 +273,67 @@ function App({ baseUrl, apiKey, apiClient }) {
360
273
  toast: toastEl,
361
274
  });
362
275
 
276
+ // Shared left-pane list props for split-pane views.
277
+ const baseListProps = {
278
+ jobs,
279
+ cursor,
280
+ tab,
281
+ onCursorChange: setCursor,
282
+ onSelect: handleSelect,
283
+ onMark: handleMark,
284
+ onAISummary: handleAISummary,
285
+ onDossier: handleDossier,
286
+ getDossierStatus: ai.getDossierStatus,
287
+ };
288
+
363
289
  // Split-pane: compact list on left, detail on right
364
290
  if (view === 'detail' && selectedJob) {
365
- return h(
366
- Box,
367
- { flexDirection: 'column', height: process.stdout.rows || 40 },
291
+ return h(SplitPane, {
368
292
  header,
369
- h(
370
- Box,
371
- { flexGrow: 1, flexDirection: 'row' },
372
- // Left pane: compact job list
373
- h(
374
- Box,
375
- {
376
- flexDirection: 'column',
377
- width: '40%',
378
- borderStyle: 'single',
379
- borderColor: 'gray',
380
- borderRight: true,
381
- borderLeft: false,
382
- borderTop: false,
383
- borderBottom: false,
384
- },
385
- h(JobList, {
386
- jobs,
387
- cursor,
388
- tab,
389
- onCursorChange: setCursor,
390
- onSelect: handleSelect,
391
- onMark: handleMark,
392
- onAISummary: handleAISummary,
393
- onDossier: handleDossier,
394
- getDossierStatus: ai.getDossierStatus,
395
- isActive: true,
396
- compact: true,
397
- reservedRows: 8,
398
- })
399
- ),
400
- // Right pane: job detail
401
- h(
402
- Box,
403
- { flexDirection: 'column', width: '60%' },
404
- h(JobDetail, {
405
- job: selectedJob,
406
- api,
407
- onBack: handleBack,
408
- onMark: handleMark,
409
- onAISummary: handleAISummary,
410
- onDossier: handleDossier,
411
- getDossierStatus: ai.getDossierStatus,
412
- isActive: false,
413
- isPanel: true,
414
- })
415
- )
416
- ),
417
- statusBar
418
- );
293
+ statusBar,
294
+ listProps: { ...baseListProps, isActive: true },
295
+ right: h(JobDetail, {
296
+ job: selectedJob,
297
+ api,
298
+ onBack: handleBack,
299
+ onMark: handleMark,
300
+ onAISummary: handleAISummary,
301
+ onDossier: handleDossier,
302
+ getDossierStatus: ai.getDossierStatus,
303
+ isActive: false,
304
+ isPanel: true,
305
+ }),
306
+ });
419
307
  }
420
308
 
421
309
  // Split-pane: compact list on left, AI/dossier on right
422
310
  if (view === 'ai' && selectedJob) {
423
- return h(
424
- Box,
425
- { flexDirection: 'column', height: process.stdout.rows || 40 },
311
+ return h(SplitPane, {
426
312
  header,
427
- h(
428
- Box,
429
- { flexGrow: 1, flexDirection: 'row' },
430
- // Left pane: compact job list
431
- h(
432
- Box,
433
- {
434
- flexDirection: 'column',
435
- width: '40%',
436
- borderStyle: 'single',
437
- borderColor: 'gray',
438
- borderRight: true,
439
- borderLeft: false,
440
- borderTop: false,
441
- borderBottom: false,
442
- },
443
- h(JobList, {
444
- jobs,
445
- cursor,
446
- tab,
447
- onCursorChange: setCursor,
448
- onSelect: handleSelect,
449
- onMark: handleMark,
450
- onAISummary: handleAISummary,
451
- onDossier: handleDossier,
452
- getDossierStatus: ai.getDossierStatus,
453
- isActive: false,
454
- compact: true,
455
- reservedRows: 8,
456
- })
457
- ),
458
- // Right pane: AI/dossier panel
459
- h(
460
- Box,
461
- { flexDirection: 'column', width: '60%' },
462
- h(AIPanel, {
463
- text: ai.text,
464
- loading: ai.loading,
465
- error: ai.error,
466
- mode: ai.mode,
467
- job: selectedJob,
468
- onMark: handleMark,
469
- onDismiss: () => {
470
- setView(selectedJob ? 'detail' : 'list');
471
- },
472
- onExport: () => {
473
- const f = ai.exportDossier(selectedJob);
474
- if (f) showToast(`Saved ./${f}`, 'export');
475
- return f;
476
- },
477
- onRegenerate: (job) => {
478
- ai.regenerateDossier(job, api);
479
- showToast('Regenerating dossierโ€ฆ', 'info');
480
- },
481
- isActive: true,
482
- })
483
- )
484
- ),
485
- statusBar
486
- );
313
+ statusBar,
314
+ listProps: { ...baseListProps, isActive: false },
315
+ right: h(AIPanel, {
316
+ text: ai.text,
317
+ loading: ai.loading,
318
+ error: ai.error,
319
+ mode: ai.mode,
320
+ job: selectedJob,
321
+ onMark: handleMark,
322
+ onDismiss: () => {
323
+ setView(selectedJob ? 'detail' : 'list');
324
+ },
325
+ onExport: () => {
326
+ const f = ai.exportDossier(selectedJob);
327
+ if (f) showToast(`Saved ./${f}`, 'export');
328
+ return f;
329
+ },
330
+ onRegenerate: (job) => {
331
+ ai.regenerateDossier(job, api);
332
+ showToast('Regenerating dossierโ€ฆ', 'info');
333
+ },
334
+ isActive: true,
335
+ }),
336
+ });
487
337
  }
488
338
 
489
339
  // Full-width list view
@@ -493,14 +343,7 @@ function App({ baseUrl, apiKey, apiClient }) {
493
343
  header,
494
344
  view === 'list'
495
345
  ? h(JobList, {
496
- jobs,
497
- cursor,
498
- tab,
499
- onCursorChange: setCursor,
500
- onSelect: handleSelect,
501
- onMark: handleMark,
502
- onAISummary: handleAISummary,
503
- onDossier: handleDossier,
346
+ ...baseListProps,
504
347
  onAIBatch: handleAIBatch,
505
348
  onExport: handleExport,
506
349
  isActive: !inlineSearch,
@@ -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(
@@ -0,0 +1,58 @@
1
+ import { Box, Text } from 'ink';
2
+ import TextInput from 'ink-text-input';
3
+ import { h } from './h.js';
4
+ import JobList from './JobList.js';
5
+
6
+ // Inline find-as-you-type bar shown under the list view.
7
+ export function InlineSearch({ query, onChange, onSubmit }) {
8
+ return h(
9
+ Box,
10
+ { paddingX: 1, gap: 1 },
11
+ h(Text, { color: 'yellow', bold: true }, 'Find:'),
12
+ h(TextInput, { value: query, onChange, onSubmit }),
13
+ h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
14
+ );
15
+ }
16
+
17
+ // Compact job list used as the left pane of the split-pane views.
18
+ function leftPane(listProps) {
19
+ return h(
20
+ Box,
21
+ {
22
+ flexDirection: 'column',
23
+ width: '40%',
24
+ borderStyle: 'single',
25
+ borderColor: 'gray',
26
+ borderRight: true,
27
+ borderLeft: false,
28
+ borderTop: false,
29
+ borderBottom: false,
30
+ },
31
+ h(JobList, { ...listProps, compact: true, reservedRows: 8 })
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Two-column layout: compact job list on the left, an arbitrary right pane.
37
+ * Used by both the detail and AI/dossier views.
38
+ *
39
+ * @param {object} p
40
+ * @param {any} p.header rendered header element
41
+ * @param {any} p.statusBar rendered status bar element
42
+ * @param {object} p.listProps props forwarded to the left-pane JobList
43
+ * @param {any} p.right rendered right-pane element
44
+ */
45
+ export function SplitPane({ header, statusBar, listProps, right }) {
46
+ return h(
47
+ Box,
48
+ { flexDirection: 'column', height: process.stdout.rows || 40 },
49
+ header,
50
+ h(
51
+ Box,
52
+ { flexGrow: 1, flexDirection: 'row' },
53
+ leftPane(listProps),
54
+ h(Box, { flexDirection: 'column', width: '60%' }, right)
55
+ ),
56
+ statusBar
57
+ );
58
+ }