careermate 0.0.9 → 0.0.12

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 (52) hide show
  1. package/AGENTS.md +3 -2
  2. package/INSTALL.md +6 -6
  3. package/LICENSE +1 -1
  4. package/README.md +18 -16
  5. package/dist/career-os/README.md +2 -2
  6. package/dist/doctor.mjs +17857 -6595
  7. package/dist/mcp.mjs +14545 -3839
  8. package/dist/migrate.mjs +14258 -3709
  9. package/dist/pdf.worker.min.mjs +4 -4
  10. package/dist/public/app.js +3 -18
  11. package/dist/public/i18n/en/documents.js +1 -0
  12. package/dist/public/i18n/en/home.js +8 -1
  13. package/dist/public/i18n/en/jobs.js +2 -0
  14. package/dist/public/i18n/en/nav.js +1 -0
  15. package/dist/public/i18n/en/settings.js +1 -6
  16. package/dist/public/i18n/ko/documents.js +1 -0
  17. package/dist/public/i18n/ko/home.js +8 -1
  18. package/dist/public/i18n/ko/jobs.js +2 -0
  19. package/dist/public/i18n/ko/nav.js +1 -0
  20. package/dist/public/i18n/ko/settings.js +1 -6
  21. package/dist/public/index.html +0 -1
  22. package/dist/public/lib.js +13 -4
  23. package/dist/public/pages/documents.js +27 -10
  24. package/dist/public/pages/home.js +15 -5
  25. package/dist/public/pages/insights.js +156 -0
  26. package/dist/public/pages/jobs.js +21 -3
  27. package/dist/public/pages/settings.js +13 -12
  28. package/dist/public/styles.css +33 -0
  29. package/dist/web.mjs +16890 -5492
  30. package/docs/FAQ.md +3 -4
  31. package/docs/LOCAL_FIRST.md +32 -0
  32. package/docs/MCP_TOOLS.md +6 -3
  33. package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
  34. package/docs/career-os/README.md +2 -2
  35. package/package.json +7 -12
  36. package/packages/core/src/data-health.ts +161 -0
  37. package/packages/core/src/fit-explain.ts +63 -0
  38. package/packages/core/src/index.ts +4 -0
  39. package/packages/core/src/job-categories.data.ts +140 -0
  40. package/packages/core/src/job-categories.ts +106 -0
  41. package/packages/core/src/jobsearch.ts +374 -66
  42. package/packages/core/src/reuse-insights.ts +126 -0
  43. package/packages/db/src/connection.ts +46 -26
  44. package/packages/db/src/index.ts +3 -0
  45. package/packages/db/src/interview-retrospectives.ts +145 -0
  46. package/packages/db/src/job-search.ts +100 -0
  47. package/packages/db/src/provenance.ts +125 -0
  48. package/packages/db/src/repositories.ts +1 -0
  49. package/packages/mcp-tools/src/tools.ts +115 -11
  50. package/packages/prompts/src/system.ts +21 -64
  51. package/scripts/init.ts +2 -0
  52. package/tsconfig.json +9 -10
@@ -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
 
