careermate 0.0.8 → 0.0.10
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/AGENTS.md +2 -2
- package/LICENSE +1 -1
- package/README.md +78 -81
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +10 -4
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +4 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/doctor.mjs +18138 -6866
- package/dist/mcp.mjs +14820 -4124
- package/dist/migrate.mjs +14436 -3712
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +10 -19
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +6 -0
- package/dist/public/i18n/en/documents.js +1 -0
- package/dist/public/i18n/en/home.js +8 -1
- package/dist/public/i18n/en/jobs.js +19 -0
- package/dist/public/i18n/en/nav.js +1 -0
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +5 -0
- package/dist/public/i18n/ko/documents.js +1 -0
- package/dist/public/i18n/ko/home.js +8 -1
- package/dist/public/i18n/ko/jobs.js +19 -0
- package/dist/public/i18n/ko/nav.js +1 -0
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +29 -0
- package/dist/public/pages/applications.js +86 -10
- package/dist/public/pages/documents.js +25 -12
- package/dist/public/pages/home.js +24 -4
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +92 -4
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +16 -0
- package/dist/web.mjs +17079 -5651
- package/docs/FAQ.md +13 -3
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/MCP_TOOLS.md +22 -9
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/docs/career-os/eop/cover-letter.md +10 -4
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +4 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/package.json +9 -5
- package/packages/core/src/data-health.ts +161 -0
- package/packages/core/src/fit-explain.ts +63 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/jobsearch.ts +99 -54
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/core/src/services.ts +5 -1
- package/packages/core/src/summary.ts +245 -19
- package/packages/db/src/connection.ts +102 -14
- package/packages/db/src/index.ts +3 -0
- package/packages/db/src/interview-retrospectives.ts +149 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/db/src/repositories.ts +140 -8
- package/packages/db/src/schema.ts +140 -8
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +143 -20
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/prompts/src/system.ts +21 -64
- package/packages/shared/src/schemas.ts +134 -0
- package/scripts/init.ts +2 -0
- package/tsconfig.json +9 -10
package/dist/public/app.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { el, icon, get, navigate, toastError, Spinner, clear } from '/lib.js';
|
|
7
7
|
import { t, onLangChange } from '/i18n.js';
|
|
8
8
|
|
|
9
|
-
// 라벨은 t('nav.<id>')로 해석 — 로케일 전환 시 재렌더로 갱신된다.
|
|
10
9
|
const ROUTES = [
|
|
11
10
|
{ id: 'home', icon: 'home', module: '/pages/home.js' },
|
|
12
11
|
{ id: 'profile', icon: 'user', module: '/pages/profile.js' },
|
|
@@ -14,6 +13,7 @@ const ROUTES = [
|
|
|
14
13
|
{ id: 'applications', icon: 'layers', module: '/pages/applications.js', countKey: 'active_applications' },
|
|
15
14
|
{ id: 'documents', icon: 'file', module: '/pages/documents.js', countKey: 'cover_letters' },
|
|
16
15
|
{ id: 'interview', icon: 'mic', module: '/pages/interview.js', countKey: 'interview_pending' },
|
|
16
|
+
{ id: 'insights', icon: 'target', module: '/pages/insights.js' },
|
|
17
17
|
{ id: 'settings', icon: 'settings', module: '/pages/settings.js' },
|
|
18
18
|
];
|
|
19
19
|
|
|
@@ -24,6 +24,10 @@ const actionsEl = document.getElementById('topbar-actions');
|
|
|
24
24
|
const routeStatusEl = document.getElementById('route-status');
|
|
25
25
|
const moduleCache = new Map();
|
|
26
26
|
|
|
27
|
+
function removePrivateRepositoryLinks(root = document) {
|
|
28
|
+
root.querySelectorAll?.('a[href*="github.com/osntak/careermate"]').forEach((link) => link.remove());
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
function parseHash() {
|
|
28
32
|
const raw = location.hash.replace(/^#\/?/, '');
|
|
29
33
|
const segments = raw.split('/').filter(Boolean);
|
|
@@ -35,7 +39,7 @@ function renderNav(counts = {}) {
|
|
|
35
39
|
navEl.append(el('div', { class: 'nav__label' }, t('nav.section.workspace')));
|
|
36
40
|
const { page } = parseHash();
|
|
37
41
|
for (const r of ROUTES) {
|
|
38
|
-
if (r.id === '
|
|
42
|
+
if (r.id === 'insights') navEl.append(el('div', { class: 'nav__label' }, t('nav.section.system')));
|
|
39
43
|
const count = r.countKey ? counts[r.countKey] : null;
|
|
40
44
|
navEl.append(el('a', {
|
|
41
45
|
class: `nav__item${page === r.id ? ' is-active' : ''}`,
|
|
@@ -67,7 +71,6 @@ async function route() {
|
|
|
67
71
|
const { page, params } = parseHash();
|
|
68
72
|
const def = ROUTES.find((r) => r.id === page) || ROUTES[0];
|
|
69
73
|
|
|
70
|
-
// active nav state
|
|
71
74
|
navEl.querySelectorAll('.nav__item').forEach((n) => n.classList.toggle('is-active', n.getAttribute('href') === `#/${def.id}`));
|
|
72
75
|
const pageTitle = t('nav.' + def.id);
|
|
73
76
|
titleEl.textContent = pageTitle;
|
|
@@ -89,11 +92,10 @@ async function route() {
|
|
|
89
92
|
view.append(Spinner());
|
|
90
93
|
try {
|
|
91
94
|
const mod = await loadModule(def.module);
|
|
92
|
-
if (token !== renderToken) return;
|
|
95
|
+
if (token !== renderToken) return;
|
|
93
96
|
clear(view);
|
|
94
97
|
await mod.render(ctx);
|
|
95
|
-
|
|
96
|
-
// detail-page ctx.setTitle override).
|
|
98
|
+
removePrivateRepositoryLinks(view);
|
|
97
99
|
if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || t('nav.' + def.id);
|
|
98
100
|
} catch (err) {
|
|
99
101
|
if (token !== renderToken) return;
|
|
@@ -106,9 +108,6 @@ async function route() {
|
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
// Mobile/narrow-window nav: the sidebar is the only navigation, so below 900px it
|
|
110
|
-
// becomes an off-canvas drawer toggled from the topbar. Without this a phone/narrow
|
|
111
|
-
// window is stranded on whatever page loaded first.
|
|
112
111
|
function setupMobileNav() {
|
|
113
112
|
const toggle = document.getElementById('nav-toggle');
|
|
114
113
|
const scrim = document.getElementById('nav-scrim');
|
|
@@ -126,14 +125,10 @@ function setupMobileNav() {
|
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
function initTheme() {
|
|
129
|
-
// Read new key, falling back to the legacy 'cf-theme' so existing users keep their choice.
|
|
130
128
|
const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
|
|
131
129
|
if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
// Static shell strings live in index.html (not page-rendered), so set them from the
|
|
135
|
-
// catalog at boot + on locale change. foot-status belongs to the demo shim when the
|
|
136
|
-
// demo is running, so skip it there.
|
|
137
132
|
function localizeShell() {
|
|
138
133
|
const sub = document.querySelector('.sidebar__subtitle');
|
|
139
134
|
if (sub) sub.textContent = t('nav.subtitle');
|
|
@@ -141,36 +136,32 @@ function localizeShell() {
|
|
|
141
136
|
if (skip) skip.textContent = t('nav.skip');
|
|
142
137
|
const foot = document.getElementById('foot-status');
|
|
143
138
|
if (foot && !window.__CAREERMATE_DEMO__) foot.textContent = t('nav.footStatus');
|
|
144
|
-
// FAQ help link points at the marketing-site FAQ in the active locale.
|
|
145
139
|
const faq = document.getElementById('foot-faq');
|
|
146
140
|
if (faq) {
|
|
147
141
|
const en = document.documentElement.lang === 'en';
|
|
148
142
|
faq.href = en ? 'https://careermate.life/en/faq' : 'https://careermate.life/faq';
|
|
149
143
|
faq.title = en ? 'FAQ' : '자주 묻는 질문';
|
|
150
144
|
}
|
|
145
|
+
removePrivateRepositoryLinks();
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
async function boot() {
|
|
154
149
|
initTheme();
|
|
155
150
|
setupMobileNav();
|
|
156
151
|
localizeShell();
|
|
157
|
-
// Skip link: jump keyboard focus past the sidebar to the page content. A hash
|
|
158
|
-
// anchor would collide with the router, so move focus directly instead.
|
|
159
152
|
document.getElementById('skip-link')?.addEventListener('click', () => {
|
|
160
153
|
view.setAttribute('tabindex', '-1');
|
|
161
154
|
view.focus();
|
|
162
155
|
});
|
|
163
|
-
// Show data location in the footer (privacy: user can always see where data lives).
|
|
164
156
|
try {
|
|
165
157
|
const h = await get('/api/health');
|
|
166
158
|
document.getElementById('foot-path').textContent = h.data_dir;
|
|
167
159
|
document.getElementById('foot-path').title = h.data_dir;
|
|
168
160
|
} catch { /* ignore */ }
|
|
169
161
|
await refreshNav();
|
|
170
|
-
// 로케일 전환 시 내비게이션과 현재 라우트를 다시 그린다(전체 리로드 없이).
|
|
171
162
|
onLangChange(() => { localizeShell(); refreshNav(); route(); });
|
|
172
163
|
window.addEventListener('hashchange', route);
|
|
173
164
|
await route();
|
|
174
165
|
}
|
|
175
166
|
|
|
176
|
-
boot();
|
|
167
|
+
boot();
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// Applications page.
|
|
2
2
|
export default {
|
|
3
|
+
// offers comparison
|
|
4
|
+
'applications.offers.title': 'Offer comparison',
|
|
5
|
+
'applications.offers.est': 'Est. annual cash',
|
|
6
|
+
'applications.offers.unit': '만원',
|
|
7
|
+
'applications.offers.noScore': 'No score',
|
|
8
|
+
'applications.offers.acceptBy': 'Accept by {{date}}',
|
|
9
|
+
'applications.offers.expired': 'Deadline passed',
|
|
10
|
+
'applications.offers.past': 'Past offers · {{count}} (accepted/declined)',
|
|
11
|
+
'applications.offers.status.accepted': 'Accepted',
|
|
12
|
+
'applications.offers.status.declined': 'Declined',
|
|
13
|
+
|
|
3
14
|
// deadline D-day chip
|
|
4
15
|
'applications.dday.future': 'Due in {{n}}d',
|
|
5
16
|
'applications.dday.today': 'Due today',
|
|
@@ -16,6 +27,14 @@ export default {
|
|
|
16
27
|
// app row
|
|
17
28
|
'applications.row.score': '{{score}} pts',
|
|
18
29
|
|
|
30
|
+
// Rejection retrospective — which stage the rejection happened at (rejected rows only)
|
|
31
|
+
'applications.rejectedStage.label': 'Rejected at:',
|
|
32
|
+
'applications.rejectedStage.document': 'Screening',
|
|
33
|
+
'applications.rejectedStage.assessment': 'Assessment/AI',
|
|
34
|
+
'applications.rejectedStage.interview': 'Interview',
|
|
35
|
+
'applications.rejectedStage.final': 'Final/offer',
|
|
36
|
+
'applications.rejectedStage.other': 'Other',
|
|
37
|
+
|
|
19
38
|
// status select + toasts
|
|
20
39
|
'applications.statusSelect.title': 'Change status',
|
|
21
40
|
'applications.toast.statusChanged': 'Application status updated.',
|
|
@@ -14,6 +14,12 @@ export default {
|
|
|
14
14
|
'common.fmtHtml': 'HTML',
|
|
15
15
|
'common.fmtMarkdown': 'Markdown',
|
|
16
16
|
|
|
17
|
+
// Hiring-schedule stage D-day badge (shared by Home action lane + job detail).
|
|
18
|
+
// Separate from the deadline D-day labels (applications.dday.*).
|
|
19
|
+
'schedule.dday.today': 'D-day',
|
|
20
|
+
'schedule.dday.future': 'D-{{n}}',
|
|
21
|
+
'schedule.dday.past': 'Past',
|
|
22
|
+
|
|
17
23
|
'toast.copied': 'Copied to clipboard.',
|
|
18
24
|
'toast.copyFailed': "Couldn't copy. Please select and copy it manually.",
|
|
19
25
|
'toast.errorTitle': 'Error',
|
|
@@ -28,6 +28,7 @@ export default {
|
|
|
28
28
|
'documents.cover.primary': 'Primary',
|
|
29
29
|
'documents.cover.open': 'Open',
|
|
30
30
|
'documents.cover.copy': 'Copy',
|
|
31
|
+
'documents.cover.more': 'More',
|
|
31
32
|
'documents.cover.setPrimary': 'Set as primary',
|
|
32
33
|
'documents.cover.delete': 'Delete',
|
|
33
34
|
'documents.cover.noContent': 'No content yet.',
|
|
@@ -47,6 +47,9 @@ export default {
|
|
|
47
47
|
'home.pipeline.title': 'Application pipeline',
|
|
48
48
|
'home.pipeline.openBoard': 'Open board',
|
|
49
49
|
'home.pipeline.empty': 'No application stages yet. Set an application status on a posting and your pipeline fills in here.',
|
|
50
|
+
// small-sample fallback (< 3 applications): show the current stage as one line
|
|
51
|
+
'home.pipeline.small.item': '{{status}} · {{count}}',
|
|
52
|
+
'home.pipeline.small': '{{detail}} — your stage flow shows here as applications add up',
|
|
50
53
|
'home.pipeline.stage.applied': 'Applied',
|
|
51
54
|
'home.pipeline.stage.documentPassed': 'Screening passed',
|
|
52
55
|
'home.pipeline.stage.interview': 'Interview',
|
|
@@ -62,7 +65,11 @@ export default {
|
|
|
62
65
|
'home.actionLane.title': 'What to do next',
|
|
63
66
|
'home.actionLane.viewAll': 'View all',
|
|
64
67
|
'home.actionLane.interviewPrep': 'Interview prep',
|
|
65
|
-
'home.actionLane.
|
|
68
|
+
'home.actionLane.followupBadge': 'Follow up',
|
|
69
|
+
// per-type reason / next-action line
|
|
70
|
+
'home.actionLane.reason.interview': 'Time to start interview prep',
|
|
71
|
+
'home.actionLane.reason.deadline': 'Application deadline',
|
|
72
|
+
'home.actionLane.reason.followup': 'Applied {{n}}d ago · time to follow up',
|
|
66
73
|
'home.actionLane.empty': 'Nothing to handle right now. Try analyzing a new posting.',
|
|
67
74
|
'home.placeholder.dash': '—',
|
|
68
75
|
|
|
@@ -68,11 +68,18 @@ export default {
|
|
|
68
68
|
'jobs.detail.deleteMessage': "Delete the '{{company}} · {{position}}' posting? Its linked application records will be removed too.",
|
|
69
69
|
'jobs.detail.deleteConfirm': 'Delete',
|
|
70
70
|
'jobs.detail.deleted': 'Posting deleted.',
|
|
71
|
+
'jobs.detail.dangerZone': 'Danger zone',
|
|
72
|
+
'jobs.detail.dangerZoneHint': 'Deletes this posting along with its fit analysis, cover letters, interview prep, and application records. This cannot be undone.',
|
|
71
73
|
'jobs.detail.list': 'Postings',
|
|
72
74
|
'jobs.detail.statusLabel': 'Status',
|
|
73
75
|
'jobs.detail.openPosting': 'Original posting',
|
|
74
76
|
|
|
75
77
|
// ---- posting card ----
|
|
78
|
+
'jobs.company.title': 'Company research',
|
|
79
|
+
'jobs.company.overview': 'Overview',
|
|
80
|
+
'jobs.company.talent': 'Ideal candidate',
|
|
81
|
+
'jobs.company.values': 'Core values',
|
|
82
|
+
'jobs.company.reputation': 'Reputation',
|
|
76
83
|
'jobs.posting.title': 'Posting details',
|
|
77
84
|
'jobs.posting.location': 'Location',
|
|
78
85
|
'jobs.posting.employment': 'Employment type',
|
|
@@ -85,6 +92,18 @@ export default {
|
|
|
85
92
|
'jobs.posting.requirements': 'Requirements · Preferred',
|
|
86
93
|
'jobs.posting.rawText': 'Original posting',
|
|
87
94
|
|
|
95
|
+
// ---- cover letter questions (문항형 자소서) ----
|
|
96
|
+
'jobs.questions.title': 'Cover letter questions',
|
|
97
|
+
'jobs.questions.min': 'min {{n}}{{unit}}',
|
|
98
|
+
'jobs.questions.max': 'max {{n}}{{unit}}',
|
|
99
|
+
'jobs.questions.unitChar': ' chars',
|
|
100
|
+
'jobs.questions.unitByte': ' bytes',
|
|
101
|
+
'jobs.questions.modeWithSpace': ' (incl. spaces)',
|
|
102
|
+
'jobs.questions.modeNoSpace': ' (excl. spaces)',
|
|
103
|
+
|
|
104
|
+
// ---- hiring schedule (전형 일정) ---- (D-day badge uses shared schedule.dday.*)
|
|
105
|
+
'jobs.schedule.title': 'Hiring schedule',
|
|
106
|
+
|
|
88
107
|
// ---- fit card ----
|
|
89
108
|
'jobs.fit.title': 'Fit analysis',
|
|
90
109
|
'jobs.fit.emptyBody': 'Not analyzed yet. The comparison between this posting and your profile will appear here.',
|
|
@@ -6,6 +6,7 @@ export default {
|
|
|
6
6
|
'nav.applications': 'Applications',
|
|
7
7
|
'nav.documents': 'Documents',
|
|
8
8
|
'nav.interview': 'Interview prep',
|
|
9
|
+
'nav.insights': 'Diagnostics & insights',
|
|
9
10
|
'nav.settings': 'Settings',
|
|
10
11
|
'nav.section.workspace': 'Workspace',
|
|
11
12
|
'nav.section.system': 'System',
|
|
@@ -105,18 +105,21 @@ export default {
|
|
|
105
105
|
'settings.import.confirm': 'Import',
|
|
106
106
|
'settings.import.doneToast': 'Data imported.',
|
|
107
107
|
|
|
108
|
+
// language
|
|
109
|
+
'settings.lang.sub': 'Dashboard display language',
|
|
110
|
+
|
|
108
111
|
// danger zone
|
|
109
112
|
'settings.reset.gateWord': 'RESET',
|
|
110
113
|
'settings.reset.title': 'Reset all data',
|
|
111
114
|
'settings.reset.calloutTitle': "This can't be undone",
|
|
112
|
-
'settings.reset.calloutBody': 'All data, including your profile, postings, applications, documents, and interview prep, will be permanently deleted. For safety, a backup is created automatically right before the reset.',
|
|
115
|
+
'settings.reset.calloutBody': 'All data, including your profile, postings, applications, documents, and interview prep, will be permanently deleted. For safety, a backup is created automatically right before the reset — that backup file remains in the backups folder inside your data directory, so delete it too if you want no trace left.',
|
|
113
116
|
'settings.reset.instruction': 'To continue, type {{word}} in the field below.',
|
|
114
117
|
'settings.reset.confirm': 'Reset all data',
|
|
115
118
|
'settings.reset.doneToast': 'All data has been reset.',
|
|
116
119
|
'settings.danger.title': 'Danger zone',
|
|
117
120
|
'settings.danger.sub': "This action can't be undone",
|
|
118
121
|
'settings.danger.resetTitle': 'Reset all data',
|
|
119
|
-
'settings.danger.resetBody': 'Deletes all CareerMate data stored on this computer.
|
|
122
|
+
'settings.danger.resetBody': 'Deletes all CareerMate data stored on this computer. An automatic backup is created before the reset and remains in the backups folder.',
|
|
120
123
|
|
|
121
124
|
// help / report a problem
|
|
122
125
|
'settings.support.title': 'Help & report a problem',
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// 지원 현황 페이지.
|
|
2
2
|
export default {
|
|
3
|
+
// offers comparison
|
|
4
|
+
'applications.offers.title': '오퍼 비교',
|
|
5
|
+
'applications.offers.est': '추정 연 현금성',
|
|
6
|
+
'applications.offers.unit': '만원',
|
|
7
|
+
'applications.offers.noScore': '점수 미정',
|
|
8
|
+
'applications.offers.acceptBy': '수락 마감 {{date}}',
|
|
9
|
+
'applications.offers.expired': '수락 마감 지남',
|
|
10
|
+
'applications.offers.past': '지난 오퍼 {{count}}건 (수락·거절)',
|
|
11
|
+
'applications.offers.status.accepted': '수락',
|
|
12
|
+
'applications.offers.status.declined': '거절',
|
|
13
|
+
|
|
3
14
|
// deadline D-day chip
|
|
4
15
|
'applications.dday.future': '마감 D-{{n}}',
|
|
5
16
|
'applications.dday.today': '마감 D-day',
|
|
@@ -16,6 +27,14 @@ export default {
|
|
|
16
27
|
// app row
|
|
17
28
|
'applications.row.score': '{{score}}점',
|
|
18
29
|
|
|
30
|
+
// 탈락 회고 — 어느 단계에서 떨어졌는지 병기(불합격 행에만 표시)
|
|
31
|
+
'applications.rejectedStage.label': '탈락 단계:',
|
|
32
|
+
'applications.rejectedStage.document': '서류',
|
|
33
|
+
'applications.rejectedStage.assessment': '인적성·AI역검',
|
|
34
|
+
'applications.rejectedStage.interview': '면접',
|
|
35
|
+
'applications.rejectedStage.final': '최종·처우',
|
|
36
|
+
'applications.rejectedStage.other': '기타',
|
|
37
|
+
|
|
19
38
|
// status select + toasts
|
|
20
39
|
'applications.statusSelect.title': '상태 변경',
|
|
21
40
|
'applications.toast.statusChanged': '지원 상태를 변경했어요.',
|
|
@@ -14,6 +14,11 @@ export default {
|
|
|
14
14
|
'common.fmtHtml': 'HTML',
|
|
15
15
|
'common.fmtMarkdown': 'Markdown',
|
|
16
16
|
|
|
17
|
+
// 전형 일정 D-day 배지(홈 액션레인 + 공고 상세 공유). 마감 D-day(applications.dday.*)와는 별개.
|
|
18
|
+
'schedule.dday.today': 'D-day',
|
|
19
|
+
'schedule.dday.future': 'D-{{n}}',
|
|
20
|
+
'schedule.dday.past': '지남',
|
|
21
|
+
|
|
17
22
|
'toast.copied': '클립보드에 복사했습니다.',
|
|
18
23
|
'toast.copyFailed': '복사에 실패했습니다. 직접 선택해 복사해 주세요.',
|
|
19
24
|
'toast.errorTitle': '오류',
|
|
@@ -28,6 +28,7 @@ export default {
|
|
|
28
28
|
'documents.cover.primary': '대표',
|
|
29
29
|
'documents.cover.open': '열기',
|
|
30
30
|
'documents.cover.copy': '복사',
|
|
31
|
+
'documents.cover.more': '더보기',
|
|
31
32
|
'documents.cover.setPrimary': '대표 지정',
|
|
32
33
|
'documents.cover.delete': '삭제',
|
|
33
34
|
'documents.cover.noContent': '아직 내용이 없습니다.',
|
|
@@ -38,6 +38,9 @@ export default {
|
|
|
38
38
|
'home.pipeline.title': '지원 파이프라인',
|
|
39
39
|
'home.pipeline.openBoard': '보드 열기',
|
|
40
40
|
'home.pipeline.empty': '아직 지원 단계가 없어요. 공고에 지원 상태를 표시하면 여기에 파이프라인이 채워져요.',
|
|
41
|
+
// small-sample fallback (< 3 applications): show the current stage as one line
|
|
42
|
+
'home.pipeline.small.item': '{{status}} {{count}}건',
|
|
43
|
+
'home.pipeline.small': '{{detail}} — 지원이 쌓이면 단계별 흐름이 여기 보여요',
|
|
41
44
|
'home.pipeline.stage.applied': '지원',
|
|
42
45
|
'home.pipeline.stage.documentPassed': '서류 합격',
|
|
43
46
|
'home.pipeline.stage.interview': '면접',
|
|
@@ -50,7 +53,11 @@ export default {
|
|
|
50
53
|
'home.actionLane.title': '이번에 할 일',
|
|
51
54
|
'home.actionLane.viewAll': '전체 보기',
|
|
52
55
|
'home.actionLane.interviewPrep': '면접 준비',
|
|
53
|
-
'home.actionLane.
|
|
56
|
+
'home.actionLane.followupBadge': '팔로업',
|
|
57
|
+
// per-type reason / next-action line
|
|
58
|
+
'home.actionLane.reason.interview': '면접 준비를 시작할 차례',
|
|
59
|
+
'home.actionLane.reason.deadline': '지원 마감',
|
|
60
|
+
'home.actionLane.reason.followup': '지원 {{n}}일 — 팔로업 적기',
|
|
54
61
|
'home.actionLane.empty': '지금 처리할 일이 없어요. 새 공고를 분석해 보세요.',
|
|
55
62
|
'home.placeholder.dash': '—',
|
|
56
63
|
|
|
@@ -68,11 +68,18 @@ export default {
|
|
|
68
68
|
'jobs.detail.deleteMessage': "'{{company}} · {{position}}' 공고를 삭제할까요? 연결된 지원 기록도 함께 사라집니다.",
|
|
69
69
|
'jobs.detail.deleteConfirm': '삭제',
|
|
70
70
|
'jobs.detail.deleted': '공고를 삭제했어요.',
|
|
71
|
+
'jobs.detail.dangerZone': '위험 구역',
|
|
72
|
+
'jobs.detail.dangerZoneHint': '이 공고와 연결된 적합도 분석·자기소개서·면접 자료·지원 기록이 함께 삭제됩니다. 되돌릴 수 없어요.',
|
|
71
73
|
'jobs.detail.list': '목록',
|
|
72
74
|
'jobs.detail.statusLabel': '상태',
|
|
73
75
|
'jobs.detail.openPosting': '공고 원문',
|
|
74
76
|
|
|
75
77
|
// ---- posting card ----
|
|
78
|
+
'jobs.company.title': '회사 리서치',
|
|
79
|
+
'jobs.company.overview': '사업 개요',
|
|
80
|
+
'jobs.company.talent': '인재상',
|
|
81
|
+
'jobs.company.values': '핵심가치',
|
|
82
|
+
'jobs.company.reputation': '평판',
|
|
76
83
|
'jobs.posting.title': '공고 정보',
|
|
77
84
|
'jobs.posting.location': '위치',
|
|
78
85
|
'jobs.posting.employment': '고용형태',
|
|
@@ -85,6 +92,18 @@ export default {
|
|
|
85
92
|
'jobs.posting.requirements': '자격요건 · 우대사항',
|
|
86
93
|
'jobs.posting.rawText': '공고 원문',
|
|
87
94
|
|
|
95
|
+
// ---- cover letter questions (문항형 자소서) ----
|
|
96
|
+
'jobs.questions.title': '자소서 문항',
|
|
97
|
+
'jobs.questions.min': '최소 {{n}}{{unit}}',
|
|
98
|
+
'jobs.questions.max': '최대 {{n}}{{unit}}',
|
|
99
|
+
'jobs.questions.unitChar': '자',
|
|
100
|
+
'jobs.questions.unitByte': 'byte',
|
|
101
|
+
'jobs.questions.modeWithSpace': '(공백포함)',
|
|
102
|
+
'jobs.questions.modeNoSpace': '(공백제외)',
|
|
103
|
+
|
|
104
|
+
// ---- hiring schedule (전형 일정) ---- (D-day 배지는 공용 schedule.dday.* 사용)
|
|
105
|
+
'jobs.schedule.title': '전형 일정',
|
|
106
|
+
|
|
88
107
|
// ---- fit card ----
|
|
89
108
|
'jobs.fit.title': '적합도 분석',
|
|
90
109
|
'jobs.fit.emptyBody': '아직 분석 전이에요. 이 공고와 내 프로필을 비교한 결과가 여기에 표시됩니다.',
|
|
@@ -105,18 +105,21 @@ export default {
|
|
|
105
105
|
'settings.import.confirm': '가져오기',
|
|
106
106
|
'settings.import.doneToast': '데이터를 가져왔어요.',
|
|
107
107
|
|
|
108
|
+
// 언어
|
|
109
|
+
'settings.lang.sub': '대시보드 표시 언어',
|
|
110
|
+
|
|
108
111
|
// 위험 구역
|
|
109
112
|
'settings.reset.gateWord': '초기화',
|
|
110
113
|
'settings.reset.title': '모든 데이터 초기화',
|
|
111
114
|
'settings.reset.calloutTitle': '되돌릴 수 없습니다',
|
|
112
|
-
'settings.reset.calloutBody': '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번
|
|
115
|
+
'settings.reset.calloutBody': '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성되며, 이 백업 파일은 데이터 폴더의 backups에 남습니다 — 흔적 없이 지우려면 초기화 후 그 파일도 직접 삭제하세요.',
|
|
113
116
|
'settings.reset.instruction': '계속하려면 아래 입력란에 {{word}} 를 입력하세요.',
|
|
114
117
|
'settings.reset.confirm': '모든 데이터 초기화',
|
|
115
118
|
'settings.reset.doneToast': '모든 데이터를 초기화했어요.',
|
|
116
119
|
'settings.danger.title': '위험 구역',
|
|
117
120
|
'settings.danger.sub': '이 작업은 되돌릴 수 없습니다',
|
|
118
121
|
'settings.danger.resetTitle': '모든 데이터 초기화',
|
|
119
|
-
'settings.danger.resetBody': '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에
|
|
122
|
+
'settings.danger.resetBody': '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동 백업이 생성되어 backups 폴더에 남습니다.',
|
|
120
123
|
|
|
121
124
|
// help / report a problem
|
|
122
125
|
'settings.support.title': '도움말·문제 신고',
|
package/dist/public/lib.js
CHANGED
|
@@ -184,6 +184,24 @@ export function scoreClass(score) {
|
|
|
184
184
|
if (score >= 50) return 'score-mid';
|
|
185
185
|
return 'score-weak';
|
|
186
186
|
}
|
|
187
|
+
/* --------------------------------------------------------------- date math */
|
|
188
|
+
// Local-calendar helpers. The server (core daysUntilDate) computes D-days from LOCAL
|
|
189
|
+
// midnight; mirror that here so the dashboard never disagrees by a day near midnight
|
|
190
|
+
// or across timezones — toISOString() would use UTC and can drift the date.
|
|
191
|
+
/** Today as a local YYYY-MM-DD string (not UTC). */
|
|
192
|
+
export function todayLocalISO() {
|
|
193
|
+
const d = new Date();
|
|
194
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
195
|
+
}
|
|
196
|
+
/** Whole days from local midnight today to a YYYY-MM-DD date (future > 0, today = 0, past < 0); null if unparseable. */
|
|
197
|
+
export function daysUntil(dateStr) {
|
|
198
|
+
if (!dateStr) return null;
|
|
199
|
+
const d = new Date(`${dateStr}T00:00:00`);
|
|
200
|
+
if (isNaN(d.getTime())) return null;
|
|
201
|
+
const today = new Date();
|
|
202
|
+
today.setHours(0, 0, 0, 0);
|
|
203
|
+
return Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
204
|
+
}
|
|
187
205
|
/** Textarea (newline-separated) → trimmed array, empties dropped. */
|
|
188
206
|
export function linesToArray(str) { return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean); }
|
|
189
207
|
/** CSV ("a, b, c") → trimmed array, empties dropped. */
|
|
@@ -208,6 +226,17 @@ export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '
|
|
|
208
226
|
export function Badge(status, label) {
|
|
209
227
|
return el('span', { class: `badge badge--${status}` }, el('span', { class: 'dot' }), label);
|
|
210
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Hiring-schedule stage D-day badge (면접·인적성·발표 date), shared by the Home action
|
|
231
|
+
* lane and the job-detail schedule list so label + colour never diverge: past = grey
|
|
232
|
+
* "지남", today = red "D-day", future = amber "D-{{n}}". Pass a D-day computed with the
|
|
233
|
+
* same local-midnight rule as the server (see daysUntil / core daysUntilDate).
|
|
234
|
+
*/
|
|
235
|
+
export function scheduleDdayBadge(dDay) {
|
|
236
|
+
if (dDay < 0) return Badge('draft', t('schedule.dday.past'));
|
|
237
|
+
if (dDay === 0) return Badge('rejected', t('schedule.dday.today'));
|
|
238
|
+
return Badge('interview', t('schedule.dday.future', { n: dDay }));
|
|
239
|
+
}
|
|
211
240
|
export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled, title } = {}) {
|
|
212
241
|
const cls = ['btn', variant && `btn--${variant}`, sm && 'btn--sm'].filter(Boolean).join(' ');
|
|
213
242
|
return el('button', { class: cls, onClick, type: type || 'button', disabled, title }, ic && icon(ic), label && el('span', {}, label));
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
// Applications — every application grouped by status in a calm VERTICAL list
|
|
2
2
|
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
3
|
import {
|
|
4
|
-
el, get, put, navigate, EmptyState, Btn, ListRow, Badge,
|
|
5
|
-
Select, scoreClass, statusColor, mount, toast, toastOk, toastError,
|
|
4
|
+
el, get, put, navigate, EmptyState, Btn, ListRow, Badge, Card,
|
|
5
|
+
Select, scoreClass, statusColor, mount, toast, toastOk, toastError, todayLocalISO, daysUntil,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
import { t } from '/i18n.js';
|
|
8
8
|
|
|
9
|
-
// deadline(YYYY-MM-DD) → "Due in 7d" / "Due today" / "Overdue" (null if absent/invalid)
|
|
9
|
+
// deadline(YYYY-MM-DD) → "Due in 7d" / "Due today" / "Overdue" (null if absent/invalid).
|
|
10
|
+
// daysUntil shares the server's local-midnight rule (core daysUntilDate).
|
|
10
11
|
function ddayLabel(deadline) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (isNaN(d.getTime())) return null;
|
|
14
|
-
const today = new Date();
|
|
15
|
-
today.setHours(0, 0, 0, 0);
|
|
16
|
-
const days = Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
12
|
+
const days = daysUntil(deadline);
|
|
13
|
+
if (days == null) return null;
|
|
17
14
|
if (days > 0) return t('applications.dday.future', { n: days });
|
|
18
15
|
if (days === 0) return t('applications.dday.today');
|
|
19
16
|
return t('applications.dday.past');
|
|
@@ -21,9 +18,10 @@ function ddayLabel(deadline) {
|
|
|
21
18
|
|
|
22
19
|
export async function render(ctx) {
|
|
23
20
|
ctx.setActions([]);
|
|
24
|
-
const data = await get('/api/applications');
|
|
21
|
+
const [data, offersRes] = await Promise.all([get('/api/applications'), get('/api/offers')]);
|
|
25
22
|
const applications = data.applications || [];
|
|
26
23
|
const boardOrder = data.board_order || [];
|
|
24
|
+
const offers = (offersRes && offersRes.offers) || [];
|
|
27
25
|
|
|
28
26
|
if (!applications.length) {
|
|
29
27
|
mount(ctx.view, el('div', { class: 'stack-4' }, EmptyState({
|
|
@@ -43,6 +41,7 @@ export async function render(ctx) {
|
|
|
43
41
|
for (const app of applications) (byStatus[app.status] || (byStatus[app.status] = [])).push(app);
|
|
44
42
|
|
|
45
43
|
const wrap = el('div', { class: 'stack-4' });
|
|
44
|
+
if (offers.length) wrap.append(OffersSection(offers));
|
|
46
45
|
for (const code of boardOrder) {
|
|
47
46
|
const apps = byStatus[code];
|
|
48
47
|
if (apps && apps.length) wrap.append(StatusGroup(code, apps));
|
|
@@ -63,6 +62,10 @@ export async function render(ctx) {
|
|
|
63
62
|
const subBits = [job.position, job.location].filter(Boolean);
|
|
64
63
|
const dday = ddayLabel(job.deadline);
|
|
65
64
|
if (dday) subBits.push(dday);
|
|
65
|
+
// 탈락 회고: 불합격이고 단계가 기록돼 있으면 어느 단계에서 떨어졌는지 sub에 가볍게 병기.
|
|
66
|
+
if (app.status === 'rejected' && app.rejected_stage) {
|
|
67
|
+
subBits.push(`${t('applications.rejectedStage.label')} ${t('applications.rejectedStage.' + app.rejected_stage)}`);
|
|
68
|
+
}
|
|
66
69
|
const sel = Select(
|
|
67
70
|
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
68
71
|
{
|
|
@@ -112,3 +115,76 @@ export async function render(ctx) {
|
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
}
|
|
118
|
+
|
|
119
|
+
// Offers — sorted by the AI's score (server pre-sorts). The score + verdict were
|
|
120
|
+
// written by the connected AI at save time, so this renders without any LLM. The
|
|
121
|
+
// precise head-to-head comparison happens in the AI chat via compare_offers.
|
|
122
|
+
//
|
|
123
|
+
// Lifecycle-aware: only OPEN offers deserve the top of the pipeline (they carry
|
|
124
|
+
// accept deadlines — the most time-sensitive decision in the whole search).
|
|
125
|
+
// Accepted/declined offers are kept as career records (terms history feeds the
|
|
126
|
+
// next negotiation) but demoted to a collapsed "past offers" line.
|
|
127
|
+
function OffersSection(offers) {
|
|
128
|
+
const status = (o) => o.offer_status || 'open';
|
|
129
|
+
const active = offers.filter((o) => status(o) === 'open');
|
|
130
|
+
const past = offers.filter((o) => status(o) !== 'open');
|
|
131
|
+
const children = [];
|
|
132
|
+
if (active.length) {
|
|
133
|
+
children.push(
|
|
134
|
+
el('div', { class: 'app-group__head' },
|
|
135
|
+
el('span', { class: 'app-group__title' }, t('applications.offers.title')),
|
|
136
|
+
el('span', { class: 'app-group__count tnum' }, t('applications.group.count', { count: active.length }))),
|
|
137
|
+
...active.map(OfferCard),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (past.length) children.push(PastOffers(past));
|
|
141
|
+
return el('div', { class: 'stack-3' }, ...children);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function OfferCard(o) {
|
|
145
|
+
const meta = [];
|
|
146
|
+
if (o.total_comp_annual_est != null)
|
|
147
|
+
meta.push(`${t('applications.offers.est')} ${o.total_comp_annual_est.toLocaleString()}${t('applications.offers.unit')}`);
|
|
148
|
+
if (o.work_arrangement) meta.push(o.work_arrangement);
|
|
149
|
+
if (o.accept_deadline) meta.push(t('applications.offers.acceptBy', { date: o.accept_deadline }));
|
|
150
|
+
const score = o.ai_score != null
|
|
151
|
+
? el('span', { class: `strong tnum ${scoreClass(o.ai_score)}` }, String(o.ai_score))
|
|
152
|
+
: el('span', { class: 'muted' }, t('applications.offers.noScore'));
|
|
153
|
+
// Deadline passed on a still-open offer: flag it, never auto-hide — deadlines
|
|
154
|
+
// get extended in negotiation, so the call is the user's (or the AI's), not ours.
|
|
155
|
+
const expired = !!o.accept_deadline && o.accept_deadline < todayLocalISO();
|
|
156
|
+
const actions = expired
|
|
157
|
+
? el('span', { class: 'flex gap-2', style: { alignItems: 'center' } },
|
|
158
|
+
Badge('on_hold', t('applications.offers.expired')), score)
|
|
159
|
+
: score;
|
|
160
|
+
return Card({
|
|
161
|
+
title: o.company,
|
|
162
|
+
sub: meta.join(' · '),
|
|
163
|
+
actions,
|
|
164
|
+
body: o.verdict ? el('p', { class: 'muted', style: { marginTop: '6px' } }, o.verdict) : null,
|
|
165
|
+
clickable: true,
|
|
166
|
+
onClick: () => navigate(`/jobs/${o.job_id}`),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Accepted/declined offers, one compact row each inside a collapsed disclosure.
|
|
171
|
+
function PastOffers(past) {
|
|
172
|
+
const rows = past.map((o) =>
|
|
173
|
+
el('div', { class: 'flex gap-2', style: { alignItems: 'center', padding: '8px 0' } },
|
|
174
|
+
Badge(o.offer_status === 'accepted' ? 'final_passed' : 'rejected',
|
|
175
|
+
t(`applications.offers.status.${o.offer_status}`)),
|
|
176
|
+
el('span', {
|
|
177
|
+
class: 'strong',
|
|
178
|
+
style: { cursor: 'pointer' },
|
|
179
|
+
activate: () => navigate(`/jobs/${o.job_id}`),
|
|
180
|
+
}, o.company),
|
|
181
|
+
o.total_comp_annual_est != null
|
|
182
|
+
? el('span', { class: 'muted text-sm' },
|
|
183
|
+
`${t('applications.offers.est')} ${o.total_comp_annual_est.toLocaleString()}${t('applications.offers.unit')}`)
|
|
184
|
+
: null,
|
|
185
|
+
));
|
|
186
|
+
return el('details', {},
|
|
187
|
+
el('summary', { class: 'muted text-sm', style: { cursor: 'pointer' } },
|
|
188
|
+
t('applications.offers.past', { count: past.length })),
|
|
189
|
+
...rows);
|
|
190
|
+
}
|