careermate 0.0.4 → 0.0.6

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 (124) hide show
  1. package/AGENTS.md +11 -7
  2. package/INSTALL.md +18 -16
  3. package/README.md +28 -26
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +8 -3
  7. package/dist/career-os/eop/career-description.md +136 -0
  8. package/dist/career-os/eop/cover-letter.md +7 -5
  9. package/dist/career-os/eop/fit-analysis.md +15 -5
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +5 -3
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +4 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  16. package/dist/career-os/knowledge/interview-technical.md +106 -4
  17. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  18. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  19. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  20. package/dist/career-os/knowledge/portfolio.md +3 -3
  21. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  22. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
  23. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  24. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  25. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  26. package/dist/doctor.mjs +1019 -127
  27. package/dist/mcp.mjs +1036 -126
  28. package/dist/migrate.mjs +52 -1
  29. package/dist/public/app.js +42 -16
  30. package/dist/public/i18n/en/applications.js +23 -0
  31. package/dist/public/i18n/en/common.js +22 -0
  32. package/dist/public/i18n/en/documents.js +133 -0
  33. package/dist/public/i18n/en/home.js +75 -0
  34. package/dist/public/i18n/en/interview.js +69 -0
  35. package/dist/public/i18n/en/jobs.js +152 -0
  36. package/dist/public/i18n/en/nav.js +18 -0
  37. package/dist/public/i18n/en/profile.js +201 -0
  38. package/dist/public/i18n/en/settings.js +127 -0
  39. package/dist/public/i18n/en/status.js +22 -0
  40. package/dist/public/i18n/en.js +17 -0
  41. package/dist/public/i18n/ko/applications.js +23 -0
  42. package/dist/public/i18n/ko/common.js +22 -0
  43. package/dist/public/i18n/ko/documents.js +133 -0
  44. package/dist/public/i18n/ko/home.js +63 -0
  45. package/dist/public/i18n/ko/interview.js +69 -0
  46. package/dist/public/i18n/ko/jobs.js +152 -0
  47. package/dist/public/i18n/ko/nav.js +18 -0
  48. package/dist/public/i18n/ko/profile.js +201 -0
  49. package/dist/public/i18n/ko/settings.js +127 -0
  50. package/dist/public/i18n/ko/status.js +23 -0
  51. package/dist/public/i18n/ko.js +18 -0
  52. package/dist/public/i18n.js +55 -0
  53. package/dist/public/index.html +1 -0
  54. package/dist/public/lib.js +25 -17
  55. package/dist/public/pages/applications.js +17 -21
  56. package/dist/public/pages/documents.js +153 -107
  57. package/dist/public/pages/home.js +88 -53
  58. package/dist/public/pages/interview.js +105 -51
  59. package/dist/public/pages/jobs.js +330 -106
  60. package/dist/public/pages/profile.js +157 -152
  61. package/dist/public/pages/settings.js +270 -72
  62. package/dist/public/styles.css +38 -3
  63. package/dist/web.mjs +859 -103
  64. package/docs/FAQ.md +26 -5
  65. package/docs/MCP_TOOLS.md +26 -12
  66. package/docs/START_WORKFLOW.md +2 -2
  67. package/docs/SUPPORTED_AI_APPS.md +13 -0
  68. package/docs/WORKFLOWS.md +80 -23
  69. package/docs/career-os/README.md +8 -3
  70. package/docs/career-os/eop/career-description.md +136 -0
  71. package/docs/career-os/eop/cover-letter.md +7 -5
  72. package/docs/career-os/eop/fit-analysis.md +15 -5
  73. package/docs/career-os/eop/interview-prep.md +209 -21
  74. package/docs/career-os/eop/job-analysis.md +5 -3
  75. package/docs/career-os/eop/profile-extraction.md +5 -4
  76. package/docs/career-os/knowledge/cover-letter.md +4 -4
  77. package/docs/career-os/knowledge/fit-matching.md +8 -8
  78. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  79. package/docs/career-os/knowledge/interview-technical.md +106 -4
  80. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  81. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  82. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  83. package/docs/career-os/knowledge/portfolio.md +3 -3
  84. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  85. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
  86. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  87. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  88. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  89. package/package.json +2 -2
  90. package/packages/core/src/onboarding.ts +2 -1
  91. package/packages/core/src/services.ts +138 -21
  92. package/packages/core/src/summary.ts +59 -1
  93. package/packages/core/src/verify/lint.ts +4 -0
  94. package/packages/db/src/index.ts +1 -0
  95. package/packages/db/src/repositories.ts +0 -0
  96. package/packages/db/src/schema.ts +17 -0
  97. package/packages/exporters/src/index.ts +28 -17
  98. package/packages/knowledge/src/index.ts +46 -7
  99. package/packages/mcp-tools/src/bridge.ts +194 -13
  100. package/packages/mcp-tools/src/tools.ts +390 -38
  101. package/packages/mcp-tools/src/update.ts +31 -16
  102. package/packages/parsers/src/documents.ts +12 -1
  103. package/packages/prompts/src/humanize.ts +46 -0
  104. package/packages/prompts/src/index.ts +5 -1
  105. package/packages/prompts/src/install.ts +4 -4
  106. package/packages/prompts/src/onboarding.ts +4 -4
  107. package/packages/prompts/src/system.ts +17 -8
  108. package/packages/shared/src/enums.ts +14 -4
  109. package/packages/shared/src/schemas.ts +41 -0
  110. package/packages/workflows/src/definitions.ts +47 -10
  111. package/scripts/init.ts +284 -13
  112. package/dist/site/analytics.js +0 -97
  113. package/dist/site/favicon.svg +0 -1
  114. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  115. package/dist/site/icon-180.png +0 -0
  116. package/dist/site/icon-512.png +0 -0
  117. package/dist/site/index.html +0 -565
  118. package/dist/site/landing.js +0 -54
  119. package/dist/site/llms-install.txt +0 -218
  120. package/dist/site/robots.txt +0 -4
  121. package/dist/site/sitemap.xml +0 -21
  122. package/dist/site/start.html +0 -353
  123. package/dist/site/style.css +0 -559
  124. package/dist/site/vercel.json +0 -20
