@jsonresume/jobs 0.14.1 → 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/formatters.js +54 -2
- package/src/formatters.test.js +107 -0
- package/src/tui/App.js +108 -265
- 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 +35 -302
- 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/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
|
}
|
|
@@ -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
|
+
});
|
package/src/tui/App.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
-
import { render, Box,
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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,
|