careermate 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +30 -10
  2. package/THIRD_PARTY_NOTICES.md +2 -1
  3. package/dist/career-os/eop/cover-letter.md +4 -3
  4. package/dist/career-os/knowledge/cover-letter.md +3 -3
  5. package/dist/career-os/knowledge/fit-matching.md +2 -0
  6. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  7. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  8. package/dist/doctor.mjs +18995 -801
  9. package/dist/mcp.mjs +19009 -815
  10. package/dist/migrate.mjs +83 -6
  11. package/dist/public/i18n/en/common.js +4 -0
  12. package/dist/public/i18n/en/documents.js +0 -10
  13. package/dist/public/i18n/en/home.js +5 -0
  14. package/dist/public/i18n/en/jobs.js +1 -1
  15. package/dist/public/i18n/en/profile.js +0 -5
  16. package/dist/public/i18n/ko/common.js +4 -0
  17. package/dist/public/i18n/ko/documents.js +0 -10
  18. package/dist/public/i18n/ko/home.js +5 -0
  19. package/dist/public/i18n/ko/jobs.js +1 -1
  20. package/dist/public/i18n/ko/profile.js +0 -5
  21. package/dist/public/lib.js +72 -0
  22. package/dist/public/pages/applications.js +10 -6
  23. package/dist/public/pages/documents.js +4 -7
  24. package/dist/public/pages/home.js +26 -6
  25. package/dist/public/pages/profile.js +2 -3
  26. package/dist/public/styles.css +39 -4
  27. package/dist/web.mjs +18547 -649
  28. package/docs/MCP_TOOLS.md +14 -7
  29. package/docs/career-os/eop/cover-letter.md +4 -3
  30. package/docs/career-os/knowledge/cover-letter.md +3 -3
  31. package/docs/career-os/knowledge/fit-matching.md +2 -0
  32. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  33. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  34. package/package.json +3 -2
  35. package/packages/core/src/index.ts +1 -0
  36. package/packages/core/src/jobsearch.ts +186 -0
  37. package/packages/core/src/services.ts +24 -2
  38. package/packages/core/src/summary.ts +196 -0
  39. package/packages/core/src/verify/charcount.ts +87 -0
  40. package/packages/core/src/verify/index.ts +1 -0
  41. package/packages/core/src/verify/lint.ts +5 -0
  42. package/packages/db/src/repositories.ts +42 -8
  43. package/packages/db/src/schema.ts +29 -0
  44. package/packages/exporters/src/docx.ts +165 -0
  45. package/packages/exporters/src/index.ts +142 -9
  46. package/packages/exporters/src/markdown.ts +16 -11
  47. package/packages/knowledge/src/index.ts +8 -3
  48. package/packages/mcp-tools/src/tools.ts +173 -53
  49. package/packages/shared/src/schemas.ts +64 -0
  50. package/scripts/init.ts +5 -0
package/dist/migrate.mjs CHANGED
@@ -312,6 +312,32 @@ var MIGRATIONS = [
312
312
  );
313
313
 
314
314
  CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