@@ -7,6 +7,7 @@ import {
7
7
  el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
8
8
  Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
9
9
  } from '/lib.js';
10
+ import { t } from '/i18n.js';
10
11
 
11
12
  const PIPE_ORDER = ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold', 'rejected'];
12
13
 
@@ -28,7 +29,7 @@ export async function render(ctx) {
28
29
  const name = profileRes.profile?.name;
29
30
 
30
31
  // exactly one primary action per screen, pinned to the topbar
31
- ctx.setActions([Btn('공고 추가', { icon: 'plus', variant: 'primary', onClick: () => navigate('/jobs') })]);
32
+ ctx.setActions([Btn(t('home.action.addJob'), { icon: 'plus', variant: 'primary', onClick: () => navigate('/jobs') })]);
32
33
 
33
34
  // day one: nothing saved yet → just greeting + the single getting-started card
34
35
  // (skip the pipeline + a wall of empty cards).
@@ -47,7 +48,7 @@ export async function render(ctx) {
47
48
  wrap.append(StatRow(s.counts));
48
49
  wrap.append(Pipeline(s.status_breakdown));
49
50
  wrap.append(el('div', { class: 'grid grid--2' },
50
- el('div', { class: 'stack-4' }, ActionLane(s.interview_todo, s.in_progress), RecentJobs(s.recent_jobs)),
51
+ el('div', { class: 'stack-4' }, ActionLane(s), RecentJobs(s.recent_jobs)),
51
52
  el('div', { class: 'stack-4' }, Activity(s.recent_activity)),
52
53
  ));
53
54
 
@@ -57,25 +58,25 @@ export async function render(ctx) {
57
58
  /* ----------------------------------------------------------------- greeting band */
58
59
  function Greeting(name, s, firstRun) {
59
60
  let focus;
60
- if (firstRun) focus = '시작해 볼까요 — 먼저 기본 정보를 채워 주세요.';
61
- else if (s.interview_todo.length) focus = `면접 준비가 필요한 지원 ${s.interview_todo.length}건이 있어요.`;
62
- else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}건이 있어요.`;
63
- else if (s.counts.jobs) focus = `저장된 공고 ${s.counts.jobs}건 · 새 적합도 분석을 받아보세요.`;
64
- else focus = '오늘도 한 걸음 나아가 볼까요.';
65
- return PageHead({ title: name ? `${name}님, 안녕하세요` : '환영합니다', desc: focus });
61
+ if (firstRun) focus = t('home.greeting.firstRun');
62
+ else if (s.interview_todo.length) focus = t('home.greeting.interviewTodo', { count: s.interview_todo.length });
63
+ else if (s.in_progress.length) focus = t('home.greeting.inProgress', { count: s.in_progress.length });
64
+ else if (s.counts.jobs) focus = t('home.greeting.jobsSaved', { count: s.counts.jobs });
65
+ else focus = t('home.greeting.idle');
66
+ return PageHead({ title: name ? t('home.greeting.title', { name }) : t('home.greeting.welcome'), desc: focus });
66
67
  }
67
68
 
68
69
  /* ----------------------------------------------- getting started (the one guidance surface) */
69
70
  function GettingStarted(o) {
70
71
  const steps = [
71
- { done: o.has_profile, label: '기본 프로필 작성', to: '/profile' },
72
- { done: o.has_resume || o.has_experience, label: '이력서 또는 경력 추가', to: '/profile' },
73
- { done: o.has_cover_letter, label: '기존 자기소개서 등록', to: '/documents' },
74
- { done: o.has_job, label: '관심 공고 저장', to: '/jobs' },
72
+ { done: o.has_profile, label: t('home.start.step.profile'), to: '/profile' },
73
+ { done: o.has_resume, label: t('home.start.step.resume'), to: '/documents/career' },
74
+ { done: o.has_cover_letter, label: t('home.start.step.coverLetter'), to: '/documents' },
75
+ { done: o.has_job, label: t('home.start.step.job'), to: '/jobs' },
75
76
  ];
76
77
  return Card({
77
- title: '시작하기',
78
- sub: `프로필 완성도 ${o.profile_completeness}%`,
78
+ title: t('home.start.title'),
79
+ sub: t('home.start.completeness', { pct: o.profile_completeness }),
79
80
  body: [
80
81
  // The one place (per DESIGN_GUIDE rule 4) that teaches the core mental model:
81
82
  // CareerMate stores; your own AI does the thinking. Without this, first-run users
@@ -83,12 +84,12 @@ function GettingStarted(o) {
83
84
  el('div', { class: 'callout mb-3' },
84
85
  icon('info'),
85
86
  el('div', {},
86
- el('div', { class: 'callout__title' }, '두뇌는 당신의 AI, 보관은 CareerMate'),
87
+ el('div', { class: 'callout__title' }, t('home.start.calloutTitle')),
87
88
  el('div', { class: 'callout__body' },
88
- '공고 분석·자기소개서·면접 준비는 평소 쓰던 AI에게 말로 시키면, 결과가 이 대시보드에 쌓여요.'))),
89
+ t('home.start.calloutBody')))),
89
90
  el('div', { class: 'progress' }, el('div', { class: 'progress__bar', style: { width: `${o.profile_completeness}%` } })),
90
91
  el('p', { class: 'text-secondary text-sm mt-3 mb-2' },
91
- '완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
92
+ t('home.start.note')),
92
93
  el('div', {}, ...steps.map((it) => CheckRow({ done: it.done, label: it.label, onClick: () => navigate(it.to) }))),
93
94
  ],
94
95
  });
@@ -102,10 +103,10 @@ function StatTile(label, value, iconName, to) {
102
103
  }
103
104
  function StatRow(counts) {
104
105
  return el('div', { class: 'grid grid--4' },
105
- StatTile('저장 공고', counts.jobs, 'briefcase', '/jobs'),
106
- StatTile('진행 중 지원', counts.active_applications, 'layers', '/applications'),
107
- StatTile('자기소개서', counts.cover_letters, 'file', '/documents'),
108
- StatTile('면접 준비', counts.interview_pending, 'mic', '/interview'),
106
+ StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs'),
107
+ StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications'),
108
+ StatTile(t('home.stat.coverLetters'), counts.cover_letters, 'file', '/documents'),
109
+ StatTile(t('home.stat.interviewPending'), counts.interview_pending, 'mic', '/interview'),
109
110
  );
110
111
  }
111
112
 
@@ -119,13 +120,13 @@ function Pipeline(breakdown) {
119
120
  let body;
120
121
  if (total === 0) {
121
122
  body = el('p', { class: 'muted', style: { margin: 0 } },
122
- '아직 지원 단계가 없어요. 공고에 지원 상태를 표시하면 여기에 파이프라인이 채워져요.');
123
+ t('home.pipeline.empty'));
123
124
  } else {
124
125
  const segs = PIPE_ORDER.filter((st) => c(st) > 0).map((st) =>
125
126
  el('div', {
126
127
  class: 'pipebar__seg',
127
128
  style: { flexGrow: String(c(st)), background: STATUS_COLOR[st] },
128
- title: `${breakdown.find((b) => b.status === st).label} ${c(st)}`,
129
+ title: `${t('status.' + st)} ${c(st)}`,
129
130
  onClick: open,
130
131
  }));
131
132
 
@@ -134,13 +135,13 @@ function Pipeline(breakdown) {
134
135
  const intv = c('interview') + c('final_passed');
135
136
  const fin = c('final_passed');
136
137
  const pct = (num, den) => (den > 0 ? Math.round((num / den) * 100) : null);
137
- const conv = (num, den, label) => (pct(num, den) != null ? `${label} ${pct(num, den)}%` : null);
138
+ const conv = (num, den, key) => (pct(num, den) != null ? t(key, { pct: pct(num, den) }) : null);
138
139
 
139
140
  const tiles = [
140
- { st: 'applied', label: '지원', value: reached, conv: total ? `전체 ${total}건` : null },
141
- { st: 'document_passed', label: '서류 합격', value: docPass, conv: conv(docPass, reached, '지원 대비') },
142
- { st: 'interview', label: '면접', value: intv, conv: conv(intv, reached, '지원 대비') },
143
- { st: 'final_passed', label: '최종 합격', value: fin, conv: conv(fin, intv, '면접 대비') || conv(fin, reached, '지원 대비') },
141
+ { st: 'applied', label: t('home.pipeline.stage.applied'), value: reached, conv: total ? t('home.pipeline.conv.total', { count: total }) : null },
142
+ { st: 'document_passed', label: t('home.pipeline.stage.documentPassed'), value: docPass, conv: conv(docPass, reached, 'home.pipeline.conv.vsApplied') },
143
+ { st: 'interview', label: t('home.pipeline.stage.interview'), value: intv, conv: conv(intv, reached, 'home.pipeline.conv.vsApplied') },
144
+ { st: 'final_passed', label: t('home.pipeline.stage.finalPassed'), value: fin, conv: conv(fin, intv, 'home.pipeline.conv.vsInterview') || conv(fin, reached, 'home.pipeline.conv.vsApplied') },
144
145
  ];
145
146
 
146
147
  body = [
@@ -155,8 +156,8 @@ function Pipeline(breakdown) {
155
156
  }
156
157
 
157
158
  return Card({
158
- title: '지원 파이프라인',
159
- actions: Btn('보드 열기', { sm: true, variant: 'ghost', onClick: open }),
159
+ title: t('home.pipeline.title'),
160
+ actions: Btn(t('home.pipeline.openBoard'), { sm: true, variant: 'ghost', onClick: open }),
160
161
  body,
161
162
  });
162
163
  }
@@ -166,33 +167,67 @@ function statusDot(status) {
166
167
  return el('span', { class: 'list-row__dot', style: { background: statusColor(status) } });
167
168
  }
168
169
 
169
- function ActionLane(interviewTodo, inProgress) {
170
+ // deadline days_left (computed server-side) → coloured D-day badge.
171
+ function ddayBadge(daysLeft) {
172
+ if (daysLeft < 0) return Badge('rejected', t('applications.dday.past'));
173
+ if (daysLeft === 0) return Badge('rejected', t('applications.dday.today'));
174
+ return Badge('interview', t('applications.dday.future', { n: daysLeft }));
175
+ }
176
+
177
+ function ActionLane(s) {
170
178
  const rows = [];
171
- for (const it of interviewTodo) {
172
- rows.push(ListRow({
179
+ const seen = new Set();
180
+ // Dedup by job id: the same posting can surface in multiple lanes (e.g. a draft
181
+ // with a near deadline is also in_progress) — show it once, in its highest lane.
182
+ const push = (jobId, r) => {
183
+ if (rows.length >= 6 || (jobId && seen.has(jobId))) return;
184
+ if (jobId) seen.add(jobId);
185
+ rows.push(r);
186
+ };
187
+ // Priority: interview prep (post-screening, time-bound) → deadline-urgent postings
188
+ // → stale follow-ups → other in-progress. interview prep keeps its prior top slot.
189
+ for (const it of s.interview_todo || []) {
190
+ push(it.job.id, ListRow({
173
191
  leading: statusDot('document_passed'),
174
192
  title: it.job.company,
175
193
  sub: it.job.position,
176
- trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), '면접 준비'),
194
+ trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), t('home.actionLane.interviewPrep')),
195
+ onClick: () => navigate(`/jobs/${it.job.id}`),
196
+ }));
197
+ }
198
+ for (const it of s.deadlines || []) {
199
+ push(it.job.id, ListRow({
200
+ leading: statusDot(it.status),
201
+ title: it.job.company,
202
+ sub: it.job.position,
203
+ trailing: ddayBadge(it.days_left),
204
+ onClick: () => navigate(`/jobs/${it.job.id}`),
205
+ }));
206
+ }
207
+ for (const it of s.followups || []) {
208
+ push(it.job.id, ListRow({
209
+ leading: statusDot('applied'),
210
+ title: it.job.company,
211
+ sub: it.job.position,
212
+ trailing: Badge('accent', t('home.actionLane.followup', { n: it.days_since })),
177
213
  onClick: () => navigate(`/jobs/${it.job.id}`),
178
214
  }));
179
215
  }
180
- for (const it of inProgress) {
181
- if (rows.length >= 6) break;
182
- rows.push(ListRow({
216
+ for (const it of s.in_progress || []) {
217
+ push(it.job?.id, ListRow({
183
218
  leading: statusDot(it.application.status),
184
- title: it.job?.company || '—',
219
+ title: it.job?.company || t('home.placeholder.dash'),
185
220
  sub: it.job?.position || '',
186
- trailing: Badge(it.application.status, it.status_label),
221
+ trailing: Badge(it.application.status, t('status.' + it.application.status)),
187
222
  onClick: it.job ? () => navigate(`/jobs/${it.job.id}`) : null,
188
223
  }));
189
224
  }
190
225
  return Card({
191
- title: '이번에 할 일',
192
- actions: rows.length ? Btn('전체 보기', { sm: true, variant: 'ghost', onClick: () => navigate('/applications') }) : null,
226
+ title: t('home.actionLane.title'),
227
+ actions: rows.length ? Btn(t('home.actionLane.viewAll'), { sm: true, variant: 'ghost', onClick: () => navigate('/applications') }) : null,
193
228
  body: rows.length
194
229
  ? el('div', {}, ...rows)
195
- : el('p', { class: 'muted', style: { margin: 0 } }, '지금 처리할 일이 없어요. 새 공고를 분석해 보세요.'),
230
+ : el('p', { class: 'muted', style: { margin: 0 } }, t('home.actionLane.empty')),
196
231
  });
197
232
  }
198
233
 
@@ -200,8 +235,8 @@ function ActionLane(interviewTodo, inProgress) {
200
235
  function RecentJobs(jobs) {
201
236
  if (!jobs.length) {
202
237
  return Card({
203
- title: '최근 공고',
204
- body: el('p', { class: 'muted', style: { margin: 0 } }, '저장된 공고가 아직 없어요. 새 공고를 추가해 보세요.'),
238
+ title: t('home.recentJobs.title'),
239
+ body: el('p', { class: 'muted', style: { margin: 0 } }, t('home.recentJobs.empty')),
205
240
  });
206
241
  }
207
242
  const rows = jobs.map((j) => ListRow({
@@ -209,15 +244,15 @@ function RecentJobs(jobs) {
209
244
  sub: j.position,
210
245
  trailing: [
211
246
  j.fit_score != null
212
- ? el('span', { class: `strong tnum ${scoreClass(j.fit_score)}` }, `${j.fit_score}점`)
213
- : el('span', { class: 'muted text-sm' }, '—'),
214
- Badge(j.status, j.status_label),
247
+ ? el('span', { class: `strong tnum ${scoreClass(j.fit_score)}` }, t('home.recentJobs.score', { score: j.fit_score }))
248
+ : el('span', { class: 'muted text-sm' }, t('home.placeholder.dash')),
249
+ Badge(j.status, t('status.' + j.status)),
215
250
  ],
216
251
  onClick: () => navigate(`/jobs/${j.id}`),
217
252
  }));
218
253
  return Card({
219
- title: '최근 공고',
220
- actions: Btn('전체 보기', { sm: true, variant: 'ghost', onClick: () => navigate('/jobs') }),
254
+ title: t('home.recentJobs.title'),
255
+ actions: Btn(t('home.recentJobs.viewAll'), { sm: true, variant: 'ghost', onClick: () => navigate('/jobs') }),
221
256
  body: el('div', {}, ...rows),
222
257
  });
223
258
  }
@@ -226,8 +261,8 @@ function RecentJobs(jobs) {
226
261
  function Activity(acts) {
227
262
  if (!acts.length) {
228
263
  return Card({
229
- title: '최근 활동',
230
- body: el('p', { class: 'muted', style: { margin: 0 } }, '아직 활동 내역이 없어요. 공고를 저장하거나 분석하면 여기에 기록돼요.'),
264
+ title: t('home.activity.title'),
265
+ body: el('p', { class: 'muted', style: { margin: 0 } }, t('home.activity.empty')),
231
266
  });
232
267
  }
233
268
  const body = [];
@@ -242,7 +277,7 @@ function Activity(acts) {
242
277
  el('time', { class: 'feed-item__time', title: fmtDate(a.created_at), attrs: { datetime: a.created_at } }, fmtRelative(a.created_at)))));
243
278
  }
244
279
  }
245
- return Card({ title: '최근 활동', body: el('div', {}, ...body) });
280
+ return Card({ title: t('home.activity.title'), body: el('div', {}, ...body) });
246
281
  }
247
282
 
248
283
  function groupByDay(acts) {
@@ -253,7 +288,7 @@ function groupByDay(acts) {
253
288
  const buckets = new Map();
254
289
  for (const a of acts) {
255
290
  const day = midnight(a.created_at);
256
- const label = day === today ? '오늘' : day === yesterday ? '어제' : fmtDate(a.created_at);
291
+ const label = day === today ? t('home.activity.today') : day === yesterday ? t('home.activity.yesterday') : fmtDate(a.created_at);
257
292
  if (!buckets.has(label)) { buckets.set(label, []); order.push(label); }
258
293
  buckets.get(label).push(a);
259
294
  }
@@ -1,9 +1,15 @@
1
- // Interview — preparation materials for jobs that reached 서류 합격 or beyond.
1
+ // Interview — preparation materials saved for jobs, plus timely interview-stage to-dos.
2
2
  import {
3
3
  el, get, put, icon, navigate, Card, Badge, Btn, SubmitBtn, EmptyState, Field, Input,
4
- Textarea, openModal, closeModal, toastOk, copyText,
4
+ Textarea, Select, openModal, closeModal, toastOk, copyText,
5
5
  downloadUrl, mount,
6
6
  } from '/lib.js';
7
+ import { t } from '/i18n.js';
8
+
9
+ // Interview questions split into two tabs: 기술(1차) / 인성·컬처핏(2차). A question
10
+ // with no category is treated as technical (back-compat with preps saved before tabs).
11
+ const CATEGORIES = ['technical', 'behavioral'];
12
+ const catOf = (q) => (q && q.category === 'behavioral' ? 'behavioral' : 'technical');
7
13
 
8
14
  export async function render(ctx) {
9
15
  const data = await get('/api/interview');
@@ -17,9 +23,9 @@ export async function render(ctx) {
17
23
  if (!eligible.length) {
18
24
  wrap.append(EmptyState({
19
25
  iconName: 'mic',
20
- title: '아직 면접 준비할 공고가 없어요',
21
- body: '서류 합격 이상 상태가 되면 그 공고의 면접 준비를 여기서 할 수 있어요.',
22
- action: Btn('지원 현황으로 가기', { icon: 'layers', variant: 'primary', onClick: () => navigate('/applications') }),
26
+ title: t('interview.empty.title'),
27
+ body: t('interview.empty.body'),
28
+ action: Btn(t('interview.empty.cta'), { icon: 'briefcase', variant: 'primary', onClick: () => navigate('/jobs') }),
23
29
  }));
24
30
  mount(ctx.view, wrap);
25
31
  ctx.setActions([]);
@@ -39,27 +45,35 @@ export async function render(ctx) {
39
45
 
40
46
  const body = [];
41
47
  if (hasPrep) {
42
- const qCount = (prep.questions || []).length;
48
+ const qs = prep.questions || [];
49
+ const techN = qs.filter((q) => catOf(q) === 'technical').length;
50
+ const behN = qs.filter((q) => catOf(q) === 'behavioral').length;
43
51
  const sCount = (prep.star_guides || []).length;
44
52
  const hasIntro = !!(prep.self_introduction && prep.self_introduction.trim());
45
53
  body.push(el('p', { class: 'text-secondary', style: { margin: 0 } },
46
- `예상 질문 ${qCount}개 · STAR 가이드 ${sCount}개 · 1분 자기소개 ${hasIntro ? '있음' : '없음'}`));
54
+ t('interview.card.summary', {
55
+ q: behN > 0
56
+ ? t('interview.card.questionsSplit', { tech: techN, beh: behN })
57
+ : t('interview.card.questionsCount', { count: qs.length }),
58
+ s: t('interview.card.starCount', { count: sCount }),
59
+ intro: hasIntro ? t('interview.card.introYes') : t('interview.card.introNo'),
60
+ })));
47
61
  } else {
48
62
  body.push(el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
49
- '예상 질문과 1분 자기소개를 정리해 두면 한곳에서 연습할 수 있어요.'));
63
+ t('interview.card.hint')));
50
64
  }
51
65
 
52
66
  const actions = [];
53
67
  if (hasPrep) {
54
- actions.push(Btn('자세히 보기', { icon: 'external', sm: true, onClick: () => openPrepModal(entry, prep) }));
68
+ actions.push(Btn(t('interview.card.view'), { icon: 'external', sm: true, onClick: () => openPrepModal(entry, prep) }));
55
69
  } else {
56
- actions.push(Btn('직접 작성', { icon: 'edit', sm: true, onClick: () => openEditModal(entry, null) }));
70
+ actions.push(Btn(t('interview.card.create'), { icon: 'edit', sm: true, onClick: () => openEditModal(entry, null) }));
57
71
  }
58
72
 
59
73
  return Card({
60
74
  title: `${job.company || '—'} · ${job.position || ''}`,
61
75
  sub: undefined,
62
- actions: [Badge(entry.status, entry.status_label), ...actions],
76
+ actions: [Badge(entry.status, t('status.' + entry.status)), ...actions],
63
77
  body,
64
78
  });
65
79
  }
@@ -70,66 +84,93 @@ export async function render(ctx) {
70
84
  const jobId = prep.job_id;
71
85
  const sections = el('div', { class: 'stack-4' });
72
86
 
73
- // 예상 질문 & 꼬리 질문
87
+ // 예상 질문 & 꼬리 질문 — 기술 / 인성·컬처핏 탭
74
88
  const questions = prep.questions || [];
75
89
  sections.append(Card({
76
- title: '예상 질문 & 꼬리 질문',
90
+ title: t('interview.modal.questionsTitle'),
77
91
  body: questions.length
78
- ? el('div', { class: 'stack-3' }, ...questions.map(QuestionBlock))
79
- : el('p', { class: 'muted', style: { margin: 0 } }, '등록된 예상 질문이 없습니다.'),
92
+ ? QuestionTabs(questions)
93
+ : el('p', { class: 'muted', style: { margin: 0 } }, t('interview.modal.questionsEmpty')),
80
94
  }));
81
95
 
82
96
  // STAR 답변 가이드
83
97
  const stars = prep.star_guides || [];
84
98
  sections.append(Card({
85
- title: 'STAR 답변 가이드',
99
+ title: t('interview.modal.starTitle'),
86
100
  body: stars.length
87
101
  ? el('div', { class: 'stack-3' }, ...stars.map(StarBlock))
88
- : el('p', { class: 'muted', style: { margin: 0 } }, '등록된 STAR 가이드가 없습니다.'),
102
+ : el('p', { class: 'muted', style: { margin: 0 } }, t('interview.modal.starEmpty')),
89
103
  }));
90
104
 
91
105
  // 1분 자기소개
92
106
  const intro = prep.self_introduction || '';
93
107
  sections.append(Card({
94
- title: '1분 자기소개',
95
- actions: intro.trim() && Btn('복사', { icon: 'copy', sm: true, onClick: () => copyText(intro) }),
108
+ title: t('interview.modal.introTitle'),
109
+ actions: intro.trim() && Btn(t('interview.modal.introCopy'), { icon: 'copy', sm: true, onClick: () => copyText(intro) }),
96
110
  body: intro.trim()
97
111
  ? el('div', { class: 'doc-preview' }, intro)
98
- : el('p', { class: 'muted', style: { margin: 0 } }, '작성된 자기소개가 없습니다.'),
112
+ : el('p', { class: 'muted', style: { margin: 0 } }, t('interview.modal.introEmpty')),
99
113
  }));
100
114
 
101
115
  // 메모
102
116
  if (prep.notes && prep.notes.trim()) {
103
117
  sections.append(Card({
104
- title: '메모',
118
+ title: t('interview.modal.notesTitle'),
105
119
  body: el('div', { class: 'doc-preview' }, prep.notes),
106
120
  }));
107
121
  }
108
122
 
109
123
  openModal({
110
- title: `${job.company || ''} 면접 준비`,
124
+ title: t('interview.modal.title', { company: job.company || '' }),
111
125
  size: 'lg',
112
126
  body: sections,
113
127
  footer: (close) => [
114
- Btn('MD 내보내기', { icon: 'download', onClick: () => downloadUrl(`/api/export/interview/${jobId}?format=md`) }),
115
- Btn('HTML 내보내기', { icon: 'download', onClick: () => downloadUrl(`/api/export/interview/${jobId}?format=html`) }),
116
- Btn('수정', { icon: 'edit', variant: 'primary', onClick: () => { close(); openEditModal(entry, prep); } }),
128
+ Btn(t('interview.modal.exportMd'), { icon: 'download', onClick: () => downloadUrl(`/api/export/interview/${jobId}?format=md`) }),
129
+ Btn(t('interview.modal.exportHtml'), { icon: 'download', onClick: () => downloadUrl(`/api/export/interview/${jobId}?format=html`) }),
130
+ Btn(t('interview.modal.edit'), { icon: 'edit', variant: 'primary', onClick: () => { close(); openEditModal(entry, prep); } }),
117
131
  ],
118
132
  });
119
133
  }
120
134
 
135
+ // Two-tab view over the question list. Opens on the first tab that has content.
136
+ function QuestionTabs(questions) {
137
+ const groups = {
138
+ technical: questions.filter((q) => catOf(q) === 'technical'),
139
+ behavioral: questions.filter((q) => catOf(q) === 'behavioral'),
140
+ };
141
+ let active = groups.technical.length === 0 && groups.behavioral.length ? 'behavioral' : 'technical';
142
+ const tabEls = {};
143
+ const panel = el('div', {});
144
+ function draw() {
145
+ const list = groups[active];
146
+ mount(panel, list.length
147
+ ? el('div', { class: 'stack-3' }, ...list.map(QuestionBlock))
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);
150
+ }
151
+ const tabs = el('div', { class: 'tabs' }, ...CATEGORIES.map((c) => {
152
+ 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));
155
+ tabEls[c] = tabEl;
156
+ return tabEl;
157
+ }));
158
+ draw();
159
+ return el('div', { class: 'stack-3' }, tabs, panel);
160
+ }
161
+
121
162
  function QuestionBlock(q) {
122
163
  const node = el('div', { class: 'stack-2', style: { paddingBottom: '6px' } });
123
164
  node.append(el('div', { class: 'strong' }, q.question || ''));
124
- if (q.intent) node.append(el('div', { class: 'muted text-sm' }, `의도: ${q.intent}`));
165
+ if (q.intent) node.append(el('div', { class: 'muted text-sm' }, t('interview.question.intent', { intent: q.intent })));
125
166
  const followups = (q.followups || []).filter((f) => f && String(f).trim());
126
167
  if (followups.length) {
127
- node.append(el('div', { class: 'text-sm text-secondary', style: { marginTop: '2px' } }, '꼬리 질문'));
168
+ node.append(el('div', { class: 'text-sm text-secondary', style: { marginTop: '2px' } }, t('interview.question.followups')));
128
169
  node.append(el('ul', { class: 'stack-2', style: { margin: '2px 0 0', paddingLeft: '18px' } },
129
170
  ...followups.map((f) => el('li', { class: 'text-sm text-secondary' }, f))));
130
171
  }
131
172
  if (q.answer_outline && String(q.answer_outline).trim()) {
132
- node.append(el('div', { class: 'muted text-sm', style: { marginTop: '4px' } }, '답변 가이드'));
173
+ node.append(el('div', { class: 'muted text-sm', style: { marginTop: '4px' } }, t('interview.question.answerGuide')));
133
174
  node.append(el('div', { class: 'doc-preview', style: { marginTop: '2px' } }, q.answer_outline));
134
175
  }
135
176
  return node;
@@ -138,15 +179,15 @@ export async function render(ctx) {
138
179
  function StarBlock(g) {
139
180
  const rows = [];
140
181
  const add = (label, val) => { if (val && String(val).trim()) { rows.push(el('dt', {}, label)); rows.push(el('dd', {}, val)); } };
141
- add('상황', g.situation);
142
- add('과제', g.task);
143
- add('행동', g.action);
144
- add('결과', g.result);
182
+ add(t('interview.star.situation'), g.situation);
183
+ add(t('interview.star.task'), g.task);
184
+ add(t('interview.star.action'), g.action);
185
+ add(t('interview.star.result'), g.result);
145
186
  return el('div', { class: 'stack-2', style: { paddingBottom: '6px' } },
146
187
  el('div', { class: 'strong' }, g.question || ''),
147
188
  rows.length
148
189
  ? el('dl', { class: 'kv' }, ...rows)
149
- : el('div', { class: 'muted text-sm' }, 'S/T/A/R 내용이 비어 있습니다.'));
190
+ : el('div', { class: 'muted text-sm' }, t('interview.star.empty')));
150
191
  }
151
192
 
152
193
  // -------------------------------------------------- manual edit / create
@@ -158,12 +199,12 @@ export async function render(ctx) {
158
199
  const introInput = Textarea({
159
200
  value: prep.self_introduction || '',
160
201
  rows: 5,
161
- placeholder: '1분 분량(약 300~400자)의 자기소개를 작성하세요.',
202
+ placeholder: t('interview.edit.introPlaceholder'),
162
203
  });
163
204
  const notesInput = Textarea({
164
205
  value: prep.notes || '',
165
206
  rows: 3,
166
- placeholder: '면접 준비 메모를 자유롭게 적어두세요.',
207
+ placeholder: t('interview.edit.notesPlaceholder'),
167
208
  });
168
209
 
169
210
  // Repeatable question list: question + answer_outline.
@@ -171,43 +212,56 @@ export async function render(ctx) {
171
212
  const qRows = [];
172
213
  function addQuestionRow(q) {
173
214
  q = q || {};
174
- const qInput = Input({ value: q.question || '', placeholder: '예상 질문' });
175
- const aInput = Textarea({ value: q.answer_outline || '', rows: 2, placeholder: '답변 가이드 (선택)' });
215
+ const qInput = Input({ value: q.question || '', placeholder: t('interview.edit.questionPlaceholder') });
216
+ const aInput = Textarea({ value: q.answer_outline || '', rows: 2, placeholder: t('interview.edit.answerPlaceholder') });
217
+ const catSelect = Select(
218
+ CATEGORIES.map((c) => ({ value: c, label: t('interview.tab.' + c), selected: catOf(q) === c })),
219
+ { attrs: { 'aria-label': t('interview.edit.fieldCategory') } },
220
+ );
221
+ catSelect.classList.add('select--sm');
176
222
  const row = el('div', { class: 'subcard stack-2' },
177
223
  el('div', { class: 'flex between center' },
178
- el('span', { class: 'text-sm muted' }, `질문 ${qRows.length + 1}`),
179
- Btn('삭제', { icon: 'trash', sm: true, variant: 'ghost', onClick: () => { entryRef.removed = true; row.remove(); } })),
180
- Field('질문', qInput),
181
- Field('답변 가이드', aInput));
182
- const entryRef = { qInput, aInput, removed: false };
224
+ el('span', { class: 'text-sm muted' }, t('interview.edit.questionNum', { n: qRows.length + 1 })),
225
+ el('div', { class: 'flex gap-2 center' },
226
+ catSelect,
227
+ Btn(t('interview.edit.delete'), { icon: 'trash', sm: true, variant: 'ghost', onClick: () => { entryRef.removed = true; row.remove(); } }))),
228
+ Field(t('interview.edit.fieldQuestion'), qInput),
229
+ Field(t('interview.edit.fieldAnswer'), aInput));
230
+ const entryRef = { qInput, aInput, catSelect, removed: false };
183
231
  qRows.push(entryRef);
184
232
  qList.append(row);
185
233
  }
186
234
  (prep.questions || []).forEach(addQuestionRow);
187
235
 
188
236
  const formBody = el('div', { class: 'stack-4' },
189
- Field('1분 자기소개', introInput),
190
- Field('메모', notesInput),
237
+ Field(t('interview.edit.introLabel'), introInput),
238
+ Field(t('interview.edit.notesLabel'), notesInput),
191
239
  el('div', { class: 'stack-2' },
192
240
  el('div', { class: 'flex between center' },
193
- el('label', {}, '예상 질문'),
194
- Btn('질문 추가', { icon: 'plus', sm: true, onClick: () => addQuestionRow(null) })),
241
+ el('label', {}, t('interview.edit.questionsLabel')),
242
+ Btn(t('interview.edit.addQuestion'), { icon: 'plus', sm: true, onClick: () => addQuestionRow(null) })),
195
243
  qList));
196
244
 
197
245
  openModal({
198
- title: `${job.company || ''} 면접 준비 ${Object.keys(prep).length ? '수정' : '작성'}`,
246
+ title: Object.keys(prep).length
247
+ ? t('interview.edit.titleEdit', { company: job.company || '' })
248
+ : t('interview.edit.titleCreate', { company: job.company || '' }),
199
249
  size: 'lg',
200
250
  body: formBody,
201
251
  footer: (close) => [
202
- Btn('취소', { onClick: close }),
203
- SubmitBtn('면접 준비 저장', () => save(jobId)),
252
+ Btn(t('interview.edit.cancel'), { onClick: close }),
253
+ SubmitBtn(t('interview.edit.save'), () => save(jobId)),
204
254
  ],
205
255
  });
206
256
 
207
257
  async function save(id) {
208
258
  const questions = qRows
209
259
  .filter((r) => !r.removed)
210
- .map((r) => ({ question: r.qInput.value.trim(), answer_outline: r.aInput.value.trim() }))
260
+ .map((r) => ({
261
+ question: r.qInput.value.trim(),
262
+ category: r.catSelect.value,
263
+ answer_outline: r.aInput.value.trim(),
264
+ }))
211
265
  .filter((q) => q.question);
212
266
  // Preserve any richer star_guides authored elsewhere (AI/MCP).
213
267
  const payload = {
@@ -219,7 +273,7 @@ export async function render(ctx) {
219
273
  // Throws on failure so the SubmitBtn surfaces it inline.
220
274
  await put(`/api/jobs/${id}/interview`, payload);
221
275
  closeModal();
222
- toastOk('면접 준비를 저장했어요.');
276
+ toastOk(t('interview.edit.saved'));
223
277
  await ctx.refreshNav();
224
278
  await render(ctx);
225
279
  }