careermate 0.0.7 → 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 (71) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +108 -91
  4. package/THIRD_PARTY_NOTICES.md +2 -1
  5. package/bin/careermate.mjs +11 -2
  6. package/dist/career-os/eop/cover-letter.md +13 -6
  7. package/dist/career-os/eop/interview-prep.md +1 -0
  8. package/dist/career-os/knowledge/cover-letter.md +7 -3
  9. package/dist/career-os/knowledge/fit-matching.md +2 -0
  10. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  11. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  12. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  13. package/dist/doctor.mjs +19760 -1018
  14. package/dist/mcp.mjs +19783 -1035
  15. package/dist/migrate.mjs +289 -20
  16. package/dist/public/i18n/en/applications.js +19 -0
  17. package/dist/public/i18n/en/common.js +10 -0
  18. package/dist/public/i18n/en/documents.js +0 -10
  19. package/dist/public/i18n/en/home.js +5 -0
  20. package/dist/public/i18n/en/jobs.js +18 -1
  21. package/dist/public/i18n/en/profile.js +0 -5
  22. package/dist/public/i18n/en/settings.js +5 -2
  23. package/dist/public/i18n/ko/applications.js +19 -0
  24. package/dist/public/i18n/ko/common.js +9 -0
  25. package/dist/public/i18n/ko/documents.js +0 -10
  26. package/dist/public/i18n/ko/home.js +5 -0
  27. package/dist/public/i18n/ko/jobs.js +18 -1
  28. package/dist/public/i18n/ko/profile.js +0 -5
  29. package/dist/public/i18n/ko/settings.js +5 -2
  30. package/dist/public/lib.js +101 -0
  31. package/dist/public/pages/applications.js +95 -15
  32. package/dist/public/pages/documents.js +20 -14
  33. package/dist/public/pages/home.js +38 -6
  34. package/dist/public/pages/interview.js +11 -4
  35. package/dist/public/pages/jobs.js +71 -1
  36. package/dist/public/pages/profile.js +2 -3
  37. package/dist/public/pages/settings.js +2 -2
  38. package/dist/public/styles.css +52 -4
  39. package/dist/web.mjs +19001 -758
  40. package/docs/FAQ.md +13 -3
  41. package/docs/MCP_TOOLS.md +32 -12
  42. package/docs/career-os/eop/cover-letter.md +13 -6
  43. package/docs/career-os/eop/interview-prep.md +1 -0
  44. package/docs/career-os/knowledge/cover-letter.md +7 -3
  45. package/docs/career-os/knowledge/fit-matching.md +2 -0
  46. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  47. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  48. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  49. package/package.json +5 -2
  50. package/packages/core/src/index.ts +2 -0
  51. package/packages/core/src/jobsearch.ts +190 -0
  52. package/packages/core/src/offers.ts +41 -0
  53. package/packages/core/src/services.ts +29 -3
  54. package/packages/core/src/summary.ts +431 -9
  55. package/packages/core/src/verify/charcount.ts +87 -0
  56. package/packages/core/src/verify/index.ts +1 -0
  57. package/packages/core/src/verify/lint.ts +5 -0
  58. package/packages/db/src/connection.ts +70 -2
  59. package/packages/db/src/index.ts +1 -0
  60. package/packages/db/src/repositories.ts +179 -13
  61. package/packages/db/src/schema.ts +169 -8
  62. package/packages/exporters/src/docx.ts +165 -0
  63. package/packages/exporters/src/index.ts +142 -9
  64. package/packages/exporters/src/markdown.ts +16 -11
  65. package/packages/knowledge/src/index.ts +8 -3
  66. package/packages/mcp-tools/src/result.ts +9 -0
  67. package/packages/mcp-tools/src/tools.ts +303 -60
  68. package/packages/parsers/src/documents.ts +7 -1
  69. package/packages/prompts/src/humanize.ts +4 -0
  70. package/packages/shared/src/schemas.ts +198 -0
  71. package/scripts/init.ts +7 -0
