careermate 0.0.8 → 0.0.9

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.
Files changed (50) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +78 -81
  4. package/bin/careermate.mjs +11 -2
  5. package/dist/career-os/eop/cover-letter.md +10 -4
  6. package/dist/career-os/eop/interview-prep.md +1 -0
  7. package/dist/career-os/knowledge/cover-letter.md +4 -0
  8. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  9. package/dist/doctor.mjs +888 -340
  10. package/dist/mcp.mjs +904 -350
  11. package/dist/migrate.mjs +208 -16
  12. package/dist/public/i18n/en/applications.js +19 -0
  13. package/dist/public/i18n/en/common.js +6 -0
  14. package/dist/public/i18n/en/jobs.js +17 -0
  15. package/dist/public/i18n/en/settings.js +5 -2
  16. package/dist/public/i18n/ko/applications.js +19 -0
  17. package/dist/public/i18n/ko/common.js +5 -0
  18. package/dist/public/i18n/ko/jobs.js +17 -0
  19. package/dist/public/i18n/ko/settings.js +5 -2
  20. package/dist/public/lib.js +29 -0
  21. package/dist/public/pages/applications.js +86 -10
  22. package/dist/public/pages/documents.js +16 -7
  23. package/dist/public/pages/home.js +12 -0
  24. package/dist/public/pages/interview.js +11 -4
  25. package/dist/public/pages/jobs.js +71 -1
  26. package/dist/public/pages/settings.js +2 -2
  27. package/dist/public/styles.css +13 -0
  28. package/dist/web.mjs +544 -199
  29. package/docs/FAQ.md +13 -3
  30. package/docs/MCP_TOOLS.md +22 -9
  31. package/docs/career-os/eop/cover-letter.md +10 -4
  32. package/docs/career-os/eop/interview-prep.md +1 -0
  33. package/docs/career-os/knowledge/cover-letter.md +4 -0
  34. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  35. package/package.json +3 -1
  36. package/packages/core/src/index.ts +1 -0
  37. package/packages/core/src/jobsearch.ts +6 -2
  38. package/packages/core/src/offers.ts +41 -0
  39. package/packages/core/src/services.ts +5 -1
  40. package/packages/core/src/summary.ts +245 -19
  41. package/packages/db/src/connection.ts +70 -2
  42. package/packages/db/src/index.ts +1 -0
  43. package/packages/db/src/repositories.ts +140 -8
  44. package/packages/db/src/schema.ts +140 -8
  45. package/packages/mcp-tools/src/result.ts +9 -0
  46. package/packages/mcp-tools/src/tools.ts +143 -20
  47. package/packages/parsers/src/documents.ts +7 -1
  48. package/packages/prompts/src/humanize.ts +4 -0
  49. package/packages/shared/src/schemas.ts +134 -0
  50. package/scripts/init.ts +2 -0