@@ -35,7 +35,7 @@ function renderNav(counts = {}) {
35
35
  navEl.append(el('div', { class: 'nav__label' }, t('nav.section.workspace')));
36
36
  const { page } = parseHash();
37
37
  for (const r of ROUTES) {
38
- if (r.id === 'settings') navEl.append(el('div', { class: 'nav__label' }, t('nav.section.system')));
38
+ if (r.id === 'insights') navEl.append(el('div', { class: 'nav__label' }, t('nav.section.system')));
39
39
  const count = r.countKey ? counts[r.countKey] : null;
40
40
  navEl.append(el('a', {
41
41
  class: `nav__item${page === r.id ? ' is-active' : ''}`,
@@ -67,7 +67,6 @@ async function route() {
67
67
  const { page, params } = parseHash();
68
68
  const def = ROUTES.find((r) => r.id === page) || ROUTES[0];
69
69
 
70
- // active nav state
71
70
  navEl.querySelectorAll('.nav__item').forEach((n) => n.classList.toggle('is-active', n.getAttribute('href') === `#/${def.id}`));
72
71
  const pageTitle = t('nav.' + def.id);
73
72
  titleEl.textContent = pageTitle;
@@ -89,11 +88,9 @@ async function route() {
89
88
  view.append(Spinner());
90
89
  try {
91
90
  const mod = await loadModule(def.module);
92
- if (token !== renderToken) return; // a newer navigation superseded this one
91
+ if (token !== renderToken) return;
93
92
  clear(view);
94
93
  await mod.render(ctx);
95
- // Announce just the new page name to screen readers (titleEl reflects any
96
- // detail-page ctx.setTitle override).
97
94
  if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || t('nav.' + def.id);
98
95
  } catch (err) {
99
96
  if (token !== renderToken) return;
@@ -106,9 +103,6 @@ async function route() {
106
103
  }
107
104
  }
108
105
 
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
106
  function setupMobileNav() {
113
107
  const toggle = document.getElementById('nav-toggle');
114
108
  const scrim = document.getElementById('nav-scrim');
@@ -126,14 +120,10 @@ function setupMobileNav() {
126
120
  }
127
121
 
128
122
  function initTheme() {
129
- // Read new key, falling back to the legacy 'cf-theme' so existing users keep their choice.
130
123
  const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
131
124
  if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
132
125
  }
133
126
 
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
127
  function localizeShell() {
138
128
  const sub = document.querySelector('.sidebar__subtitle');
139
129
  if (sub) sub.textContent = t('nav.subtitle');
@@ -141,7 +131,6 @@ function localizeShell() {
141
131
  if (skip) skip.textContent = t('nav.skip');
142
132
  const foot = document.getElementById('foot-status');
143
133
  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
134
  const faq = document.getElementById('foot-faq');
146
135
  if (faq) {
147
136
  const en = document.documentElement.lang === 'en';
@@ -154,20 +143,16 @@ async function boot() {
154
143
  initTheme();
155
144
  setupMobileNav();
156
145
  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
146
  document.getElementById('skip-link')?.addEventListener('click', () => {
160
147
  view.setAttribute('tabindex', '-1');
161
148
  view.focus();
162
149
  });
163
- // Show data location in the footer (privacy: user can always see where data lives).
164
150
  try {
165
151
  const h = await get('/api/health');
166
152
  document.getElementById('foot-path').textContent = h.data_dir;
167
153
  document.getElementById('foot-path').title = h.data_dir;
168
154
  } catch { /* ignore */ }
169
155
  await refreshNav();
170
- // 로케일 전환 시 내비게이션과 현재 라우트를 다시 그린다(전체 리로드 없이).
171
156
  onLangChange(() => { localizeShell(); refreshNav(); route(); });
172
157
  window.addEventListener('hashchange', route);
173
158
  await route();
@@ -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.followup': 'Follow up · {{n}}d',
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,6 +68,8 @@ 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',
@@ -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',
@@ -2,7 +2,6 @@
2
2
  export default {
3
3
  // shared
4
4
  'settings.copy': 'Copy',
5
- 'settings.confirm': 'OK',
6
5
  'settings.cancel': 'Cancel',
7
6
  'settings.copyPath': 'Copy path',
8
7
 
@@ -17,8 +16,6 @@ export default {
17
16
 
18
17
  // connection
19
18
  'settings.connection.title': 'Connection',
20
- 'settings.connection.shortcutDemoToast': "Here's a preview of how the shortcut works.",
21
- 'settings.connection.shortcutToast': 'Created a dashboard shortcut.',
22
19
  'settings.connection.shortcutDemoTitle': 'Shortcut preview',
23
20
  'settings.connection.shortcutTitle': 'Shortcut created',
24
21
  'settings.connection.shortcutDemoCalloutTitle': "The demo doesn't create files",
@@ -123,8 +120,6 @@ export default {
123
120
 
124
121
  // help / report a problem
125
122
  'settings.support.title': 'Help & report a problem',
126
- 'settings.support.sub': 'Report bugs on GitHub Issues, ask how-to questions in Discussions, or email us for anything else.',
127
- 'settings.support.report': 'Report a problem',
128
- 'settings.support.ask': 'Ask a question',
123
+ 'settings.support.sub': 'Report bugs, questions, and feature requests by email. Do not include private career data in support messages.',
129
124
  'settings.support.email': 'Email us',
130
125
  };
@@ -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.followup': '후속 필요 · {{n}}일',
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,6 +68,8 @@ 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': '공고 원문',
@@ -6,6 +6,7 @@ export default {
6
6
  'nav.applications': '지원 현황',
7
7
  'nav.documents': '문서',
8
8
  'nav.interview': '면접 준비',
9
+ 'nav.insights': '진단·인사이트',
9
10
  'nav.settings': '설정',
10
11
  'nav.section.workspace': '워크스페이스',
11
12
  'nav.section.system': '시스템',
@@ -2,7 +2,6 @@
2
2
  export default {
3
3
  // 공통
4
4
  'settings.copy': '복사',
5
- 'settings.confirm': '확인',
6
5
  'settings.cancel': '취소',
7
6
  'settings.copyPath': '경로 복사',
8
7
 
@@ -17,8 +16,6 @@ export default {
17
16
 
18
17
  // 연결 상태
19
18
  'settings.connection.title': '연결 상태',
20
- 'settings.connection.shortcutDemoToast': '데모 바로가기 동작을 미리 보여드려요.',
21
- 'settings.connection.shortcutToast': '대시보드 바로가기를 만들었어요.',
22
19
  'settings.connection.shortcutDemoTitle': '바로가기 미리보기',
23
20
  'settings.connection.shortcutTitle': '바로가기 생성',
24
21
  'settings.connection.shortcutDemoCalloutTitle': '데모에서는 파일을 만들지 않습니다',
@@ -123,8 +120,6 @@ export default {
123
120
 
124
121
  // help / report a problem
125
122
  'settings.support.title': '도움말·문제 신고',
126
- 'settings.support.sub': '버그는 GitHub 이슈로, 사용법 질문은 Discussions로, 그 밖의 문의는 이메일로 보내 주세요.',
127
- 'settings.support.report': '문제 신고',
128
- 'settings.support.ask': '질문하기',
123
+ 'settings.support.sub': '\ubc84\uadf8, \uc0ac\uc6a9\ubc95 \uc9c8\ubb38, \uae30\ub2a5 \uc694\uccad\uc740 \uc774\uba54\uc77c\ub85c \ubcf4\ub0b4 \uc8fc\uc138\uc694. \ubb38\uc758\uc5d0\ub294 \uac1c\uc778 \ucee4\ub9ac\uc5b4 \ub370\uc774\ud130\ub97c \ub123\uc9c0 \ub9c8\uc138\uc694.',
129
124
  'settings.support.email': '이메일 문의',
130
125
  };
@@ -28,7 +28,6 @@
28
28
  <div class="sidebar__foot">
29
29
  <div><span class="dot"></span><span id="foot-status">로컬에서 실행 중</span></div>
30
30
  <div id="foot-path" class="truncate" style="margin-top:4px"></div>
31
- <a class="sidebar__foot-link" href="https://github.com/osntak/careermate" target="_blank" rel="noopener" title="GitHub에서 소스 보기"><svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg><span>GitHub</span></a>
32
31
  <a class="sidebar__foot-link" id="foot-faq" href="https://careermate.life/faq" target="_blank" rel="noopener" title="자주 묻는 질문"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg><span>FAQ</span></a>
33
32
  </div>
34
33
  </aside>
@@ -411,6 +411,9 @@ export function Spinner() { return el('div', { class: 'view' }, el('div', { clas
411
411
 
412
412
  /* ------------------------------------------------------------------ toast */
413
413
  let toastRoot;
414
+ function clearToasts() {
415
+ if (toastRoot) clear(toastRoot);
416
+ }
414
417
  export function toast(message, { title, type = 'default', timeout = 3800 } = {}) {
415
418
  if (!toastRoot) { toastRoot = el('div', { class: 'toasts' }); document.body.append(toastRoot); }
416
419
  const t = el('div', { class: `toast toast--${type}` },
@@ -436,22 +439,27 @@ function ensureModalRoot() {
436
439
  }
437
440
  export function closeModal() {
438
441
  if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); }
442
+ delete document.body.dataset.modalIntent;
439
443
  // Return focus to whatever opened the modal so keyboard users aren't dumped at the page top.
440
444
  if (modalReturnFocus) { try { modalReturnFocus.focus(); } catch { /* element gone */ } modalReturnFocus = null; }
441
445
  }
442
446
  /**
443
447
  * Open a modal. `render(close)` returns the modal body content. Provide title,
444
- * optional footer buttons via `footer(close)`. Returns nothing; call closeModal().
448
+ * optional footer buttons via `footer(close)`. `intent` only changes
449
+ * interruption weight; pages should not hand-roll dialog styles.
445
450
  */
446
- export function openModal({ title, body, footer, size }) {
451
+ export function openModal({ title, body, footer, size, intent = 'dialog' }) {
447
452
  const root = ensureModalRoot();
448
453
  const close = closeModal;
454
+ if (intent === 'contextual' || intent === 'alert') clearToasts();
455
+ root.dataset.intent = intent;
456
+ document.body.dataset.modalIntent = intent;
449
457
  modalReturnFocus = document.activeElement; // restored by closeModal
450
458
  const closeBtn = IconBtn('plus', { title: t('common.close'), onClick: close }); // rotated to look like ×
451
459
  closeBtn.style.transform = 'rotate(45deg)';
452
460
  const modal = el('div', {
453
- class: `modal${size === 'lg' ? ' modal--lg' : ''}`,
454
- attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
461
+ class: `modal${size === 'lg' ? ' modal--lg' : ''}${intent === 'alert' ? ' modal--alert' : ''}`,
462
+ attrs: { role: intent === 'alert' ? 'alertdialog' : 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
455
463
  },
456
464
  el('div', { class: 'modal__head' }, el('h3', { id: 'modal-title' }, title), el('div', { class: 'topbar__spacer' }), closeBtn),
457
465
  );
@@ -482,6 +490,7 @@ export function confirmDialog({ title = t('common.confirm'), message, confirmLab
482
490
  return new Promise((resolve) => {
483
491
  openModal({
484
492
  title,
493
+ intent: danger ? 'alert' : 'dialog',
485
494
  body: el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } }, message),
486
495
  footer: (close) => [
487
496
  Btn(t('common.cancel'), { onClick: () => { close(); resolve(false); } }),
@@ -1,9 +1,9 @@
1
1
  // Documents — cover letters (with version history), career descriptions, and résumés/other documents.
2
2
  // XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
3
3
  import {
4
- el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
4
+ el, get, post, put, del, Card, Badge, Btn, SubmitBtn, EmptyState,
5
5
  Field, Input, Textarea, Select, openModal, confirmDialog,
6
- toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu,
6
+ toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu, Menu,
7
7
  } from '/lib.js';
8
8
  import { t } from '/i18n.js';
9
9
 
@@ -21,6 +21,7 @@ export async function render(ctx) {
21
21
  const root = el('div', { class: 'stack-4' });
22
22
 
23
23
  const panel = el('div', {});
24
+ let activeHasItems = true;
24
25
  // Tabs are keyboard-operable (activate = role/tabindex/Enter·Space), with tab
25
26
  // semantics for AT — role은 attrs가 먼저 tab으로 지정하므로 activate의 button 기본값을 덮지 않는다.
26
27
  const makeTab = (key, label) =>
@@ -43,7 +44,7 @@ export async function render(ctx) {
43
44
  node.classList.toggle('is-active', tab === key);
44
45
  node.setAttribute('aria-selected', String(tab === key));
45
46
  }
46
- setActions();
47
+ ctx.setActions([]);
47
48
  renderPanel();
48
49
  }
49
50
 
@@ -74,9 +75,14 @@ export async function render(ctx) {
74
75
  async function renderPanel() {
75
76
  mount(panel, el('div', { class: 'muted text-sm' }, t('documents.loading')));
76
77
  try {
77
- if (tab === 'cover') mount(panel, await CoverLettersTab(reload));
78
- else if (tab === 'career') mount(panel, await CareerDescriptionsTab(reload));
79
- else mount(panel, await DocumentsTab(reload));
78
+ const content = tab === 'cover'
79
+ ? await CoverLettersTab(reload)
80
+ : tab === 'career'
81
+ ? await CareerDescriptionsTab(reload)
82
+ : await DocumentsTab(reload);
83
+ activeHasItems = content.dataset.hasItems === 'true';
84
+ mount(panel, content);
85
+ setActions();
80
86
  applyFilter();
81
87
  await openRequestedDocument();
82
88
  } catch (err) {
@@ -102,6 +108,10 @@ export async function render(ctx) {
102
108
 
103
109
  // topbar: search (filters the active tab) + the tab's create action
104
110
  function setActions() {
111
+ if (!activeHasItems) {
112
+ ctx.setActions([]);
113
+ return;
114
+ }
105
115
  const placeholder = tab === 'cover' ? t('documents.search.cover') : tab === 'career' ? t('documents.search.career') : t('documents.search.docs');
106
116
  const search = Input({ type: 'search', placeholder, value: query, attrs: { 'aria-label': t('documents.search.aria') } });
107
117
  search.classList.add('input--inline');
@@ -115,7 +125,7 @@ export async function render(ctx) {
115
125
  }
116
126
 
117
127
  mount(ctx.view, root);
118
- setActions();
128
+ ctx.setActions([]);
119
129
  await renderPanel();
120
130
  }
121
131
 
@@ -124,6 +134,7 @@ export async function render(ctx) {
124
134
  async function CoverLettersTab(reload) {
125
135
  const { cover_letters: list } = await get('/api/cover-letters');
126
136
  const wrap = el('div', { class: 'stack-3' });
137
+ wrap.dataset.hasItems = list.length ? 'true' : 'false';
127
138
 
128
139
  if (!list.length) {
129
140
  return mountInto(wrap, EmptyState({
@@ -144,12 +155,16 @@ function CoverRow(cl, reload) {
144
155
  cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
145
156
  );
146
157
 
158
+ // Two primary actions stay visible (열기·내보내기); the rest fold into an
159
+ // overflow menu so the row reads calmly instead of a wall of five buttons.
147
160
  const actions = el('div', { class: 'flex gap-2 wrap' },
148
161
  Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
149
- Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
150
162
  ExportMenu(`/api/export/cover-letter/${cl.id}`),
151
- cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
152
- IconBtn('trash', { variant: 'danger', title: t('documents.cover.delete'), onClick: () => removeCover(cl, reload) }),
163
+ Menu(t('documents.cover.more'), [
164
+ cl.current_content ? { label: t('documents.cover.copy'), icon: 'copy', onClick: () => copyText(cl.current_content || '') } : null,
165
+ cl.is_primary ? null : { label: t('documents.cover.setPrimary'), icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) },
166
+ { label: t('documents.cover.delete'), icon: 'trash', danger: true, onClick: () => removeCover(cl, reload) },
167
+ ], { sm: true, icon: null }),
153
168
  );
154
169
 
155
170
  return Card({
@@ -372,6 +387,7 @@ async function CareerDescriptionsTab(reload) {
372
387
  const [{ documents: list }, m] = await Promise.all([get('/api/documents?kind=career_description'), meta()]);
373
388
  const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
374
389
  const wrap = el('div', { class: 'stack-3' });
390
+ wrap.dataset.hasItems = list.length ? 'true' : 'false';
375
391
 
376
392
  if (!list.length) {
377
393
  return mountInto(wrap, EmptyState({
@@ -393,6 +409,7 @@ async function DocumentsTab(reload) {
393
409
  const list = (documents || []).filter((doc) => doc.kind !== 'career_description');
394
410
  const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
395
411
  const wrap = el('div', { class: 'stack-3' });
412
+ wrap.dataset.hasItems = list.length ? 'true' : 'false';
396
413
 
397
414
  if (!list.length) {
398
415
  return mountInto(wrap, EmptyState({
@@ -142,6 +142,14 @@ function Pipeline(breakdown) {
142
142
  if (total === 0) {
143
143
  body = el('p', { class: 'muted', style: { margin: 0 } },
144
144
  t('home.pipeline.empty'));
145
+ } else if (total < 3) {
146
+ // Too few applications for a stacked bar to read as anything but noise —
147
+ // show the current stage(s) as one calm line until the pipeline fills in.
148
+ const detail = PIPE_ORDER.filter((st) => c(st) > 0)
149
+ .map((st) => t('home.pipeline.small.item', { status: t('status.' + st), count: c(st) }))
150
+ .join(' · ');
151
+ body = el('p', { class: 'muted', style: { margin: 0 } },
152
+ t('home.pipeline.small', { detail }));
145
153
  } else {
146
154
  const segs = PIPE_ORDER.filter((st) => c(st) > 0).map((st) =>
147
155
  el('div', {
@@ -211,7 +219,7 @@ function ActionLane(s) {
211
219
  push(it.job.id, ListRow({
212
220
  leading: statusDot('document_passed'),
213
221
  title: it.job.company,
214
- sub: it.job.position,
222
+ sub: t('home.actionLane.reason.interview'),
215
223
  trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), t('home.actionLane.interviewPrep')),
216
224
  onClick: () => navigate(`/jobs/${it.job.id}`),
217
225
  }));
@@ -231,7 +239,7 @@ function ActionLane(s) {
231
239
  push(it.job.id, ListRow({
232
240
  leading: statusDot(it.status),
233
241
  title: it.job.company,
234
- sub: it.job.position,
242
+ sub: t('home.actionLane.reason.deadline'),
235
243
  trailing: ddayBadge(it.days_left),
236
244
  onClick: () => navigate(`/jobs/${it.job.id}`),
237
245
  }));
@@ -240,8 +248,8 @@ function ActionLane(s) {
240
248
  push(it.job.id, ListRow({
241
249
  leading: statusDot('applied'),
242
250
  title: it.job.company,
243
- sub: it.job.position,
244
- trailing: Badge('accent', t('home.actionLane.followup', { n: it.days_since })),
251
+ sub: t('home.actionLane.reason.followup', { n: it.days_since }),
252
+ trailing: Badge('accent', t('home.actionLane.followupBadge')),
245
253
  onClick: () => navigate(`/jobs/${it.job.id}`),
246
254
  }));
247
255
  }
@@ -254,13 +262,15 @@ function ActionLane(s) {
254
262
  onClick: it.job ? () => navigate(`/jobs/${it.job.id}`) : null,
255
263
  }));
256
264
  }
257
- return Card({
265
+ const card = Card({
258
266
  title: t('home.actionLane.title'),
259
267
  actions: rows.length ? Btn(t('home.actionLane.viewAll'), { sm: true, variant: 'ghost', onClick: () => navigate('/applications') }) : null,
260
268
  body: rows.length
261
269
  ? el('div', {}, ...rows)
262
270
  : el('p', { class: 'muted', style: { margin: 0 } }, t('home.actionLane.empty')),
263
271
  });
272
+ card.classList.add('home-action-card');
273
+ return card;
264
274
  }
265
275
 
266
276
  /* ----------------------------------------------- recent jobs */
@@ -0,0 +1,156 @@
1
+ import { el, get, post, Card, Btn, icon, mount, toastOk, toastError } from '/lib.js';
2
+ import { getLang } from '/i18n.js';
3
+
4
+ const copy = {
5
+ ko: {
6
+ title: '진단·데이터 건강도',
7
+ subtitle: '로컬 데이터 상태, 모순, 출처 확인 대상과 경험 재사용 현황을 한곳에서 점검합니다.',
8
+ runtime: '실행 환경',
9
+ health: '데이터 건강도',
10
+ provenance: '사람 확인이 필요한 정보',
11
+ reuse: '경험·프로젝트 재사용 지도',
12
+ healthy: '정상',
13
+ failed: '확인 필요',
14
+ verify: '사람이 확인함',
15
+ empty: '확인할 문제가 없습니다.',
16
+ score: '건강도 점수',
17
+ times: '회 사용',
18
+ local: 'DB와 커리어 데이터는 이 컴퓨터에 저장됩니다.',
19
+ network: '업데이트 확인·설치 문서·채용 검색은 선택적으로 인터넷을 사용할 수 있습니다.',
20
+ },
21
+ en: {
22
+ title: 'Diagnostics & data health',
23
+ subtitle: 'Review local storage, contradictions, provenance, and experience reuse.',
24
+ runtime: 'Runtime checks',
25
+ health: 'Data health',
26
+ provenance: 'Needs human verification',
27
+ reuse: 'Experience & project reuse map',
28
+ healthy: 'Healthy',
29
+ failed: 'Needs attention',
30
+ verify: 'Mark human verified',
31
+ empty: 'No issues found.',
32
+ score: 'Health score',
33
+ times: 'uses',
34
+ local: 'Your database and career records are stored on this computer.',
35
+ network: 'Update checks, install docs, and job search may optionally use the internet.',
36
+ },
37
+ };
38
+
39
+ function text() {
40
+ return copy[getLang()] || copy.ko;
41
+ }
42
+
43
+ function severityBadge(severity) {
44
+ const label = severity === 'error' ? 'ERROR' : severity === 'warning' ? 'WARN' : 'INFO';
45
+ return el('span', { class: `badge badge--${severity === 'error' ? 'rejected' : severity === 'warning' ? 'planned' : 'draft'}` }, label);
46
+ }
47
+
48
+ export async function render(ctx) {
49
+ const c = text();
50
+ ctx.setTitle(c.title);
51
+ ctx.setActions([Btn(c.title, { icon: 'check', sm: true, variant: 'ghost', onClick: () => render(ctx) })]);
52
+
53
+ try {
54
+ const diagnostics = await get('/api/diagnostics');
55
+ const checks = diagnostics.checks || [];
56
+ const report = diagnostics.data_health || { score: 0, issues: [], counts: {} };
57
+ const unverified = diagnostics.unverified_entities || [];
58
+ const reuseMap = diagnostics.reuse_map || [];
59
+
60
+ const localFirst = Card({
61
+ title: 'Local-first',
62
+ body: el('div', { class: 'stack-2' },
63
+ el('div', { class: 'callout callout--privacy' }, icon('lock'), el('div', {},
64
+ el('div', { class: 'callout__title' }, c.local),
65
+ el('div', { class: 'callout__body' }, c.network))),
66
+ ),
67
+ });
68
+
69
+ const runtime = Card({
70
+ title: c.runtime,
71
+ body: el('div', { class: 'stack-2' }, ...checks.map((check) =>
72
+ el('div', { class: 'flex between gap-3' },
73
+ el('div', {},
74
+ el('div', { class: 'strong' }, check.label),
75
+ el('div', { class: 'muted text-sm tnum' }, check.detail || '—')),
76
+ el('span', { class: `badge badge--${check.ok ? 'final_passed' : 'rejected'}` }, check.ok ? c.healthy : c.failed),
77
+ ))),
78
+ });
79
+
80
+ const issueList = report.issues?.length
81
+ ? el('div', { class: 'stack-2' }, ...report.issues.map((issue) =>
82
+ el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
83
+ severityBadge(issue.severity),
84
+ el('div', {},
85
+ el('div', { class: 'strong' }, issue.message),
86
+ el('div', { class: 'muted text-sm' }, `${issue.entity_type}${issue.entity_id ? ` · ${issue.entity_id}` : ''}`)),
87
+ )))
88
+ : el('p', { class: 'muted' }, c.empty);
89
+
90
+ const health = Card({
91
+ title: c.health,
92
+ actions: [el('span', { class: 'badge badge--final_passed tnum' }, `${c.score} ${report.score}`)],
93
+ body: issueList,
94
+ });
95
+
96
+ const reuse = Card({
97
+ title: c.reuse,
98
+ body: reuseMap.length
99
+ ? el('div', { class: 'stack-2' }, ...reuseMap.map((item) =>
100
+ el('div', { class: 'flex between gap-3', style: { alignItems: 'flex-start' } },
101
+ el('div', {},
102
+ el('div', { class: 'strong' }, item.label),
103
+ el('div', { class: 'muted text-sm' },
104
+ item.cover_letters?.length
105
+ ? item.cover_letters.map((letter) => [letter.company, letter.position, letter.title].filter(Boolean).join(' · ')).join(' / ')
106
+ : c.empty)),
107
+ el('span', { class: `badge badge--${item.reuse_count ? 'final_passed' : 'draft'} tnum` }, `${item.reuse_count} ${c.times}`),
108
+ )))
109
+ : el('p', { class: 'muted' }, c.empty),
110
+ });
111
+
112
+ async function verify(item, button) {
113
+ try {
114
+ button.disabled = true;
115
+ await post('/api/provenance/verify', {
116
+ entity_type: item.entity_type,
117
+ entity_id: item.entity_id,
118
+ verified: true,
119
+ });
120
+ toastOk(c.verify);
121
+ await render(ctx);
122
+ } catch (error) {
123
+ button.disabled = false;
124
+ toastError(error);
125
+ }
126
+ }
127
+
128
+ const provenance = Card({
129
+ title: c.provenance,
130
+ body: unverified.length
131
+ ? el('div', { class: 'stack-2' }, ...unverified.map((item) => {
132
+ const button = Btn(c.verify, { icon: 'check', sm: true, variant: 'ghost' });
133
+ button.addEventListener('click', () => verify(item, button));
134
+ return el('div', { class: 'flex between gap-3' },
135
+ el('div', {},
136
+ el('div', { class: 'strong' }, `${item.entity_type} · ${item.entity_id}`),
137
+ el('div', { class: 'muted text-sm' }, item.source_excerpt || item.source)),
138
+ button,
139
+ );
140
+ }))
141
+ : el('p', { class: 'muted' }, c.empty),
142
+ });
143
+
144
+ mount(ctx.view, el('div', { class: 'stack-4' },
145
+ el('p', { class: 'muted', style: { marginTop: 0 } }, c.subtitle),
146
+ localFirst,
147
+ runtime,
148
+ health,
149
+ reuse,
150
+ provenance,
151
+ ));
152
+ } catch (error) {
153
+ toastError(error);
154
+ mount(ctx.view, el('div', { class: 'card' }, el('div', { class: 'card__body' }, String(error))));
155
+ }
156
+ }