@@ -9,6 +9,15 @@ export default {
9
9
  'common.delete': '삭제',
10
10
  'common.open': '열기',
11
11
  'common.justNow': '방금 전',
12
+ 'common.export': '내보내기',
13
+ 'common.fmtWord': 'Word',
14
+ 'common.fmtHtml': 'HTML',
15
+ 'common.fmtMarkdown': 'Markdown',
16
+
17
+ // 전형 일정 D-day 배지(홈 액션레인 + 공고 상세 공유). 마감 D-day(applications.dday.*)와는 별개.
18
+ 'schedule.dday.today': 'D-day',
19
+ 'schedule.dday.future': 'D-{{n}}',
20
+ 'schedule.dday.past': '지남',
12
21
 
13
22
  'toast.copied': '클립보드에 복사했습니다.',
14
23
  'toast.copyFailed': '복사에 실패했습니다. 직접 선택해 복사해 주세요.',
@@ -28,10 +28,6 @@ export default {
28
28
  'documents.cover.primary': '대표',
29
29
  'documents.cover.open': '열기',
30
30
  'documents.cover.copy': '복사',
31
- 'documents.cover.exportMd': 'MD',
32
- 'documents.cover.exportHtml': 'HTML',
33
- 'documents.cover.exportMdTitle': 'Markdown 내보내기',
34
- 'documents.cover.exportHtmlTitle': 'HTML 내보내기',
35
31
  'documents.cover.setPrimary': '대표 지정',
36
32
  'documents.cover.delete': '삭제',
37
33
  'documents.cover.noContent': '아직 내용이 없습니다.',
@@ -60,8 +56,6 @@ export default {
60
56
  'documents.coverCreate.toast': '자기소개서를 추가했어요.',
61
57
 
62
58
  // cover detail body
63
- 'documents.coverDetail.exportMd': 'MD 내보내기',
64
- 'documents.coverDetail.exportHtml': 'HTML 내보내기',
65
59
  'documents.coverDetail.currentVersion': '현재 버전',
66
60
  'documents.coverDetail.copy': '복사',
67
61
  'documents.coverDetail.noContent': '내용이 없습니다.',
@@ -101,10 +95,6 @@ export default {
101
95
  'documents.docDetail.updated': '수정 {{when}}',
102
96
  'documents.docDetail.noContent': '내용이 없습니다.',
103
97
  'documents.docDetail.copy': '복사',
104
- 'documents.docDetail.export': 'MD',
105
- 'documents.docDetail.exportTitle': 'Markdown 내보내기',
106
- 'documents.docDetail.exportHtml': 'HTML',
107
- 'documents.docDetail.exportHtmlTitle': 'HTML 내보내기',
108
98
  'documents.docDetail.delete': '삭제',
109
99
  'documents.docDetail.deleteTitle': '문서 삭제',
110
100
  'documents.docDetail.deleteConfirm': '"{{title}}"을(를) 삭제할까요?',
@@ -28,6 +28,11 @@ export default {
28
28
  'home.stat.activeApplications': '진행 중 지원',
29
29
  'home.stat.coverLetters': '자기소개서',
30
30
  'home.stat.interviewPending': '면접 준비',
31
+ // KPI tile context sub-lines (derived from real counts; omitted when zero)
32
+ 'home.stat.jobsSub': '지원 전 {{count}}건',
33
+ 'home.stat.subInterview': '면접 {{count}}',
34
+ 'home.stat.subDocPassed': '서류 {{count}}',
35
+ 'home.stat.subApplied': '지원 {{count}}',
31
36
 
32
37
  // pipeline
33
38
  'home.pipeline.title': '지원 파이프라인',
@@ -2,7 +2,7 @@
2
2
  export default {
3
3
  // ---- list mode ----
4
4
  'jobs.list.empty.title': '저장된 공고가 없어요',
5
- 'jobs.list.empty.body': '관심 있는 채용공고를 추가해 적합도와 진행 상태를 관리해 보세요.',
5
+ 'jobs.list.empty.body': '관심 있는 채용공고를 추가해 적합도와 진행 상태를 관리해 보세요. 연결된 AI에게 “백엔드 공고 찾아줘”처럼 말하면 공고를 찾아 여기에 저장해 줍니다.',
6
6
  'jobs.list.addJob': '공고 추가',
7
7
  'jobs.list.searchPlaceholder': '회사·직무 검색',
8
8
  'jobs.list.searchAria': '공고 검색',
@@ -73,6 +73,11 @@ export default {
73
73
  'jobs.detail.openPosting': '공고 원문',
74
74
 
75
75
  // ---- posting card ----
76
+ 'jobs.company.title': '회사 리서치',
77
+ 'jobs.company.overview': '사업 개요',
78
+ 'jobs.company.talent': '인재상',
79
+ 'jobs.company.values': '핵심가치',
80
+ 'jobs.company.reputation': '평판',
76
81
  'jobs.posting.title': '공고 정보',
77
82
  'jobs.posting.location': '위치',
78
83
  'jobs.posting.employment': '고용형태',
@@ -85,6 +90,18 @@ export default {
85
90
  'jobs.posting.requirements': '자격요건 · 우대사항',
86
91
  'jobs.posting.rawText': '공고 원문',
87
92
 
93
+ // ---- cover letter questions (문항형 자소서) ----
94
+ 'jobs.questions.title': '자소서 문항',
95
+ 'jobs.questions.min': '최소 {{n}}{{unit}}',
96
+ 'jobs.questions.max': '최대 {{n}}{{unit}}',
97
+ 'jobs.questions.unitChar': '자',
98
+ 'jobs.questions.unitByte': 'byte',
99
+ 'jobs.questions.modeWithSpace': '(공백포함)',
100
+ 'jobs.questions.modeNoSpace': '(공백제외)',
101
+
102
+ // ---- hiring schedule (전형 일정) ---- (D-day 배지는 공용 schedule.dday.* 사용)
103
+ 'jobs.schedule.title': '전형 일정',
104
+
88
105
  // ---- fit card ----
89
106
  'jobs.fit.title': '적합도 분석',
90
107
  'jobs.fit.emptyBody': '아직 분석 전이에요. 이 공고와 내 프로필을 비교한 결과가 여기에 표시됩니다.',
@@ -11,11 +11,6 @@ export default {
11
11
  'profile.completeness.title': '프로필 완성도',
12
12
  'profile.completeness.percent': '{{pct}}%',
13
13
 
14
- // export profile as a resume
15
- 'profile.export.md': 'MD',
16
- 'profile.export.html': 'HTML',
17
- 'profile.export.mdTitle': '이력서 Markdown 내보내기',
18
- 'profile.export.htmlTitle': '이력서 HTML 내보내기',
19
14
 
20
15
  // basic info card
21
16
  'profile.basic.title': '기본 정보',
@@ -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': '도움말·문제 신고',
@@ -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));
@@ -215,6 +244,78 @@ export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled
215
244
  export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
216
245
  return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
217
246
  }
247
+ /**
248
+ * A dropdown: one trigger button that opens a small popover of actions, so
249
+ * several secondary actions (e.g. export as docx/html/md) collapse into a single
250
+ * control instead of a row of competing buttons. The popover is position:fixed
251
+ * and anchored to the trigger's rect, so it never clips inside a scrolling modal
252
+ * body and flips above the trigger when there's no room below. Closes on outside
253
+ * click, Escape, scroll, resize, or item activation.
254
+ *
255
+ * `items`: array of `{ label, hint?, icon?, onClick, danger? }` (falsy entries skipped).
256
+ */
257
+ export function Menu(label, items, { icon: ic = 'download', variant = 'ghost', sm, align = 'end' } = {}) {
258
+ const list = el('div', { class: 'menu__list', attrs: { role: 'menu' }, hidden: true });
259
+ for (const it of items) {
260
+ if (!it) continue;
261
+ list.append(el('button', {
262
+ class: `menu__item${it.danger ? ' menu__item--danger' : ''}`,
263
+ type: 'button', attrs: { role: 'menuitem' },
264
+ onClick: () => { close(); it.onClick?.(); },
265
+ }, it.icon && icon(it.icon), el('span', { class: 'menu__item-label' }, it.label), it.hint && el('span', { class: 'menu__item-hint' }, it.hint)));
266
+ }
267
+ const trigger = el('button', {
268
+ class: ['btn', variant && `btn--${variant}`, sm && 'btn--sm', 'menu__trigger'].filter(Boolean).join(' '),
269
+ type: 'button', attrs: { 'aria-haspopup': 'menu', 'aria-expanded': 'false' },
270
+ onClick: () => (list.hidden ? open() : close()),
271
+ }, ic && icon(ic), label && el('span', {}, label), icon('chevronRight', 'menu__caret'));
272
+ const wrap = el('div', { class: 'menu' }, trigger, list);
273
+
274
+ function place() {
275
+ const r = trigger.getBoundingClientRect();
276
+ const lw = list.offsetWidth, lh = list.offsetHeight;
277
+ let top = r.bottom + 6;
278
+ if (top + lh > window.innerHeight - 8 && r.top - 6 - lh > 8) top = r.top - 6 - lh; // flip up
279
+ let left = align === 'end' ? r.right - lw : r.left;
280
+ left = Math.max(8, Math.min(left, window.innerWidth - lw - 8));
281
+ list.style.top = `${Math.round(top)}px`;
282
+ list.style.left = `${Math.round(left)}px`;
283
+ }
284
+ function open() {
285
+ if (!list.hidden) return;
286
+ list.hidden = false; place(); // place() reads layout while visible, same tick → no flash
287
+ trigger.setAttribute('aria-expanded', 'true');
288
+ document.addEventListener('click', onDoc, true);
289
+ document.addEventListener('keydown', onKey, true);
290
+ window.addEventListener('scroll', close, true);
291
+ window.addEventListener('resize', close);
292
+ }
293
+ function close() {
294
+ if (list.hidden) return;
295
+ list.hidden = true;
296
+ trigger.setAttribute('aria-expanded', 'false');
297
+ document.removeEventListener('click', onDoc, true);
298
+ document.removeEventListener('keydown', onKey, true);
299
+ window.removeEventListener('scroll', close, true);
300
+ window.removeEventListener('resize', close);
301
+ }
302
+ function onDoc(e) { if (!wrap.contains(e.target)) close(); }
303
+ function onKey(e) { if (e.key === 'Escape') { close(); trigger.focus(); } }
304
+ return wrap;
305
+ }
306
+ /**
307
+ * The standard "내보내기" dropdown for a document export endpoint. `baseUrl` is a
308
+ * GET export route (e.g. `/api/export/profile`); each item appends `?format=…`
309
+ * and triggers a download. One control replaces the old docx/html/md button row.
310
+ */
311
+ export function ExportMenu(baseUrl, { sm = true } = {}) {
312
+ const sep = baseUrl.includes('?') ? '&' : '?';
313
+ return Menu(t('common.export'), [
314
+ { label: t('common.fmtWord'), hint: '.docx', onClick: () => downloadUrl(`${baseUrl}${sep}format=docx`) },
315
+ { label: t('common.fmtHtml'), hint: '.html', onClick: () => downloadUrl(`${baseUrl}${sep}format=html`) },
316
+ { label: t('common.fmtMarkdown'), hint: '.md', onClick: () => downloadUrl(`${baseUrl}${sep}format=md`) },
317
+ ], { sm });
318
+ }
218
319
  /**
219
320
  * A footer button for async saves. While `action()` runs the button is disabled
220
321
  * (so a slow local round-trip can't be double-clicked into duplicate records),
@@ -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,
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,23 +62,31 @@ 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
  {
69
- class: 'select select--sm row-action',
72
+ class: 'select select--sm',
70
73
  title: t('applications.statusSelect.title'),
71
74
  onClick: (e) => e.stopPropagation(),
72
75
  onChange: (e) => { e.stopPropagation(); changeStatus(app, e.target.value, e.target); },
73
76
  },
74
77
  );
78
+ // Keep the right edge informative on every row: a right-aligned score column
79
+ // (a muted dash when not yet scored, so scores line up and "no score" reads
80
+ // clearly), then the status — a calm badge that swaps to the change-select on hover.
81
+ const score = app.fit_score != null
82
+ ? el('span', { class: `app-score strong tnum ${scoreClass(app.fit_score)}` }, t('applications.row.score', { score: app.fit_score }))
83
+ : el('span', { class: 'app-score muted tnum' }, '—');
75
84
  return ListRow({
76
85
  title: job.company || '—',
77
86
  sub: subBits.join(' · '),
78
87
  trailing: [
79
- app.fit_score != null
80
- ? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` }, t('applications.row.score', { score: app.fit_score }))
81
- : null,
82
- sel,
88
+ score,
89
+ el('span', { class: 'app-status' }, Badge(app.status, t('status.' + app.status)), sel),
83
90
  ],
84
91
  onClick: () => navigate(`/jobs/${app.job_id}`),
85
92
  });
@@ -108,3 +115,76 @@ export async function render(ctx) {
108
115
  }
109
116
  }
110
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
+ }
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
5
5
  Field, Input, Textarea, Select, openModal, confirmDialog,
6
- toastOk, toastError, copyText, downloadUrl, fmtRelative, mount, meta,
6
+ toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu,
7
7
  } from '/lib.js';
8
8
  import { t } from '/i18n.js';
9
9
 
@@ -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
  }
@@ -138,8 +147,7 @@ function CoverRow(cl, reload) {
138
147
  const actions = el('div', { class: 'flex gap-2 wrap' },
139
148
  Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
140
149
  Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
141
- Btn(t('documents.cover.exportMd'), { sm: true, variant: 'ghost', icon: 'download', title: t('documents.cover.exportMdTitle'), onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
142
- Btn(t('documents.cover.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', title: t('documents.cover.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
150
+ ExportMenu(`/api/export/cover-letter/${cl.id}`),
143
151
  cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
144
152
  IconBtn('trash', { variant: 'danger', title: t('documents.cover.delete'), onClick: () => removeCover(cl, reload) }),
145
153
  );
@@ -255,8 +263,7 @@ function CoverDetailBody(cl, { refresh }) {
255
263
  cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
256
264
  ),
257
265
  el('div', { class: 'flex gap-2 wrap' },
258
- Btn(t('documents.coverDetail.exportMd'), { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
259
- Btn(t('documents.coverDetail.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
266
+ ExportMenu(`/api/export/cover-letter/${cl.id}`),
260
267
  ),
261
268
  ));
262
269
 
@@ -446,8 +453,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
446
453
  ),
447
454
  footer: (close) => [
448
455
  Btn(t('documents.docDetail.copy'), { variant: 'ghost', icon: 'copy', onClick: () => copyText(doc.content || '') }),
449
- Btn(t('documents.docDetail.export'), { variant: 'ghost', icon: 'download', title: t('documents.docDetail.exportTitle'), onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=md`) }),
450
- Btn(t('documents.docDetail.exportHtml'), { variant: 'ghost', icon: 'download', title: t('documents.docDetail.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=html`) }),
456
+ ExportMenu(`/api/export/document/${doc.id}`, { sm: false }),
451
457
  Btn(t('documents.docDetail.delete'), { variant: 'danger', icon: 'trash', onClick: async () => {
452
458
  const ok = await confirmDialog({ title: t('documents.docDetail.deleteTitle'), message: t('documents.docDetail.deleteConfirm', { title: doc.title }), confirmLabel: t('documents.docDetail.deleteConfirmLabel'), danger: true });
453
459
  if (!ok) return;
@@ -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
 
@@ -45,7 +46,7 @@ export async function render(ctx) {
45
46
  }
46
47
 
47
48
  if (!s.onboarding.completed) wrap.append(GettingStarted(s.onboarding));
48
- wrap.append(StatRow(s.counts));
49
+ wrap.append(StatRow(s));
49
50
  wrap.append(Pipeline(s.status_breakdown));
50
51
  wrap.append(el('div', { class: 'grid grid--2' },
51
52
  el('div', { class: 'stack-4' }, ActionLane(s), RecentJobs(s.recent_jobs)),
@@ -96,15 +97,35 @@ function GettingStarted(o) {
96
97
  }
97
98
 
98
99
  /* ----------------------------------------------- KPI summary tiles (at-a-glance) */
99
- function StatTile(label, value, iconName, to) {
100
- const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
100
+ function StatTile(label, value, iconName, to, hint) {
101
+ const tile = Stat({ label, value, hint, iconName, onClick: () => navigate(to) });
101
102
  tile.classList.add('is-clickable');
102
103
  return tile;
103
104
  }
104
- function StatRow(counts) {
105
+ function StatRow(s) {
106
+ const counts = s.counts;
107
+ const by = Object.fromEntries((s.status_breakdown || []).map((b) => [b.status, b.count]));
108
+ const c = (st) => by[st] || 0;
109
+
110
+ // Contextual sub-lines use only real counts (never invented). Tiles with no
111
+ // meaningful secondary signal (cover letters, interview prep) stay clean — the
112
+ // .stat min-height keeps the row even either way.
113
+
114
+ // 저장 공고 → how many postings still await action (not yet applied).
115
+ const notApplied = c('draft') + c('planned');
116
+ const jobsHint = notApplied > 0 ? t('home.stat.jobsSub', { count: notApplied }) : null;
117
+
118
+ // 진행 중 지원 → surface the most advanced active stages (interview → screening
119
+ // → applied), up to two, so the number carries a "where are they" breakdown.
120
+ const activeHint = [
121
+ c('interview') ? t('home.stat.subInterview', { count: c('interview') }) : null,
122
+ c('document_passed') ? t('home.stat.subDocPassed', { count: c('document_passed') }) : null,
123
+ c('applied') ? t('home.stat.subApplied', { count: c('applied') }) : null,
124
+ ].filter(Boolean).slice(0, 2).join(' · ') || null;
125
+
105
126
  return el('div', { class: 'grid grid--4' },
106
- StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs'),
107
- StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications'),
127
+ StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs', jobsHint),
128
+ StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications', activeHint),
108
129
  StatTile(t('home.stat.coverLetters'), counts.cover_letters, 'file', '/documents'),
109
130
  StatTile(t('home.stat.interviewPending'), counts.interview_pending, 'mic', '/interview'),
110
131
  );
@@ -195,6 +216,17 @@ function ActionLane(s) {
195
216
  onClick: () => navigate(`/jobs/${it.job.id}`),
196
217
  }));
197
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
+ }
198
230
  for (const it of s.deadlines || []) {
199
231
  push(it.job.id, ListRow({
200
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
  }));