@@ -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
- if (!deadline) return null;
12
- const d = new Date(`${deadline}T00:00:00`);
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
+ }
@@ -21,19 +21,28 @@ export async function render(ctx) {
21
21
  const root = el('div', { class: 'stack-4' });
22
22
 
23
23
  const panel = el('div', {});
24
- const tabCover = el('div', { class: `tab${tab === 'cover' ? ' is-active' : ''}`, onClick: () => selectTab('cover') }, t('documents.tab.cover'));
25
- const tabCareer = el('div', { class: `tab${tab === 'career' ? ' is-active' : ''}`, onClick: () => selectTab('career') }, t('documents.tab.career'));
26
- const tabDocs = el('div', { class: `tab${tab === 'docs' ? ' is-active' : ''}`, onClick: () => selectTab('docs') }, t('documents.tab.docs'));
27
- root.append(el('div', { class: 'tabs' }, tabCover, tabCareer, tabDocs), panel);
24
+ // Tabs are keyboard-operable (activate = role/tabindex/Enter·Space), with tab
25
+ // semantics for AT — role은 attrs가 먼저 tab으로 지정하므로 activate의 button 기본값을 덮지 않는다.
26
+ const makeTab = (key, label) =>
27
+ el('div', {
28
+ class: `tab${tab === key ? ' is-active' : ''}`,
29
+ attrs: { role: 'tab', 'aria-selected': String(tab === key) },
30
+ activate: () => selectTab(key),
31
+ }, label);
32
+ const tabCover = makeTab('cover', t('documents.tab.cover'));
33
+ const tabCareer = makeTab('career', t('documents.tab.career'));
34
+ const tabDocs = makeTab('docs', t('documents.tab.docs'));
35
+ root.append(el('div', { class: 'tabs', attrs: { role: 'tablist' } }, tabCover, tabCareer, tabDocs), panel);
28
36
 
29
37
  let query = '';
30
38
  let searchInput = null;
31
39
  function selectTab(next) {
32
40
  tab = next;
33
41
  query = '';
34
- tabCover.classList.toggle('is-active', tab === 'cover');
35
- tabCareer.classList.toggle('is-active', tab === 'career');
36
- tabDocs.classList.toggle('is-active', tab === 'docs');
42
+ for (const [node, key] of [[tabCover, 'cover'], [tabCareer, 'career'], [tabDocs, 'docs']]) {
43
+ node.classList.toggle('is-active', tab === key);
44
+ node.setAttribute('aria-selected', String(tab === key));
45
+ }
37
46
  setActions();
38
47
  renderPanel();
39
48
  }
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
8
8
  Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
9
+ scheduleDdayBadge,
9
10
  } from '/lib.js';
10
11
  import { t } from '/i18n.js';
11
12
 
@@ -215,6 +216,17 @@ function ActionLane(s) {
215
216
  onClick: () => navigate(`/jobs/${it.job.id}`),
216
217
  }));
217
218
  }
