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.
- package/AGENTS.md +11 -7
- package/INSTALL.md +18 -16
- package/README.md +28 -26
- package/THIRD_PARTY_NOTICES.md +2 -3
- package/bin/careermate.mjs +8 -1
- package/dist/career-os/README.md +8 -3
- package/dist/career-os/eop/career-description.md +136 -0
- package/dist/career-os/eop/cover-letter.md +7 -5
- package/dist/career-os/eop/fit-analysis.md +15 -5
- package/dist/career-os/eop/interview-prep.md +209 -21
- package/dist/career-os/eop/job-analysis.md +5 -3
- package/dist/career-os/eop/profile-extraction.md +5 -4
- package/dist/career-os/knowledge/cover-letter.md +4 -4
- package/dist/career-os/knowledge/fit-matching.md +8 -8
- package/dist/career-os/knowledge/interview-behavioral.md +76 -8
- package/dist/career-os/knowledge/interview-technical.md +106 -4
- package/dist/career-os/knowledge/linkedin-profile.md +1 -1
- package/dist/career-os/knowledge/networking-referrals.md +1 -1
- package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/dist/career-os/knowledge/portfolio.md +3 -3
- package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/dist/doctor.mjs +1019 -127
- package/dist/mcp.mjs +1036 -126
- package/dist/migrate.mjs +52 -1
- package/dist/public/app.js +42 -16
- package/dist/public/i18n/en/applications.js +23 -0
- package/dist/public/i18n/en/common.js +22 -0
- package/dist/public/i18n/en/documents.js +133 -0
- package/dist/public/i18n/en/home.js +75 -0
- package/dist/public/i18n/en/interview.js +69 -0
- package/dist/public/i18n/en/jobs.js +152 -0
- package/dist/public/i18n/en/nav.js +18 -0
- package/dist/public/i18n/en/profile.js +201 -0
- package/dist/public/i18n/en/settings.js +127 -0
- package/dist/public/i18n/en/status.js +22 -0
- package/dist/public/i18n/en.js +17 -0
- package/dist/public/i18n/ko/applications.js +23 -0
- package/dist/public/i18n/ko/common.js +22 -0
- package/dist/public/i18n/ko/documents.js +133 -0
- package/dist/public/i18n/ko/home.js +63 -0
- package/dist/public/i18n/ko/interview.js +69 -0
- package/dist/public/i18n/ko/jobs.js +152 -0
- package/dist/public/i18n/ko/nav.js +18 -0
- package/dist/public/i18n/ko/profile.js +201 -0
- package/dist/public/i18n/ko/settings.js +127 -0
- package/dist/public/i18n/ko/status.js +23 -0
- package/dist/public/i18n/ko.js +18 -0
- package/dist/public/i18n.js +55 -0
- package/dist/public/index.html +1 -0
- package/dist/public/lib.js +25 -17
- package/dist/public/pages/applications.js +17 -21
- package/dist/public/pages/documents.js +153 -107
- package/dist/public/pages/home.js +88 -53
- package/dist/public/pages/interview.js +105 -51
- package/dist/public/pages/jobs.js +330 -106
- package/dist/public/pages/profile.js +157 -152
- package/dist/public/pages/settings.js +270 -72
- package/dist/public/styles.css +38 -3
- package/dist/web.mjs +859 -103
- package/docs/FAQ.md +26 -5
- package/docs/MCP_TOOLS.md +26 -12
- package/docs/START_WORKFLOW.md +2 -2
- package/docs/SUPPORTED_AI_APPS.md +13 -0
- package/docs/WORKFLOWS.md +80 -23
- package/docs/career-os/README.md +8 -3
- package/docs/career-os/eop/career-description.md +136 -0
- package/docs/career-os/eop/cover-letter.md +7 -5
- package/docs/career-os/eop/fit-analysis.md +15 -5
- package/docs/career-os/eop/interview-prep.md +209 -21
- package/docs/career-os/eop/job-analysis.md +5 -3
- package/docs/career-os/eop/profile-extraction.md +5 -4
- package/docs/career-os/knowledge/cover-letter.md +4 -4
- package/docs/career-os/knowledge/fit-matching.md +8 -8
- package/docs/career-os/knowledge/interview-behavioral.md +76 -8
- package/docs/career-os/knowledge/interview-technical.md +106 -4
- package/docs/career-os/knowledge/linkedin-profile.md +1 -1
- package/docs/career-os/knowledge/networking-referrals.md +1 -1
- package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/docs/career-os/knowledge/portfolio.md +3 -3
- package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/package.json +2 -2
- package/packages/core/src/onboarding.ts +2 -1
- package/packages/core/src/services.ts +138 -21
- package/packages/core/src/summary.ts +59 -1
- package/packages/core/src/verify/lint.ts +4 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +0 -0
- package/packages/db/src/schema.ts +17 -0
- package/packages/exporters/src/index.ts +28 -17
- package/packages/knowledge/src/index.ts +46 -7
- package/packages/mcp-tools/src/bridge.ts +194 -13
- package/packages/mcp-tools/src/tools.ts +390 -38
- package/packages/mcp-tools/src/update.ts +31 -16
- package/packages/parsers/src/documents.ts +12 -1
- package/packages/prompts/src/humanize.ts +46 -0
- package/packages/prompts/src/index.ts +5 -1
- package/packages/prompts/src/install.ts +4 -4
- package/packages/prompts/src/onboarding.ts +4 -4
- package/packages/prompts/src/system.ts +17 -8
- package/packages/shared/src/enums.ts +14 -4
- package/packages/shared/src/schemas.ts +41 -0
- package/packages/workflows/src/definitions.ts +47 -10
- package/scripts/init.ts +284 -13
- package/dist/site/analytics.js +0 -97
- package/dist/site/favicon.svg +0 -1
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +0 -565
- package/dist/site/landing.js +0 -54
- package/dist/site/llms-install.txt +0 -218
- package/dist/site/robots.txt +0 -4
- package/dist/site/sitemap.xml +0 -21
- package/dist/site/start.html +0 -353
- package/dist/site/style.css +0 -559
- 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('
|
|
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
|
|
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 =
|
|
62
|
-
else if (s.in_progress.length) focus =
|
|
63
|
-
else if (s.counts.jobs) focus =
|
|
64
|
-
else focus = '
|
|
65
|
-
return PageHead({ title: name ?
|
|
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: '
|
|
72
|
-
{ done: o.has_resume
|
|
73
|
-
{ done: o.has_cover_letter, label: '
|
|
74
|
-
{ done: o.has_job, label: '
|
|
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:
|
|
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' }, '
|
|
87
|
+
el('div', { class: 'callout__title' }, t('home.start.calloutTitle')),
|
|
87
88
|
el('div', { class: 'callout__body' },
|
|
88
|
-
'
|
|
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
|
-
'
|
|
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('
|
|
106
|
-
StatTile('
|
|
107
|
-
StatTile('
|
|
108
|
-
StatTile('
|
|
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: `${
|
|
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,
|
|
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: '
|
|
141
|
-
{ st: 'document_passed', label: '
|
|
142
|
-
{ st: 'interview', label: '
|
|
143
|
-
{ st: 'final_passed', label: '
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
181
|
-
|
|
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.
|
|
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('
|
|
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)}` },
|
|
213
|
-
: el('span', { class: 'muted text-sm' }, '
|
|
214
|
-
Badge(j.status, j.
|
|
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('
|
|
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: '
|
|
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 ? '
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
63
|
+
t('interview.card.hint')));
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
const actions = [];
|
|
53
67
|
if (hasPrep) {
|
|
54
|
-
actions.push(Btn('
|
|
68
|
+
actions.push(Btn(t('interview.card.view'), { icon: 'external', sm: true, onClick: () => openPrepModal(entry, prep) }));
|
|
55
69
|
} else {
|
|
56
|
-
actions.push(Btn('
|
|
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.
|
|
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
|
-
?
|
|
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: '
|
|
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 } }, '
|
|
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: '
|
|
95
|
-
actions: intro.trim() && Btn('
|
|
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:
|
|
124
|
+
title: t('interview.modal.title', { company: job.company || '' }),
|
|
111
125
|
size: 'lg',
|
|
112
126
|
body: sections,
|
|
113
127
|
footer: (close) => [
|
|
114
|
-
Btn('
|
|
115
|
-
Btn('
|
|
116
|
-
Btn('
|
|
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' },
|
|
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('
|
|
142
|
-
add('
|
|
143
|
-
add('
|
|
144
|
-
add('
|
|
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' }, '
|
|
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: '
|
|
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' },
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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('
|
|
190
|
-
Field('
|
|
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('
|
|
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:
|
|
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('
|
|
203
|
-
SubmitBtn('
|
|
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) => ({
|
|
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
|
}
|