@jsonresume/jobs 0.7.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.
@@ -0,0 +1,108 @@
1
+ import { Box, Text } from 'ink';
2
+ import { h } from './h.js';
3
+
4
+ const KEYS = {
5
+ list: [
6
+ ['jk', 'nav'],
7
+ ['enter', 'open'],
8
+ ['i', '⭐'],
9
+ ['x', '📨'],
10
+ ['m', '?'],
11
+ ['p', '✗'],
12
+ ['v', 'select'],
13
+ ['space', 'AI'],
14
+ ['f', 'filter'],
15
+ ['/', 'search'],
16
+ ['e', 'export'],
17
+ ['?', 'help'],
18
+ ['q', 'quit'],
19
+ ],
20
+ detail: [
21
+ ['jk', 'nav'],
22
+ ['JK', 'scroll detail'],
23
+ ['i', '⭐'],
24
+ ['x', '📨'],
25
+ ['m', '?'],
26
+ ['p', '✗'],
27
+ ['o', 'open'],
28
+ ['space', 'AI'],
29
+ ['esc', 'back'],
30
+ ['q', 'close'],
31
+ ],
32
+ filters: [
33
+ ['jk', 'nav'],
34
+ ['enter', 'edit'],
35
+ ['a', 'add'],
36
+ ['d', 'delete'],
37
+ ['esc', 'close'],
38
+ ],
39
+ searches: [
40
+ ['jk', 'nav'],
41
+ ['enter', 'switch'],
42
+ ['n', 'new'],
43
+ ['d', 'delete'],
44
+ ['esc', 'close'],
45
+ ],
46
+ ai: [['esc', 'dismiss']],
47
+ help: [['?/esc', 'close']],
48
+ };
49
+
50
+ export default function StatusBar({
51
+ view,
52
+ jobCount,
53
+ totalCount,
54
+ loading,
55
+ reranking,
56
+ error,
57
+ aiEnabled,
58
+ searchName,
59
+ toast,
60
+ }) {
61
+ const keys = KEYS[view] || KEYS.list;
62
+
63
+ const keyElements = keys.map(([key, label], i) =>
64
+ h(
65
+ Box,
66
+ { key: i, marginRight: 1 },
67
+ h(Text, { bold: true, color: 'cyan' }, key),
68
+ h(Text, { dimColor: true }, `:${label}`)
69
+ )
70
+ );
71
+
72
+ return h(
73
+ Box,
74
+ {
75
+ flexDirection: 'column',
76
+ borderStyle: 'single',
77
+ borderColor: 'gray',
78
+ paddingX: 1,
79
+ marginTop: 0,
80
+ },
81
+ toast
82
+ ? h(
83
+ Box,
84
+ { justifyContent: 'space-between' },
85
+ toast,
86
+ h(
87
+ Box,
88
+ { gap: 1 },
89
+ h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`)
90
+ )
91
+ )
92
+ : h(
93
+ Box,
94
+ { justifyContent: 'space-between' },
95
+ h(Box, { flexWrap: 'wrap', gap: 0 }, ...keyElements),
96
+ h(
97
+ Box,
98
+ { gap: 1 },
99
+ loading ? h(Text, { color: 'yellow' }, '⏳') : null,
100
+ reranking ? h(Text, { color: 'magenta' }, '🧠 reranking…') : null,
101
+ h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`),
102
+ searchName ? h(Text, { color: 'magenta' }, '🔍') : null,
103
+ aiEnabled ? null : h(Text, { color: 'gray' }, '(no AI)')
104
+ )
105
+ ),
106
+ error ? h(Text, { color: 'red' }, `Error: ${error}`) : null
107
+ );
108
+ }
@@ -0,0 +1,56 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { h } from './h.js';
4
+
5
+ const ICONS = {
6
+ interested: '⭐',
7
+ applied: '📨',
8
+ maybe: '❓',
9
+ not_interested: '✗',
10
+ dismissed: '👁',
11
+ success: '✓',
12
+ error: '✗',
13
+ info: 'ℹ',
14
+ export: '📄',
15
+ };
16
+
17
+ const COLORS = {
18
+ interested: 'green',
19
+ applied: 'cyan',
20
+ maybe: 'yellow',
21
+ not_interested: 'red',
22
+ dismissed: 'gray',
23
+ success: 'green',
24
+ error: 'red',
25
+ info: 'blue',
26
+ export: 'green',
27
+ };
28
+
29
+ export function useToast(timeout = 2500) {
30
+ const [toast, setToast] = useState(null);
31
+
32
+ useEffect(() => {
33
+ if (!toast) return;
34
+ const timer = setTimeout(() => setToast(null), timeout);
35
+ return () => clearTimeout(timer);
36
+ }, [toast, timeout]);
37
+
38
+ const show = (message, type = 'info') => {
39
+ setToast({ message, type, time: Date.now() });
40
+ };
41
+
42
+ return { toast, show };
43
+ }
44
+
45
+ export default function Toast({ toast }) {
46
+ if (!toast) return null;
47
+
48
+ const icon = ICONS[toast.type] || ICONS.info;
49
+ const color = COLORS[toast.type] || 'blue';
50
+
51
+ return h(
52
+ Box,
53
+ { paddingX: 1 },
54
+ h(Text, { color, bold: true }, `${icon} ${toast.message}`)
55
+ );
56
+ }
package/src/tui/h.js ADDED
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export const h = React.createElement;
@@ -0,0 +1,107 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { generateText } from 'ai';
3
+ import { openai } from '@ai-sdk/openai';
4
+
5
+ export function useAI(resume) {
6
+ const [text, setText] = useState('');
7
+ const [loading, setLoading] = useState(false);
8
+ const [error, setError] = useState(null);
9
+ const hasKey = Boolean(process.env.OPENAI_API_KEY);
10
+
11
+ const summarizeJob = useCallback(
12
+ async (job) => {
13
+ if (!hasKey) {
14
+ setError('Set OPENAI_API_KEY to enable AI features');
15
+ return;
16
+ }
17
+ setLoading(true);
18
+ setError(null);
19
+ setText('');
20
+ try {
21
+ const resumeText = [
22
+ resume?.basics?.label,
23
+ resume?.basics?.summary,
24
+ ...(resume?.skills || []).map(
25
+ (s) => `${s.name}: ${(s.keywords || []).join(', ')}`
26
+ ),
27
+ ]
28
+ .filter(Boolean)
29
+ .join('\n');
30
+
31
+ const jobText = [
32
+ `Title: ${job.title}`,
33
+ `Company: ${job.company}`,
34
+ `Salary: ${job.salary || 'Not listed'}`,
35
+ `Remote: ${job.remote || 'Not specified'}`,
36
+ `Description: ${job.description || ''}`,
37
+ `Skills: ${(job.skills || []).map((s) => s.name).join(', ')}`,
38
+ ].join('\n');
39
+
40
+ const { text: result } = await generateText({
41
+ model: openai('gpt-4o-mini'),
42
+ system:
43
+ 'You are a career advisor. Given a job posting and resume, provide a concise fit analysis in 4-5 bullet points. Cover: why it fits, skill gaps, salary assessment, and remote compatibility. Be direct and opinionated.',
44
+ prompt: `Resume:\n${resumeText}\n\nJob:\n${jobText}`,
45
+ maxTokens: 400,
46
+ });
47
+ setText(result);
48
+ } catch (err) {
49
+ setError(err.message);
50
+ } finally {
51
+ setLoading(false);
52
+ }
53
+ },
54
+ [resume, hasKey]
55
+ );
56
+
57
+ const batchReview = useCallback(
58
+ async (jobs) => {
59
+ if (!hasKey) {
60
+ setError('Set OPENAI_API_KEY to enable AI features');
61
+ return;
62
+ }
63
+ setLoading(true);
64
+ setError(null);
65
+ setText('');
66
+ try {
67
+ const resumeText = [resume?.basics?.label, resume?.basics?.summary]
68
+ .filter(Boolean)
69
+ .join('\n');
70
+
71
+ const jobsList = jobs
72
+ .slice(0, 15)
73
+ .map(
74
+ (j, i) =>
75
+ `${i + 1}. [${j.similarity}] ${j.title} at ${j.company} | ${
76
+ j.salary || 'no salary'
77
+ } | ${j.remote || 'no remote info'}`
78
+ )
79
+ .join('\n');
80
+
81
+ const { text: result } = await generateText({
82
+ model: openai('gpt-4o-mini'),
83
+ system:
84
+ 'You are a career advisor. Rank these jobs by fit for the candidate. For each, give a 1-line verdict. Be direct.',
85
+ prompt: `Resume:\n${resumeText}\n\nJobs:\n${jobsList}`,
86
+ maxTokens: 600,
87
+ });
88
+ setText(result);
89
+ } catch (err) {
90
+ setError(err.message);
91
+ } finally {
92
+ setLoading(false);
93
+ }
94
+ },
95
+ [resume, hasKey]
96
+ );
97
+
98
+ return {
99
+ text,
100
+ loading,
101
+ error,
102
+ hasKey,
103
+ summarizeJob,
104
+ batchReview,
105
+ clear: () => setText(''),
106
+ };
107
+ }
@@ -0,0 +1,168 @@
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
2
+ import { getCached, setCache, updateCachedJob } from '../cache.js';
3
+
4
+ export function useJobs(api, activeFilters, tab, searchId) {
5
+ const [allJobs, setAllJobs] = useState([]);
6
+ const [loading, setLoading] = useState(true);
7
+ const [reranking, setReranking] = useState(false);
8
+ const [error, setError] = useState(null);
9
+ const rerankAbort = useRef(null);
10
+
11
+ // Build API params from active filters array
12
+ const params = useMemo(() => {
13
+ const p = { top: 100, days: 30 };
14
+ if (searchId) p.searchId = searchId;
15
+ for (const f of activeFilters || []) {
16
+ if (f.type === 'days') p.days = Number(f.value) || 30;
17
+ if (f.type === 'remote') p.remote = true;
18
+ if (f.type === 'minSalary') p.minSalary = Number(f.value) || 0;
19
+ if (f.type === 'search') p.search = f.value || '';
20
+ }
21
+ return p;
22
+ }, [activeFilters, searchId]);
23
+
24
+ const paramsKey = JSON.stringify(params);
25
+
26
+ const fetchJobs = useCallback(
27
+ async (force) => {
28
+ // Cancel any pending rerank
29
+ if (rerankAbort.current) {
30
+ rerankAbort.current.cancelled = true;
31
+ rerankAbort.current = null;
32
+ }
33
+
34
+ setLoading(true);
35
+ setReranking(false);
36
+ setError(null);
37
+
38
+ if (!force) {
39
+ // Check for reranked cache first, then plain cache
40
+ const cachedReranked = getCached({ ...params, rerank: true });
41
+ if (cachedReranked) {
42
+ setAllJobs(cachedReranked);
43
+ setLoading(false);
44
+ return;
45
+ }
46
+ const cached = getCached(params);
47
+ if (cached) {
48
+ setAllJobs(cached);
49
+ setLoading(false);
50
+ // Still kick off rerank in background if eligible
51
+ if (searchId) {
52
+ startRerank(cached);
53
+ }
54
+ return;
55
+ }
56
+ }
57
+
58
+ try {
59
+ // Pass 1: fast fetch without reranking
60
+ const { jobs: data } = await api.fetchJobs({
61
+ ...params,
62
+ rerank: false,
63
+ });
64
+ const result = data || [];
65
+ setCache(params, result);
66
+ setAllJobs(result);
67
+ setLoading(false);
68
+
69
+ // Pass 2: rerank in background if using a custom search
70
+ if (searchId && result.length > 0) {
71
+ startRerank(result);
72
+ }
73
+ } catch (err) {
74
+ setError(err.message);
75
+ setLoading(false);
76
+ }
77
+ },
78
+ [api, paramsKey]
79
+ );
80
+
81
+ function startRerank(currentJobs) {
82
+ const token = { cancelled: false };
83
+ rerankAbort.current = token;
84
+ setReranking(true);
85
+
86
+ api
87
+ .fetchJobs({ ...params, rerank: true })
88
+ .then(({ jobs: reranked }) => {
89
+ if (token.cancelled) return;
90
+ const result = reranked || [];
91
+ setCache({ ...params, rerank: true }, result);
92
+ // Merge rerank data into existing jobs (preserve any local state changes)
93
+ setAllJobs((prev) => {
94
+ const stateMap = {};
95
+ prev.forEach((j) => {
96
+ if (j.state) stateMap[j.id] = j.state;
97
+ });
98
+ return result.map((j) => ({
99
+ ...j,
100
+ state: stateMap[j.id] || j.state,
101
+ }));
102
+ });
103
+ })
104
+ .catch(() => {})
105
+ .finally(() => {
106
+ if (!token.cancelled) setReranking(false);
107
+ });
108
+ }
109
+
110
+ useEffect(() => {
111
+ fetchJobs(false);
112
+ }, [fetchJobs]);
113
+
114
+ // Filter by tab (job state) + client-side filters
115
+ const jobs = useMemo(() => {
116
+ let filtered = allJobs;
117
+
118
+ for (const f of activeFilters || []) {
119
+ if (f.type === 'remote') {
120
+ filtered = filtered.filter(
121
+ (j) => j.remote === 'Full' || /remote/i.test(j.location || '')
122
+ );
123
+ }
124
+ if (f.type === 'minSalary' && f.value) {
125
+ filtered = filtered.filter(
126
+ (j) => !j.salary_usd || j.salary_usd >= Number(f.value) * 1000
127
+ );
128
+ }
129
+ if (f.type === 'search' && f.value) {
130
+ const q = f.value.toLowerCase();
131
+ filtered = filtered.filter((j) =>
132
+ JSON.stringify(j).toLowerCase().includes(q)
133
+ );
134
+ }
135
+ }
136
+
137
+ if (tab === 'interested')
138
+ return filtered.filter((j) => j.state === 'interested');
139
+ if (tab === 'applied') return filtered.filter((j) => j.state === 'applied');
140
+ if (tab === 'maybe') return filtered.filter((j) => j.state === 'maybe');
141
+ if (tab === 'passed')
142
+ return filtered.filter((j) => j.state === 'not_interested');
143
+ // "All" tab: hide passed and dismissed jobs
144
+ return filtered.filter(
145
+ (j) => j.state !== 'not_interested' && j.state !== 'dismissed'
146
+ );
147
+ }, [allJobs, activeFilters, tab]);
148
+
149
+ const markJob = useCallback(
150
+ async (id, state, feedback) => {
151
+ setAllJobs((prev) =>
152
+ prev.map((j) => (j.id === id ? { ...j, state } : j))
153
+ );
154
+ updateCachedJob(params, id, { state });
155
+ updateCachedJob({ ...params, rerank: true }, id, { state });
156
+ try {
157
+ await api.markJob(id, state, feedback);
158
+ } catch {
159
+ fetchJobs(false);
160
+ }
161
+ },
162
+ [api, fetchJobs, paramsKey]
163
+ );
164
+
165
+ const forceRefresh = useCallback(() => fetchJobs(true), [fetchJobs]);
166
+
167
+ return { jobs, allJobs, loading, reranking, error, markJob, forceRefresh };
168
+ }
@@ -0,0 +1,56 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ export function useSearches(api) {
4
+ const [searches, setSearches] = useState([]);
5
+ const [loading, setLoading] = useState(false);
6
+
7
+ const fetch = useCallback(async () => {
8
+ setLoading(true);
9
+ try {
10
+ const { searches: data } = await api.listSearches();
11
+ setSearches(data || []);
12
+ } catch {
13
+ // Silently fail — searches are optional
14
+ } finally {
15
+ setLoading(false);
16
+ }
17
+ }, [api]);
18
+
19
+ useEffect(() => {
20
+ fetch();
21
+ }, [fetch]);
22
+
23
+ const create = useCallback(
24
+ async (name, prompt) => {
25
+ try {
26
+ const { search } = await api.createSearch(name, prompt);
27
+ setSearches((prev) => [search, ...prev]);
28
+ return search;
29
+ } catch (err) {
30
+ throw err;
31
+ }
32
+ },
33
+ [api]
34
+ );
35
+
36
+ const remove = useCallback(
37
+ async (id) => {
38
+ try {
39
+ await api.deleteSearch(id);
40
+ setSearches((prev) => prev.filter((s) => s.id !== id));
41
+ } catch {}
42
+ },
43
+ [api]
44
+ );
45
+
46
+ const updateFilters = useCallback(
47
+ async (id, filters) => {
48
+ try {
49
+ await api.updateSearch(id, { filters });
50
+ } catch {}
51
+ },
52
+ [api]
53
+ );
54
+
55
+ return { searches, loading, create, remove, updateFilters, refetch: fetch };
56
+ }