219
+ // Hiring-schedule stages (면접·인적성·발표) sit right after interview prep and before
220
+ // deadlines: they're the most time-bound "be there on this date" items.
221
+ for (const it of s.upcoming_schedule || []) {
222
+ push(it.job.id, ListRow({
223
+ leading: statusDot('interview'),
224
+ title: it.job.company,
225
+ sub: it.time ? `${it.label} · ${it.time}` : it.label,
226
+ trailing: scheduleDdayBadge(it.d_day),
227
+ onClick: () => navigate(`/jobs/${it.job.id}`),
228
+ }));
229
+ }
218
230
  for (const it of s.deadlines || []) {
219
231
  push(it.job.id, ListRow({
220
232
  leading: statusDot(it.status),
@@ -146,12 +146,19 @@ export async function render(ctx) {
146
146
  mount(panel, list.length
147
147
  ? el('div', { class: 'stack-3' }, ...list.map(QuestionBlock))
148
148
  : el('p', { class: 'muted', style: { margin: 0 } }, t('interview.modal.questionsEmptyTab')));
149
- for (const c of CATEGORIES) tabEls[c].classList.toggle('is-active', c === active);
149
+ for (const c of CATEGORIES) {
150
+ tabEls[c].classList.toggle('is-active', c === active);
151
+ tabEls[c].setAttribute('aria-selected', String(c === active));
152
+ }
150
153
  }
151
- const tabs = el('div', { class: 'tabs' }, ...CATEGORIES.map((c) => {
154
+ // 키보드 조작 가능 탭(activate = role/tabindex/Enter·Space) + 탭 시맨틱.
155
+ const tabs = el('div', { class: 'tabs', attrs: { role: 'tablist' } }, ...CATEGORIES.map((c) => {
152
156
  const n = groups[c].length;
153
- const tabEl = el('div', { class: 'tab', onClick: () => { active = c; draw(); } },
154
- n ? `${t('interview.tab.' + c)} (${n})` : t('interview.tab.' + c));
157
+ const tabEl = el('div', {
158
+ class: 'tab',
159
+ attrs: { role: 'tab', 'aria-selected': 'false' },
160
+ activate: () => { active = c; draw(); },
161
+ }, n ? `${t('interview.tab.' + c)} (${n})` : t('interview.tab.' + c));
155
162
  tabEls[c] = tabEl;
156
163
  return tabEl;
157
164
  }));
@@ -6,7 +6,7 @@ import {
6
6
  el, get, post, put, del, icon, navigate, Card, Badge, Btn, SubmitBtn,
7
7
  EmptyState, Chips, Field, Input, Textarea, Select,
8
8
  openModal, closeModal, confirmDialog, toastOk, toastError,
9
- fmtDate, scoreClass, mount, meta, linesToArray, csvToArray,
9
+ fmtDate, scoreClass, mount, meta, linesToArray, csvToArray, daysUntil, scheduleDdayBadge,
10
10
  } from '/lib.js';
11
11
  import { t } from '/i18n.js';
12
12
 
@@ -340,6 +340,7 @@ async function renderDetail(ctx, jobId) {
340
340
  CoverLettersCard(job.cover_letters),
341
341
  InterviewCard(job)),
342
342
  PostingCard(job),
343
+ CompanyResearchCard(job),
343
344
  FitCard(job.fit),
344
345
  TimelineCard(job.timeline),
345
346
  RelatedCard(job.related),
@@ -372,6 +373,41 @@ function PostingCard(job) {
372
373
  ...job.requirements.map((r) => el('li', {}, r)))));
373
374
  }
374
375
 
376
+ if (job.questions && job.questions.length) {
377
+ // 한국 문항형 자소서: 회사 지정 문항·글자수 한도. AI가 문항별 답변을 쓰고 한도를
378
+ // 점검할 원본이자, 사용자가 대시보드에서 요구 문항을 그대로 볼 수 있게 한다.
379
+ body.push(el('div', { class: 'mt-3' },
380
+ el('div', { class: 'muted text-sm mb-2' }, t('jobs.questions.title')),
381
+ el('ol', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
382
+ ...job.questions.map((q) => {
383
+ const label = questionLimitLabel(q);
384
+ return el('li', {},
385
+ el('span', {}, q.question),
386
+ label ? el('span', { class: 'muted text-sm', style: { marginLeft: '8px' } }, label) : null);
387
+ }))));
388
+ }
389
+
390
+ if (job.schedule && job.schedule.length) {
391
+ // 전형 일정: 면접·인적성·발표 등 단계별 날짜. 날짜순으로 D-day 라벨과 함께 보여주고,
392
+ // 이미 지난 일정은 흐리게 처리한다(v9 문항 블록과 같은 위치·스타일 패턴).
393
+ const sorted = job.schedule.slice().sort((a, b) => {
394
+ if (a.date !== b.date) return a.date < b.date ? -1 : 1;
395
+ return (a.time || '99:99').localeCompare(b.time || '99:99');
396
+ });
397
+ body.push(el('div', { class: 'mt-3' },
398
+ el('div', { class: 'muted text-sm mb-2' }, t('jobs.schedule.title')),
399
+ el('ul', { class: 'stack-2', style: { margin: 0, padding: 0, listStyle: 'none' } },
400
+ ...sorted.map((s) => {
401
+ const dDay = daysUntil(s.date);
402
+ const when = s.time ? `${fmtDate(s.date)} ${s.time}` : fmtDate(s.date);
403
+ return el('li', { class: `flex gap-2 center${dDay != null && dDay < 0 ? ' muted' : ''}` },
404
+ scheduleDdayBadge(dDay),
405
+ el('span', { class: 'strong' }, s.label),
406
+ el('span', { class: 'text-sm' }, when),
407
+ s.note ? el('span', { class: 'muted text-sm' }, s.note) : null);
408
+ }))));
409
+ }
410
+
375
411
  if (job.description) {
376
412
  // 원문은 가장 길어 칼럼을 늘리는 주범 — 기본 접힘(아코디언)으로 둔다.
377
413
  body.push(el('details', { class: 'disclosure mt-3' },
@@ -384,6 +420,40 @@ function PostingCard(job) {
384
420
  return Card({ title: t('jobs.posting.title'), body });
385
421
  }
386
422
 
423
+ // 문항 글자수 한도 라벨(예: "최대 700자(공백포함)"). 한도가 없으면 null.
424
+ // count_mode: with_space=공백포함 · no_space=공백제외 · byte=UTF-8 바이트(공백 구분 없음).
425
+ function questionLimitLabel(q) {
426
+ const hasMin = typeof q.min_chars === 'number';
427
+ const hasMax = typeof q.max_chars === 'number';
428
+ if (!hasMin && !hasMax) return null;
429
+ const unit = q.count_mode === 'byte' ? t('jobs.questions.unitByte') : t('jobs.questions.unitChar');
430
+ const suffix = q.count_mode === 'no_space' ? t('jobs.questions.modeNoSpace')
431
+ : q.count_mode === 'with_space' ? t('jobs.questions.modeWithSpace')
432
+ : '';
433
+ const parts = [];
434
+ if (hasMin) parts.push(t('jobs.questions.min', { n: q.min_chars, unit }));
435
+ if (hasMax) parts.push(t('jobs.questions.max', { n: q.max_chars, unit }));
436
+ return `${parts.join(' · ')}${suffix}`;
437
+ }
438
+
439
+ // Company research the AI saved (overview·인재상·핵심가치·평판) — rendered so the
440
+ // user can actually SEE it in the dashboard, not just have the AI use it.
441
+ function CompanyResearchCard(job) {
442
+ const hasValues = job.core_values && job.core_values.length;
443
+ if (!job.company_overview && !job.talent_profile && !hasValues && !job.reputation) return null;
444
+ const field = (labelKey, value) => el('div', { class: 'mt-3' },
445
+ el('div', { class: 'muted text-sm mb-2' }, t(labelKey)),
446
+ el('p', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, value));
447
+ const body = [];
448
+ if (job.company_overview) body.push(field('jobs.company.overview', job.company_overview));
449
+ if (job.talent_profile) body.push(field('jobs.company.talent', job.talent_profile));
450
+ if (hasValues) body.push(el('div', { class: 'mt-3' },
451
+ el('div', { class: 'muted text-sm mb-2' }, t('jobs.company.values')),
452
+ Chips(job.core_values, { accent: true })));
453
+ if (job.reputation) body.push(field('jobs.company.reputation', job.reputation));
454
+ return Card({ title: t('jobs.company.title'), body });
455
+ }
456
+
387
457
  function FitCard(fit) {
388
458
  if (!fit) {
389
459
  return Card({
@@ -168,7 +168,6 @@ function Theme() {
168
168
  /* ------------------------------------------------------------- 언어 / Language */
169
169
  function LanguageSwitcher() {
170
170
  // 테마 컴포넌트와 동일 패턴: localStorage 영속 + paint() + 변경 시 재페인트.
171
- // 카탈로그가 채워지는 Phase 2부터 페이지 텍스트가 실제로 전환된다.
172
171
  const options = [
173
172
  { value: 'ko', label: '한국어' },
174
173
  { value: 'en', label: 'English' },
@@ -188,8 +187,9 @@ function LanguageSwitcher() {
188
187
  onLangChange(paint);
189
188
 
190
189
  return Card({
190
+ // 제목은 의도적으로 양언어 병기 — 현재 언어를 못 읽는 사용자도 이 카드를 찾아야 한다.
191
191
  title: '언어 / Language',
192
- sub: '대시보드 표시 언어',
192
+ sub: t('settings.lang.sub'),
193
193
  body: row,
194
194
  });
195
195
  }
@@ -577,3 +577,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
577
577
  .card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
578
578
  .page-head{ flex-wrap: wrap; }
579
579
  }
580
+
581
+ /* ---------- reduced motion ----------
582
+ 전정기관 민감 사용자를 위해 OS 설정을 존중한다: 모달·토스트·오프캔버스의
583
+ 등장 애니메이션과 hover 트랜지션을 사실상 즉시(0.01ms)로 줄인다.
584
+ (스켈레톤 shimmer 포함 — 반복 애니메이션이 가장 부담이 크다.) */
585
+ @media (prefers-reduced-motion: reduce) {
586
+ *, *::before, *::after {
587
+ animation-duration: 0.01ms !important;
588
+ animation-iteration-count: 1 !important;
589
+ transition-duration: 0.01ms !important;
590
+ scroll-behavior: auto !important;
591
+ }
592
+ }