315
+ `,
316
+ // v4 — structured quantitative credentials on the profile (학력·자격증·어학점수·수상).
317
+ // Stored as JSON arrays in TEXT columns (same pattern as profile.links), so the AI
318
+ // can cite exact values (GPA, TOEIC 920) without re-asking or hallucinating, and
319
+ // they feed the verify corpus. ALTER ADD COLUMN is non-destructive and idempotent
320
+ // across the ordered migration runner.
321
+ `
322
+ ALTER TABLE profile ADD COLUMN education TEXT NOT NULL DEFAULT '[]';
323
+ ALTER TABLE profile ADD COLUMN certifications TEXT NOT NULL DEFAULT '[]';
324
+ ALTER TABLE profile ADD COLUMN language_scores TEXT NOT NULL DEFAULT '[]';
325
+ ALTER TABLE profile ADD COLUMN awards TEXT NOT NULL DEFAULT '[]';
326
+ `,
327
+ // v5 — company research on the job posting (사업개요·인재상·핵심가치). Korea-centric: cover
328
+ // letters and interview prep align to a company's 인재상/핵심가치, so store it where the AI
329
+ // already reads it (the job context) rather than a separate entity. Additive ALTER, idempotent.
330
+ `
331
+ ALTER TABLE jobs ADD COLUMN company_overview TEXT;
332
+ ALTER TABLE jobs ADD COLUMN talent_profile TEXT;
333
+ ALTER TABLE jobs ADD COLUMN core_values TEXT NOT NULL DEFAULT '[]';
334
+ `,
335
+ // v6 — post-interview debrief on interview_preps (받은 질문·잘된 점·개선점·다음 라운드 포커스·
336
+ // 체감 분위기). Closes the loop: prep is pre-interview, debrief is post-interview, but both belong
337
+ // to the same per-job entity, so store as one JSON column rather than a new table. Additive ALTER,
338
+ // idempotent. Feeds next-round prep and consistency (lessons carry into later interviews).
339
+ `
340
+ ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
315
341
  `
316
342
  ];
317
343
  function migrate(db) {
@@ -4482,6 +4508,33 @@ var LinkSchema = external_exports.object({
4482
4508
  label: reqLine,
4483
4509
  url: SafeUrl
4484
4510
  });
4511
+ var EducationSchema = external_exports.object({
4512
+ school: reqLine.describe("\uD559\uAD50\uBA85"),
4513
+ degree: external_exports.string().max(MAX_LINE).optional().describe("\uD559\uC704/\uACFC\uC815 (\uACE0\uC878\xB7\uD559\uC0AC\xB7\uC11D\uC0AC\xB7\uBC15\uC0AC \uB4F1)"),
4514
+ major: external_exports.string().max(MAX_LINE).optional().describe("\uC804\uACF5"),
4515
+ gpa: external_exports.string().max(MAX_LINE).optional().describe("\uD559\uC810 (\uC608: 3.8)"),
4516
+ gpa_scale: external_exports.string().max(MAX_LINE).optional().describe("\uD559\uC810 \uB9CC\uC810 (\uC608: 4.5)"),
4517
+ start_date: external_exports.string().max(MAX_LINE).optional().describe("YYYY-MM"),
4518
+ end_date: external_exports.string().max(MAX_LINE).optional().describe("YYYY-MM (\uC878\uC5C5/\uC608\uC815)"),
4519
+ status: external_exports.string().max(MAX_LINE).optional().describe("\uC878\uC5C5/\uC7AC\uD559/\uD734\uD559/\uC911\uD1F4/\uC218\uB8CC")
4520
+ });
4521
+ var CertificationSchema = external_exports.object({
4522
+ name: reqLine.describe("\uC790\uACA9\uC99D/\uBA74\uD5C8\uBA85"),
4523
+ issuer: external_exports.string().max(MAX_LINE).optional().describe("\uBC1C\uAE09\uAE30\uAD00"),
4524
+ date: external_exports.string().max(MAX_LINE).optional().describe("\uCDE8\uB4DD\uC77C YYYY-MM"),
4525
+ score: external_exports.string().max(MAX_LINE).optional().describe("\uC810\uC218/\uB4F1\uAE09(\uC788\uC73C\uBA74)")
4526
+ });
4527
+ var LanguageScoreSchema = external_exports.object({
4528
+ test: reqLine.describe("\uC2DC\uD5D8\uBA85 (TOEIC\xB7OPIc\xB7TOEIC Speaking\xB7JLPT\xB7HSK \uB4F1)"),
4529
+ score: reqLine.describe("\uC810\uC218/\uB4F1\uAE09 (\uC608: 920, IH, N1)"),
4530
+ date: external_exports.string().max(MAX_LINE).optional().describe("\uC751\uC2DC/\uCDE8\uB4DD\uC77C YYYY-MM")
4531
+ });
4532
+ var AwardSchema = external_exports.object({
4533
+ title: reqLine.describe("\uC218\uC0C1/\uB300\uC678\uD65C\uB3D9\uBA85"),
4534
+ issuer: external_exports.string().max(MAX_LINE).optional().describe("\uC8FC\uCD5C/\uC218\uC5EC\uAE30\uAD00"),
4535
+ date: external_exports.string().max(MAX_LINE).optional().describe("YYYY-MM"),
4536
+ description: external_exports.string().max(MAX_NOTE).optional().describe("\uAC04\uB2E8 \uC124\uBA85")
4537
+ });
4485
4538
  var ProfileInputSchema = external_exports.object({
4486
4539
  name: reqLine.optional(),
4487
4540
  email: optLine,
@@ -4493,7 +4546,11 @@ var ProfileInputSchema = external_exports.object({
4493
4546
  desired_conditions: external_exports.string().max(MAX_NOTE).optional().describe("\uD76C\uB9DD \uADFC\uBB34 \uC870\uAC74 (\uC5F0\uBD09, \uC9C0\uC5ED, \uADFC\uBB34\uD615\uD0DC \uB4F1)"),
4494
4547
  preferred_tone: external_exports.string().max(MAX_LINE).optional().describe("\uC790\uAE30\uC18C\uAC1C\uC11C \uC120\uD638 \uBB38\uCCB4 (\uC608: \uB2F4\uBC31\uD558\uACE0 \uAD6C\uCCB4\uC801)"),
4495
4548
  emphasis_points: strList.optional().describe("\uAC15\uC870\uD558\uACE0 \uC2F6\uC740 \uD575\uC2EC \uD3EC\uC778\uD2B8"),
4496
- links: external_exports.array(LinkSchema).max(MAX_ITEMS).optional().describe("\uD3EC\uD2B8\uD3F4\uB9AC\uC624/\uAE43\uD5C8\uBE0C/\uB9C1\uD06C\uB4DC\uC778 \uB4F1")
4549
+ links: external_exports.array(LinkSchema).max(MAX_ITEMS).optional().describe("\uD3EC\uD2B8\uD3F4\uB9AC\uC624/\uAE43\uD5C8\uBE0C/\uB9C1\uD06C\uB4DC\uC778 \uB4F1"),
4550
+ education: external_exports.array(EducationSchema).max(MAX_ITEMS).optional().describe("\uD559\uB825 (\uD559\uAD50\xB7\uD559\uC704\xB7\uC804\uACF5\xB7\uD559\uC810)"),
4551
+ certifications: external_exports.array(CertificationSchema).max(MAX_ITEMS).optional().describe("\uC790\uACA9\uC99D/\uBA74\uD5C8"),
4552
+ language_scores: external_exports.array(LanguageScoreSchema).max(MAX_ITEMS).optional().describe("\uC5B4\uD559 \uC810\uC218 (\uD1A0\uC775\xB7\uC624\uD53D \uB4F1)"),
4553
+ awards: external_exports.array(AwardSchema).max(MAX_ITEMS).optional().describe("\uC218\uC0C1/\uB300\uC678\uD65C\uB3D9")
4497
4554
  });
4498
4555
  var ProfileRecordSchema = external_exports.object({
4499
4556
  ...baseRecord,
@@ -4507,7 +4564,11 @@ var ProfileRecordSchema = external_exports.object({
4507
4564
  desired_conditions: external_exports.string().nullable(),
4508
4565
  preferred_tone: external_exports.string().nullable(),
4509
4566
  emphasis_points: external_exports.array(external_exports.string()),
4510
- links: external_exports.array(LinkSchema)
4567
+ links: external_exports.array(LinkSchema),
4568
+ education: external_exports.array(EducationSchema),
4569
+ certifications: external_exports.array(CertificationSchema),
4570
+ language_scores: external_exports.array(LanguageScoreSchema),
4571
+ awards: external_exports.array(AwardSchema)
4511
4572
  });
4512
4573
  var ExperienceInputSchema = external_exports.object({
4513
4574
  company: reqLine,
@@ -4646,7 +4707,10 @@ var JobInputSchema = external_exports.object({
4646
4707
  requirements: strList.optional().describe("\uC790\uACA9\uC694\uAC74/\uC6B0\uB300\uC0AC\uD56D \uD575\uC2EC"),
4647
4708
  keywords: strList.optional().describe("\uD575\uC2EC \uD0A4\uC6CC\uB4DC"),
4648
4709
  deadline: external_exports.string().max(MAX_LINE).optional().describe("\uB9C8\uAC10\uC77C YYYY-MM-DD"),
4649
- source: external_exports.string().max(MAX_LINE).optional().describe("\uCD9C\uCC98 (\uC0AC\uB78C\uC778/\uC6D0\uD2F0\uB4DC/\uC9C1\uC811 \uC785\uB825 \uB4F1)")
4710
+ source: external_exports.string().max(MAX_LINE).optional().describe("\uCD9C\uCC98 (\uC0AC\uB78C\uC778/\uC6D0\uD2F0\uB4DC/\uC9C1\uC811 \uC785\uB825 \uB4F1)"),
4711
+ company_overview: optBody.describe("\uD68C\uC0AC \uC0AC\uC5C5\uAC1C\uC694\xB7\uBBF8\uC158\xB7\uC8FC\uC694 \uC81C\uD488\xB7\uCD5C\uADFC \uD65C\uB3D9 (AI\uAC00 \uB9AC\uC11C\uCE58\uD574 \uCC44\uC6C0)"),
4712
+ talent_profile: optBody.describe("\uD68C\uC0AC\uAC00 \uAC15\uC870\uD558\uB294 \uC778\uC7AC\uC0C1 (\uD55C\uAD6D \uC790\uC18C\uC11C\xB7\uBA74\uC811\uC5D0\uC11C \uC815\uB82C \uAE30\uC900)"),
4713
+ core_values: strList.optional().describe("\uD68C\uC0AC \uD575\uC2EC\uAC00\uCE58 (\uD0A4\uC6CC\uB4DC \uBC30\uC5F4)")
4650
4714
  });
4651
4715
  var JobRecordSchema = external_exports.object({
4652
4716
  ...baseRecord,
@@ -4659,7 +4723,10 @@ var JobRecordSchema = external_exports.object({
4659
4723
  requirements: external_exports.array(external_exports.string()),
4660
4724
  keywords: external_exports.array(external_exports.string()),
4661
4725
  deadline: external_exports.string().nullable(),
4662
- source: external_exports.string().nullable()
4726
+ source: external_exports.string().nullable(),
4727
+ company_overview: external_exports.string().nullable(),
4728
+ talent_profile: external_exports.string().nullable(),
4729
+ core_values: external_exports.array(external_exports.string())
4663
4730
  });
4664
4731
  var FitAnalysisInputSchema = external_exports.object({
4665
4732
  job_id: reqLine.describe("\uBD84\uC11D \uB300\uC0C1 \uACF5\uACE0 ID"),
@@ -4728,12 +4795,21 @@ var InterviewQuestionSchema = external_exports.object({
4728
4795
  followups: strList.optional().describe("\uC608\uC0C1 \uAF2C\uB9AC \uC9C8\uBB38"),
4729
4796
  answer_outline: optBody.describe("\uB2F5\uBCC0 \uAC00\uC774\uB4DC/\uD575\uC2EC \uD3EC\uC778\uD2B8")
4730
4797
  });
4798
+ var InterviewDebriefSchema = external_exports.object({
4799
+ asked_questions: strList.optional().describe("\uC2E4\uC81C\uB85C \uBC1B\uC740 \uC9C8\uBB38\uB4E4"),
4800
+ went_well: optBody.describe("\uC798\uB41C \uC810/\uAC15\uD588\uB358 \uB2F5\uBCC0"),
4801
+ to_improve: optBody.describe("\uC544\uC26C\uC6B4 \uC810/\uB2E4\uC74C\uC5D0 \uAC1C\uC120\uD560 \uC810"),
4802
+ next_round_focus: optBody.describe("\uB2E4\uC74C \uB77C\uC6B4\uB4DC/\uBA74\uC811\uC5D0 \uB300\uBE44\uD574 \uC900\uBE44\uD560 \uD3EC\uC778\uD2B8"),
4803
+ sentiment: external_exports.enum(["positive", "neutral", "negative"]).optional().describe("\uCCB4\uAC10 \uBD84\uC704\uAE30"),
4804
+ interviewed_at: optLine.describe("\uBA74\uC811 \uBCF8 \uB0A0\uC9DC(YYYY-MM-DD)")
4805
+ });
4731
4806
  var InterviewPrepInputSchema = external_exports.object({
4732
4807
  job_id: reqLine,
4733
4808
  questions: external_exports.array(InterviewQuestionSchema).max(MAX_ITEMS).optional(),
4734
4809
  star_guides: external_exports.array(StarGuideSchema).max(MAX_ITEMS).optional(),
4735
4810
  self_introduction: optBody.describe("1\uBD84 \uC790\uAE30\uC18C\uAC1C \uCD08\uC548"),
4736
- notes: optNote.describe("\uBA74\uC811 \uD6C4\uAE30/\uBA54\uBAA8")
4811
+ notes: optNote.describe("\uBA74\uC811 \uD6C4\uAE30/\uBA54\uBAA8"),
4812
+ debrief: InterviewDebriefSchema.optional().describe("\uBA74\uC811 \uD6C4 \uB514\uBE0C\uB9AC\uD504(\uBC1B\uC740 \uC9C8\uBB38\xB7\uC798\uB41C \uC810\xB7\uAC1C\uC120\uC810\xB7\uB2E4\uC74C \uB77C\uC6B4\uB4DC \uD3EC\uCEE4\uC2A4)")
4737
4813
  });
4738
4814
  var InterviewPrepRecordSchema = external_exports.object({
4739
4815
  ...baseRecord,
@@ -4741,7 +4817,8 @@ var InterviewPrepRecordSchema = external_exports.object({
4741
4817
  questions: external_exports.array(InterviewQuestionSchema),
4742
4818
  star_guides: external_exports.array(StarGuideSchema),
4743
4819
  self_introduction: external_exports.string().nullable(),
4744
- notes: external_exports.string().nullable()
4820
+ notes: external_exports.string().nullable(),
4821
+ debrief: InterviewDebriefSchema.nullable()
4745
4822
  });
4746
4823
  var ActivityRecordSchema = external_exports.object({
4747
4824
  id,
@@ -9,6 +9,10 @@ export default {
9
9
  'common.delete': 'Delete',
10
10
  'common.open': 'Open',
11
11
  'common.justNow': 'just now',
12
+ 'common.export': 'Export',
13
+ 'common.fmtWord': 'Word',
14
+ 'common.fmtHtml': 'HTML',
15
+ 'common.fmtMarkdown': 'Markdown',
12
16
 
13
17
  'toast.copied': 'Copied to clipboard.',
14
18
  'toast.copyFailed': "Couldn't copy. Please select and copy it manually.",
@@ -28,10 +28,6 @@ export default {
28
28
  'documents.cover.primary': 'Primary',
29
29
  'documents.cover.open': 'Open',
30
30
  'documents.cover.copy': 'Copy',
31
- 'documents.cover.exportMd': 'MD',
32
- 'documents.cover.exportHtml': 'HTML',
33
- 'documents.cover.exportMdTitle': 'Export as Markdown',
34
- 'documents.cover.exportHtmlTitle': 'Export as HTML',
35
31
  'documents.cover.setPrimary': 'Set as primary',
36
32
  'documents.cover.delete': 'Delete',
37
33
  'documents.cover.noContent': 'No content yet.',
@@ -60,8 +56,6 @@ export default {
60
56
  'documents.coverCreate.toast': 'Cover letter added.',
61
57
 
62
58
  // cover detail body
63
- 'documents.coverDetail.exportMd': 'Export as Markdown',
64
- 'documents.coverDetail.exportHtml': 'Export as HTML',
65
59
  'documents.coverDetail.currentVersion': 'Current version',
66
60
  'documents.coverDetail.copy': 'Copy',
67
61
  'documents.coverDetail.noContent': 'No content.',
@@ -101,10 +95,6 @@ export default {
101
95
  'documents.docDetail.updated': 'Updated {{when}}',
102
96
  'documents.docDetail.noContent': 'No content.',
103
97
  'documents.docDetail.copy': 'Copy',
104
- 'documents.docDetail.export': 'MD',
105
- 'documents.docDetail.exportTitle': 'Export as Markdown',
106
- 'documents.docDetail.exportHtml': 'HTML',
107
- 'documents.docDetail.exportHtmlTitle': 'Export as HTML',
108
98
  'documents.docDetail.delete': 'Delete',
109
99
  'documents.docDetail.deleteTitle': 'Delete document',
110
100
  'documents.docDetail.deleteConfirm': 'Delete "{{title}}"?',
@@ -37,6 +37,11 @@ export default {
37
37
  'home.stat.activeApplications': 'Active applications',
38
38
  'home.stat.coverLetters': 'Cover letters',
39
39
  'home.stat.interviewPending': 'Interview prep',
40
+ // KPI tile context sub-lines (derived from real counts; omitted when zero)
41
+ 'home.stat.jobsSub': '{{count}} to apply',
42
+ 'home.stat.subInterview': 'Interview {{count}}',
43
+ 'home.stat.subDocPassed': 'Screening {{count}}',
44
+ 'home.stat.subApplied': 'Applied {{count}}',
40
45
 
41
46
  // pipeline
42
47
  'home.pipeline.title': 'Application pipeline',
@@ -2,7 +2,7 @@
2
2
  export default {
3
3
  // ---- list mode ----
4
4
  'jobs.list.empty.title': 'No saved postings yet',
5
- 'jobs.list.empty.body': 'Add a posting you are interested in to track its fit and your progress.',
5
+ 'jobs.list.empty.body': 'Add a posting you are interested in to track its fit and your progress. Or just tell your connected AI “find backend jobs” and it will fetch postings and save them here.',
6
6
  'jobs.list.addJob': 'Add posting',
7
7
  'jobs.list.searchPlaceholder': 'Search company or role',
8
8
  'jobs.list.searchAria': 'Search postings',
@@ -11,11 +11,6 @@ export default {
11
11
  'profile.completeness.title': 'Profile completeness',
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': 'Export resume as Markdown',
18
- 'profile.export.htmlTitle': 'Export resume as HTML',
19
14
 
20
15
  // basic info card
21
16
  'profile.basic.title': 'Basic info',
@@ -9,6 +9,10 @@ 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',
12
16
 
13
17
  'toast.copied': '클립보드에 복사했습니다.',
14
18
  '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': '공고 검색',
@@ -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': '기본 정보',
@@ -215,6 +215,78 @@ export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled
215
215
  export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
216
216
  return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
217
217
  }
218
+ /**
219
+ * A dropdown: one trigger button that opens a small popover of actions, so
220
+ * several secondary actions (e.g. export as docx/html/md) collapse into a single
221
+ * control instead of a row of competing buttons. The popover is position:fixed
222
+ * and anchored to the trigger's rect, so it never clips inside a scrolling modal
223
+ * body and flips above the trigger when there's no room below. Closes on outside
224
+ * click, Escape, scroll, resize, or item activation.
225
+ *
226
+ * `items`: array of `{ label, hint?, icon?, onClick, danger? }` (falsy entries skipped).
227
+ */
228
+ export function Menu(label, items, { icon: ic = 'download', variant = 'ghost', sm, align = 'end' } = {}) {
229
+ const list = el('div', { class: 'menu__list', attrs: { role: 'menu' }, hidden: true });
230
+ for (const it of items) {
231
+ if (!it) continue;
232
+ list.append(el('button', {
233
+ class: `menu__item${it.danger ? ' menu__item--danger' : ''}`,
234
+ type: 'button', attrs: { role: 'menuitem' },
235
+ onClick: () => { close(); it.onClick?.(); },
236
+ }, it.icon && icon(it.icon), el('span', { class: 'menu__item-label' }, it.label), it.hint && el('span', { class: 'menu__item-hint' }, it.hint)));
237
+ }
238
+ const trigger = el('button', {
239
+ class: ['btn', variant && `btn--${variant}`, sm && 'btn--sm', 'menu__trigger'].filter(Boolean).join(' '),
240
+ type: 'button', attrs: { 'aria-haspopup': 'menu', 'aria-expanded': 'false' },
241
+ onClick: () => (list.hidden ? open() : close()),
242
+ }, ic && icon(ic), label && el('span', {}, label), icon('chevronRight', 'menu__caret'));
243
+ const wrap = el('div', { class: 'menu' }, trigger, list);
244
+
245
+ function place() {
246
+ const r = trigger.getBoundingClientRect();
247
+ const lw = list.offsetWidth, lh = list.offsetHeight;
248
+ let top = r.bottom + 6;
249
+ if (top + lh > window.innerHeight - 8 && r.top - 6 - lh > 8) top = r.top - 6 - lh; // flip up
250
+ let left = align === 'end' ? r.right - lw : r.left;
251
+ left = Math.max(8, Math.min(left, window.innerWidth - lw - 8));
252
+ list.style.top = `${Math.round(top)}px`;
253
+ list.style.left = `${Math.round(left)}px`;
254
+ }
255
+ function open() {
256
+ if (!list.hidden) return;
257
+ list.hidden = false; place(); // place() reads layout while visible, same tick → no flash
258
+ trigger.setAttribute('aria-expanded', 'true');
259
+ document.addEventListener('click', onDoc, true);
260
+ document.addEventListener('keydown', onKey, true);
261
+ window.addEventListener('scroll', close, true);
262
+ window.addEventListener('resize', close);
263
+ }
264
+ function close() {
265
+ if (list.hidden) return;
266
+ list.hidden = true;
267
+ trigger.setAttribute('aria-expanded', 'false');
268
+ document.removeEventListener('click', onDoc, true);
269
+ document.removeEventListener('keydown', onKey, true);
270
+ window.removeEventListener('scroll', close, true);
271
+ window.removeEventListener('resize', close);
272
+ }
273
+ function onDoc(e) { if (!wrap.contains(e.target)) close(); }
274
+ function onKey(e) { if (e.key === 'Escape') { close(); trigger.focus(); } }
275
+ return wrap;
276
+ }
277
+ /**
278
+ * The standard "내보내기" dropdown for a document export endpoint. `baseUrl` is a
279
+ * GET export route (e.g. `/api/export/profile`); each item appends `?format=…`
280
+ * and triggers a download. One control replaces the old docx/html/md button row.
281
+ */
282
+ export function ExportMenu(baseUrl, { sm = true } = {}) {
283
+ const sep = baseUrl.includes('?') ? '&' : '?';
284
+ return Menu(t('common.export'), [
285
+ { label: t('common.fmtWord'), hint: '.docx', onClick: () => downloadUrl(`${baseUrl}${sep}format=docx`) },
286
+ { label: t('common.fmtHtml'), hint: '.html', onClick: () => downloadUrl(`${baseUrl}${sep}format=html`) },
287
+ { label: t('common.fmtMarkdown'), hint: '.md', onClick: () => downloadUrl(`${baseUrl}${sep}format=md`) },
288
+ ], { sm });
289
+ }
218
290
  /**
219
291
  * A footer button for async saves. While `action()` runs the button is disabled
220
292
  * (so a slow local round-trip can't be double-clicked into duplicate records),
@@ -1,7 +1,7 @@
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,
4
+ el, get, put, navigate, EmptyState, Btn, ListRow, Badge,
5
5
  Select, scoreClass, statusColor, mount, toast, toastOk, toastError,
6
6
  } from '/lib.js';
7
7
  import { t } from '/i18n.js';
@@ -66,20 +66,24 @@ export async function render(ctx) {
66
66
  const sel = Select(
67
67
  statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
68
68
  {
69
- class: 'select select--sm row-action',
69
+ class: 'select select--sm',
70
70
  title: t('applications.statusSelect.title'),
71
71
  onClick: (e) => e.stopPropagation(),
72
72
  onChange: (e) => { e.stopPropagation(); changeStatus(app, e.target.value, e.target); },
73
73
  },
74
74
  );
75
+ // Keep the right edge informative on every row: a right-aligned score column
76
+ // (a muted dash when not yet scored, so scores line up and "no score" reads
77
+ // clearly), then the status — a calm badge that swaps to the change-select on hover.
78
+ const score = app.fit_score != null
79
+ ? el('span', { class: `app-score strong tnum ${scoreClass(app.fit_score)}` }, t('applications.row.score', { score: app.fit_score }))
80
+ : el('span', { class: 'app-score muted tnum' }, '—');
75
81
  return ListRow({
76
82
  title: job.company || '—',
77
83
  sub: subBits.join(' · '),
78
84
  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,
85
+ score,
86
+ el('span', { class: 'app-status' }, Badge(app.status, t('status.' + app.status)), sel),
83
87
  ],
84
88
  onClick: () => navigate(`/jobs/${app.job_id}`),
85
89
  });
@@ -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
 
@@ -138,8 +138,7 @@ function CoverRow(cl, reload) {
138
138
  const actions = el('div', { class: 'flex gap-2 wrap' },
139
139
  Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
140
140
  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`) }),
141
+ ExportMenu(`/api/export/cover-letter/${cl.id}`),
143
142
  cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
144
143
  IconBtn('trash', { variant: 'danger', title: t('documents.cover.delete'), onClick: () => removeCover(cl, reload) }),
145
144
  );
@@ -255,8 +254,7 @@ function CoverDetailBody(cl, { refresh }) {
255
254
  cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
256
255
  ),
257
256
  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`) }),
257
+ ExportMenu(`/api/export/cover-letter/${cl.id}`),
260
258
  ),
261
259
  ));
262
260
 
@@ -446,8 +444,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
446
444
  ),
447
445
  footer: (close) => [
448
446
  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`) }),
447
+ ExportMenu(`/api/export/document/${doc.id}`, { sm: false }),
451
448
  Btn(t('documents.docDetail.delete'), { variant: 'danger', icon: 'trash', onClick: async () => {
452
449
  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
450
  if (!ok) return;
@@ -45,7 +45,7 @@ export async function render(ctx) {
45
45
  }
46
46
 
47
47
  if (!s.onboarding.completed) wrap.append(GettingStarted(s.onboarding));
48
- wrap.append(StatRow(s.counts));
48
+ wrap.append(StatRow(s));
49
49
  wrap.append(Pipeline(s.status_breakdown));
50
50
  wrap.append(el('div', { class: 'grid grid--2' },
51
51
  el('div', { class: 'stack-4' }, ActionLane(s), RecentJobs(s.recent_jobs)),
@@ -96,15 +96,35 @@ function GettingStarted(o) {
96
96
  }
97
97
 
98
98
  /* ----------------------------------------------- KPI summary tiles (at-a-glance) */
99
- function StatTile(label, value, iconName, to) {
100
- const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
99
+ function StatTile(label, value, iconName, to, hint) {
100
+ const tile = Stat({ label, value, hint, iconName, onClick: () => navigate(to) });
101
101
  tile.classList.add('is-clickable');
102
102
  return tile;
103
103
  }
104
- function StatRow(counts) {
104
+ function StatRow(s) {
105
+ const counts = s.counts;
106
+ const by = Object.fromEntries((s.status_breakdown || []).map((b) => [b.status, b.count]));
107
+ const c = (st) => by[st] || 0;
108
+
109
+ // Contextual sub-lines use only real counts (never invented). Tiles with no
110
+ // meaningful secondary signal (cover letters, interview prep) stay clean — the
111
+ // .stat min-height keeps the row even either way.
112
+
113
+ // 저장 공고 → how many postings still await action (not yet applied).
114
+ const notApplied = c('draft') + c('planned');
115
+ const jobsHint = notApplied > 0 ? t('home.stat.jobsSub', { count: notApplied }) : null;
116
+
117
+ // 진행 중 지원 → surface the most advanced active stages (interview → screening
118
+ // → applied), up to two, so the number carries a "where are they" breakdown.
119
+ const activeHint = [
120
+ c('interview') ? t('home.stat.subInterview', { count: c('interview') }) : null,
121
+ c('document_passed') ? t('home.stat.subDocPassed', { count: c('document_passed') }) : null,
122
+ c('applied') ? t('home.stat.subApplied', { count: c('applied') }) : null,
123
+ ].filter(Boolean).slice(0, 2).join(' · ') || null;
124
+
105
125
  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'),
126
+ StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs', jobsHint),
127
+ StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications', activeHint),
108
128
  StatTile(t('home.stat.coverLetters'), counts.cover_letters, 'file', '/documents'),
109
129
  StatTile(t('home.stat.interviewPending'), counts.interview_pending, 'mic', '/interview'),
110
130
  );
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  el, get, post, put, del, icon, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState, Chips,
4
4
  Field, Input, Textarea, openModal, closeModal, confirmDialog,
5
- toastOk, toastError, fmtDate, mount, linesToArray, csvToArray, downloadUrl,
5
+ toastOk, toastError, fmtDate, mount, linesToArray, csvToArray, ExportMenu,
6
6
  } from '/lib.js';
7
7
  import { t } from '/i18n.js';
8
8
 
@@ -67,8 +67,7 @@ function CompletenessCard(o) {
67
67
  title: t('profile.completeness.title'),
68
68
  sub: t('profile.completeness.percent', { pct }),
69
69
  actions: [
70
- Btn(t('profile.export.md'), { sm: true, variant: 'ghost', icon: 'download', title: t('profile.export.mdTitle'), onClick: () => downloadUrl('/api/export/profile?format=md') }),
71
- Btn(t('profile.export.html'), { sm: true, variant: 'ghost', icon: 'download', title: t('profile.export.htmlTitle'), onClick: () => downloadUrl('/api/export/profile?format=html') }),
70
+ ExportMenu('/api/export/profile'),
72
71
  ],
73
72
  body: [
74
73
  el('div', { class: 'progress', style: { marginBottom: steps.length ? '12px' : '0' } },
@@ -201,14 +201,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
201
201
  .card.is-clickable { cursor: pointer; transition: border-color .12s, box-shadow .12s, transform .12s; }
202
202
  .card.is-clickable:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
203
203
 
204
- /* stat tiles */
205
- .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px 18px; }
204
+ /* stat tiles — slim, dense KPI row (home only). A contextual hint line, when
205
+ present, sits at the bottom so tiles align evenly whether or not they carry one. */
206
+ .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
207
+ padding: 12px 15px; min-height: 84px; display: flex; flex-direction: column; }
206
208
  .stat.is-clickable { cursor: pointer; transition: border-color .12s, box-shadow .12s; }
207
209
  .stat.is-clickable:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
208
210
  .stat__label { font-size: 13px; color: var(--text-secondary); display:flex; align-items:center; gap:7px; }
209
211
  .stat__label svg { width: 15px; height: 15px; color: var(--text-tertiary); }
210
- .stat__value { font-size: 26px; font-weight: 700; letter-spacing: -.02em; margin-top: 6px; }
211
- .stat__hint { font-size: 12.5px; color: var(--text-tertiary); margin-top: 2px; }
212
+ .stat__value { font-size: 23px; font-weight: 700; letter-spacing: -.02em; margin-top: 3px; line-height: 1.15; }
213
+ .stat__hint { font-size: 12.5px; color: var(--text-tertiary); margin-top: auto; padding-top: 6px; }
212
214
  /* borderless count strip (settings 내 데이터) — flat counts, never a card-in-card */
213
215
  .countstrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px 24px; }
214
216
  .countstrip__item { display: flex; flex-direction: column; gap: 2px; }
@@ -379,6 +381,24 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
379
381
  .app-group__title { font-weight: 650; font-size: 15px; letter-spacing: -.01em; }
380
382
  .app-group__count { margin-left: auto; color: var(--text-tertiary); font-size: 13px; }
381
383
 
384
+ /* applications row trailing — keep the right edge informative (never empty):
385
+ right-aligned score column, an optional D-day chip, then the status. The status
386
+ shows a calm badge at rest and swaps to the inline change-select on hover/focus
387
+ (display swap, not opacity, so the hidden control reserves no width). */
388
+ .app-score { min-width: 42px; text-align: right; font-size: 13.5px; }
389
+ .app-dday { color: var(--text-tertiary); }
390
+ .app-dday--urgent { color: var(--amber); }
391
+ .app-status { display: inline-flex; align-items: center; }
392
+ .app-status .select { display: none; }
393
+ .list-row:hover .app-status .badge,
394
+ .list-row:focus-within .app-status .badge { display: none; }
395
+ .list-row:hover .app-status .select,
396
+ .list-row:focus-within .app-status .select { display: inline-flex; }
397
+ @media (hover: none) {
398
+ .app-status .badge { display: none; }
399
+ .app-status .select { display: inline-flex; }
400
+ }
401
+
382
402
  /* row-level action (e.g. status select) — hidden until the row is hovered or
383
403
  focused, so grouped lists stay calm. Always shown where hover isn't available. */
384
404
  .list-row .row-action { opacity: 0; pointer-events: none; transition: opacity .12s; }
@@ -467,6 +487,21 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
467
487
  @keyframes pop { from { transform: translate(-50%,-46%); opacity: 0; } to { transform: translate(-50%,-50%); opacity: 1; } }
468
488
  @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
469
489
 
490
+ /* Dropdown menu (Menu/ExportMenu) — fixed popover anchored to its trigger so it
491
+ never clips inside a scrolling modal body. Sits above the modal layer (60). */
492
+ .menu { position: relative; display: inline-flex; }
493
+ .menu__trigger .menu__caret { width: 14px; height: 14px; margin-left: -2px; opacity: .55; transform: rotate(90deg); }
494
+ .menu__list { position: fixed; z-index: 70; min-width: 176px; padding: 5px; display: flex; flex-direction: column; gap: 2px;
495
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-lg); animation: fade .12s; }
496
+ .menu__list[hidden] { display: none; }
497
+ .menu__item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border: 0; border-radius: var(--radius-sm);
498
+ background: transparent; color: var(--text); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
499
+ .menu__item:hover, .menu__item:focus-visible { background: var(--surface-hover); outline: none; }
500
+ .menu__item .icon { width: 15px; height: 15px; color: var(--text-tertiary); flex: none; }
501
+ .menu__item-label { flex: 1; }
502
+ .menu__item-hint { color: var(--text-tertiary); font-size: 11.5px; font-variant-numeric: tabular-nums; }
503
+ .menu__item--danger { color: var(--red); }
504
+
470
505
  /* ----------------------------------------------------------------- toast */
471
506
  .toasts { position: fixed; bottom: 20px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
472
507
  .toast { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--radius);