careermate 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +78 -81
  4. package/bin/careermate.mjs +11 -2
  5. package/dist/career-os/eop/cover-letter.md +10 -4
  6. package/dist/career-os/eop/interview-prep.md +1 -0
  7. package/dist/career-os/knowledge/cover-letter.md +4 -0
  8. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  9. package/dist/doctor.mjs +888 -340
  10. package/dist/mcp.mjs +904 -350
  11. package/dist/migrate.mjs +208 -16
  12. package/dist/public/i18n/en/applications.js +19 -0
  13. package/dist/public/i18n/en/common.js +6 -0
  14. package/dist/public/i18n/en/jobs.js +17 -0
  15. package/dist/public/i18n/en/settings.js +5 -2
  16. package/dist/public/i18n/ko/applications.js +19 -0
  17. package/dist/public/i18n/ko/common.js +5 -0
  18. package/dist/public/i18n/ko/jobs.js +17 -0
  19. package/dist/public/i18n/ko/settings.js +5 -2
  20. package/dist/public/lib.js +29 -0
  21. package/dist/public/pages/applications.js +86 -10
  22. package/dist/public/pages/documents.js +16 -7
  23. package/dist/public/pages/home.js +12 -0
  24. package/dist/public/pages/interview.js +11 -4
  25. package/dist/public/pages/jobs.js +71 -1
  26. package/dist/public/pages/settings.js +2 -2
  27. package/dist/public/styles.css +13 -0
  28. package/dist/web.mjs +544 -199
  29. package/docs/FAQ.md +13 -3
  30. package/docs/MCP_TOOLS.md +22 -9
  31. package/docs/career-os/eop/cover-letter.md +10 -4
  32. package/docs/career-os/eop/interview-prep.md +1 -0
  33. package/docs/career-os/knowledge/cover-letter.md +4 -0
  34. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  35. package/package.json +3 -1
  36. package/packages/core/src/index.ts +1 -0
  37. package/packages/core/src/jobsearch.ts +6 -2
  38. package/packages/core/src/offers.ts +41 -0
  39. package/packages/core/src/services.ts +5 -1
  40. package/packages/core/src/summary.ts +245 -19
  41. package/packages/db/src/connection.ts +70 -2
  42. package/packages/db/src/index.ts +1 -0
  43. package/packages/db/src/repositories.ts +140 -8
  44. package/packages/db/src/schema.ts +140 -8
  45. package/packages/mcp-tools/src/result.ts +9 -0
  46. package/packages/mcp-tools/src/tools.ts +143 -20
  47. package/packages/parsers/src/documents.ts +7 -1
  48. package/packages/prompts/src/humanize.ts +4 -0
  49. package/packages/shared/src/schemas.ts +134 -0
  50. package/scripts/init.ts +2 -0
package/dist/migrate.mjs CHANGED
@@ -18,8 +18,15 @@ function getDataDir() {
18
18
  function getDbPath() {
19
19
  return path.join(getDataDir(), "careermate.sqlite");
20
20
  }
21
+ function getBackupsDir() {
22
+ const dir = path.join(getDataDir(), "backups");
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ }
21
26
 
22
27
  // packages/db/src/connection.ts
28
+ import fs2 from "node:fs";
29
+ import path2 from "node:path";
23
30
  import { DatabaseSync } from "node:sqlite";
24
31
 
25
32
  // packages/db/src/schema.ts
@@ -338,33 +345,155 @@ var MIGRATIONS = [
338
345
  // idempotent. Feeds next-round prep and consistency (lessons carry into later interviews).
339
346
  `
340
347
  ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
348
+ `,
349
+ // v7 — offer comparison (한국 총보상 필드) + company reputation research field on jobs.
350
+ // offers: one per job (UNIQUE job_id, mirrors interview_preps). Cash pieces are numeric (만원) so
351
+ // the server can deterministically sum an annual estimate; equity/other stay free-text (comp is
352
+ // messy). ai_score/verdict are written by the connected AI at save time (no LLM here) and rendered
353
+ // by the dashboard, so it never needs to call an LLM. jobs.reputation: AI-researched company
354
+ // reputation summary (same pattern as company_overview) — feeds offer verdicts + offer-evaluation.
355
+ `
356
+ CREATE TABLE IF NOT EXISTS offers (
357
+ id TEXT PRIMARY KEY,
358
+ job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
359
+ base_salary INTEGER,
360
+ bonus_amount INTEGER,
361
+ welfare_amount INTEGER,
362
+ signing_amount INTEGER,
363
+ equity_note TEXT,
364
+ comp_note TEXT,
365
+ work_arrangement TEXT,
366
+ contract_type TEXT,
367
+ accept_deadline TEXT,
368
+ ai_score INTEGER,
369
+ verdict TEXT,
370
+ notes TEXT,
371
+ created_at TEXT NOT NULL,
372
+ updated_at TEXT NOT NULL
373
+ );
374
+ CREATE INDEX IF NOT EXISTS idx_offers_job ON offers(job_id);
375
+ ALTER TABLE jobs ADD COLUMN reputation TEXT;
376
+ `,
377
+ // v8 — offer lifecycle status. 지원 상태(전형 깔때기 = 회사의 프로세스)와 직교하는
378
+ // "내 응답" 축: open(진행)/accepted(수락)/declined(거절). declined여도 행을 지우지
379
+ // 않는다 — 조건(연봉·처우) 히스토리는 다음 협상의 자산이라, 대시보드가 접힌
380
+ // '지난 오퍼'로 강등해 보여줄 뿐이다. Additive ALTER (runner가 멱등 처리).
381
+ `
382
+ ALTER TABLE offers ADD COLUMN offer_status TEXT NOT NULL DEFAULT 'open';
383
+ `,
384
+ // v9 — 한국 문항형 자소서: 회사가 지정한 문항·글자수를 공고에 구조화 보관. 한국 자소서는
385
+ // 회사별 문항형(항목·글자수 제한)이 지배적이라, AI가 문항별로 답변을 쓰고 글자수를 점검할 때
386
+ // 참조할 원본을 공고 옆에 둔다(별도 엔티티가 아니라 job 컨텍스트에 함께 실린다). JSON 배열을
387
+ // TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
388
+ `
389
+ ALTER TABLE jobs ADD COLUMN questions TEXT NOT NULL DEFAULT '[]';
390
+ `,
391
+ // v10 — 탈락 회고: 어느 전형 단계에서 떨어졌는지 자가 기록. 탈락 사유를 고지하는 기업은 6%뿐이고
392
+ // 지원자 83%가 알고 싶어하므로, 이 자가 기록이 "서류는 붙는데 면접에서 떨어진다" 같은 패턴을 로컬
393
+ // 데이터로 드러내는 유일한 신호다. 탈락이 아니거나 미기록이면 NULL. Additive ALTER (runner가 멱등 처리).
394
+ `
395
+ ALTER TABLE applications ADD COLUMN rejected_stage TEXT;
396
+ `,
397
+ // v11 — 전형 일정: 서류마감→인적성·AI역검→1·2차 면접→발표로 이어지는 단계별 날짜를 공고에
398
+ // 구조화 보관. 일정을 jobs에 두는 이유: 서류마감(deadline)이 이미 jobs에 있고, save_job_posting
399
+ // 업서트를 그대로 재사용해 도구 신설 없이 갱신할 수 있다. 면접초대 메일·발표 공지를 사용자가
400
+ // 붙여넣으면 AI가 날짜를 추출해 저장하고, 서버가 D-day·같은 날짜 충돌을 결정론으로 계산한다.
401
+ // JSON 배열을 TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
402
+ `
403
+ ALTER TABLE jobs ADD COLUMN schedule TEXT NOT NULL DEFAULT '[]';
341
404
  `
342
405
  ];
343
406
  function migrate(db) {
344
407
  db.exec(`CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
345
408
  const row = db.prepare(`SELECT value FROM _meta WHERE key = 'schema_version'`).get();
346
409
  const from = row ? Number(row.value) : 0;
410
+ if (from >= MIGRATIONS.length) return { from, to: from };
347
411
  for (let v = from; v < MIGRATIONS.length; v++) {
348
- db.exec(MIGRATIONS[v]);
412
+ applyMigration(db, v);
349
413
  }
350
- const to = MIGRATIONS.length;
351
- if (to !== from) {
352
- db.prepare(
353
- `INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
354
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`
355
- ).run(String(to));
414
+ return { from, to: MIGRATIONS.length };
415
+ }
416
+ function stampVersion(db, version) {
417
+ db.prepare(
418
+ `INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
419
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
420
+ ).run(String(version));
421
+ }
422
+ function columnExists(db, table, column) {
423
+ const rows = db.prepare(`PRAGMA table_info("${table.replaceAll('"', '""')}")`).all();
424
+ return rows.some((r) => r.name === column);
425
+ }
426
+ var SELF_TRANSACTIONAL_MIGRATIONS = /* @__PURE__ */ new Set([1]);
427
+ var ALTER_ADD_COLUMN_RE = /^[ \t]*ALTER TABLE\s+(\w+)\s+ADD COLUMN\s+(\w+)[^;]*;[ \t]*$/gm;
428
+ function skipExistingColumns(db, sql) {
429
+ return sql.replace(
430
+ ALTER_ADD_COLUMN_RE,
431
+ (stmt, table, column) => columnExists(db, table, column) ? "" : stmt
432
+ );
433
+ }
434
+ function applyMigration(db, v) {
435
+ const sql = MIGRATIONS[v];
436
+ if (SELF_TRANSACTIONAL_MIGRATIONS.has(v)) {
437
+ db.exec(sql);
438
+ stampVersion(db, v + 1);
439
+ return;
440
+ }
441
+ db.exec("BEGIN");
442
+ try {
443
+ db.exec(skipExistingColumns(db, sql));
444
+ stampVersion(db, v + 1);
445
+ db.exec("COMMIT");
446
+ } catch (err) {
447
+ try {
448
+ db.exec("ROLLBACK");
449
+ } catch {
450
+ }
451
+ throw err;
356
452
  }
357
- return { from, to };
358
453
  }
359
454
 
360
455
  // packages/db/src/connection.ts
361
456
  var _db = null;
457
+ var PRE_MIGRATE_KEEP = 5;
458
+ function vacuumInto(db, destPath) {
459
+ if (/[\0\n\r]/.test(destPath)) throw new Error("backup path not safe for SQL literal");
460
+ db.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
461
+ }
462
+ function preMigrationBackup(db) {
463
+ try {
464
+ const hasMeta = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'`).get();
465
+ if (!hasMeta) return;
466
+ const row = db.prepare(`SELECT value FROM _meta WHERE key = 'schema_version'`).get();
467
+ const from = row ? Number(row.value) : 0;
468
+ if (from === 0 || from >= MIGRATIONS.length) return;
469
+ const dir = getBackupsDir();
470
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
471
+ const dest = path2.join(dir, `pre-migrate-v${from}-${stamp}.sqlite`);
472
+ try {
473
+ vacuumInto(db, dest);
474
+ } catch {
475
+ fs2.copyFileSync(getDbPath(), dest);
476
+ }
477
+ const stale = fs2.readdirSync(dir).filter((f) => f.startsWith("pre-migrate-") && f.endsWith(".sqlite")).sort().reverse().slice(PRE_MIGRATE_KEEP);
478
+ for (const f of stale) {
479
+ try {
480
+ fs2.unlinkSync(path2.join(dir, f));
481
+ } catch {
482
+ }
483
+ }
484
+ } catch (err) {
485
+ console.error(
486
+ `[careermate] \uC5C5\uADF8\uB808\uC774\uB4DC \uC804 \uC790\uB3D9 \uBC31\uC5C5 \uC2E4\uD328(\uB9C8\uC774\uADF8\uB808\uC774\uC158\uC740 \uACC4\uC18D \uC9C4\uD589): ${err instanceof Error ? err.message : String(err)}`
487
+ );
488
+ }
489
+ }
362
490
  function getDb() {
363
491
  if (_db) return _db;
364
492
  const db = new DatabaseSync(getDbPath());
365
493
  db.exec("PRAGMA journal_mode = WAL;");
366
494
  db.exec("PRAGMA foreign_keys = ON;");
367
495
  db.exec("PRAGMA busy_timeout = 5000;");
496
+ preMigrationBackup(db);
368
497
  migrate(db);
369
498
  _db = db;
370
499
  return db;
@@ -900,8 +1029,8 @@ function getErrorMap() {
900
1029
 
901
1030
  // node_modules/zod/v3/helpers/parseUtil.js
902
1031
  var makeIssue = (params) => {
903
- const { data, path: path2, errorMaps, issueData } = params;
904
- const fullPath = [...path2, ...issueData.path || []];
1032
+ const { data, path: path3, errorMaps, issueData } = params;
1033
+ const fullPath = [...path3, ...issueData.path || []];
905
1034
  const fullIssue = {
906
1035
  ...issueData,
907
1036
  path: fullPath
@@ -1017,11 +1146,11 @@ var errorUtil;
1017
1146
 
1018
1147
  // node_modules/zod/v3/types.js
1019
1148
  var ParseInputLazyPath = class {
1020
- constructor(parent, value, path2, key) {
1149
+ constructor(parent, value, path3, key) {
1021
1150
  this._cachedPath = [];
1022
1151
  this.parent = parent;
1023
1152
  this.data = value;
1024
- this._path = path2;
1153
+ this._path = path3;
1025
1154
  this._key = key;
1026
1155
  }
1027
1156
  get path() {
@@ -4697,6 +4826,22 @@ var CoverLetterRecordSchema = external_exports.object({
4697
4826
  current_content: external_exports.string().nullable(),
4698
4827
  versions: external_exports.array(CoverLetterVersionRecordSchema).optional()
4699
4828
  });
4829
+ var JobQuestionSchema = external_exports.object({
4830
+ no: external_exports.number().int().positive().optional().describe("\uBB38\uD56D \uBC88\uD638(\uC788\uC73C\uBA74)"),
4831
+ question: external_exports.string().max(MAX_BODY).describe("\uBB38\uD56D \uD14D\uC2A4\uD2B8(\uD68C\uC0AC\uAC00 \uC81C\uC2DC\uD55C \uC790\uC18C\uC11C \uD56D\uBAA9)"),
4832
+ min_chars: external_exports.number().int().positive().optional().describe("\uAE00\uC790\uC218 \uD558\uD55C(\uC788\uC73C\uBA74)"),
4833
+ max_chars: external_exports.number().int().positive().optional().describe("\uAE00\uC790\uC218 \uC0C1\uD55C(\uC788\uC73C\uBA74)"),
4834
+ count_mode: external_exports.enum(["with_space", "no_space", "byte"]).optional().describe("\uAE00\uC790\uC218 \uAE30\uC900: with_space=\uACF5\uBC31\uD3EC\uD568(\uAE30\uBCF8)\xB7no_space=\uACF5\uBC31\uC81C\uC678\xB7byte=UTF-8 \uBC14\uC774\uD2B8"),
4835
+ note: optLine.describe('\uBD80\uAC00 \uBA54\uBAA8(\uC608: "\uD544\uC218", "\uACBD\uD5D8 \uC911\uC2EC")')
4836
+ });
4837
+ var SCHEDULE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
4838
+ var SCHEDULE_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
4839
+ var JobScheduleItemSchema = external_exports.object({
4840
+ label: reqLine.describe("\uC77C\uC815 \uC774\uB984 (\uC608: 1\uCC28 \uBA74\uC811\xB7AI\uC5ED\uAC80\xB7\uC11C\uB958 \uBC1C\uD45C)"),
4841
+ date: external_exports.string().regex(SCHEDULE_DATE_RE, "YYYY-MM-DD \uD615\uC2DD\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4").describe("\uC77C\uC815 \uB0A0\uC9DC YYYY-MM-DD"),
4842
+ time: external_exports.string().regex(SCHEDULE_TIME_RE, "HH:MM(24\uC2DC\uAC04) \uD615\uC2DD\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4").optional().describe("\uC2DC\uAC01 HH:MM(24\uC2DC\uAC04, \uC120\uD0DD)"),
4843
+ note: optLine.describe("\uBD80\uAC00 \uBA54\uBAA8(\uC608: \uC7A5\uC18C\xB7\uC900\uBE44\uBB3C, \uC120\uD0DD)")
4844
+ });
4700
4845
  var JobInputSchema = external_exports.object({
4701
4846
  company: reqLine,
4702
4847
  position: reqLine.describe("\uC9C1\uBB34/\uD3EC\uC9C0\uC158\uBA85"),
@@ -4710,7 +4855,10 @@ var JobInputSchema = external_exports.object({
4710
4855
  source: external_exports.string().max(MAX_LINE).optional().describe("\uCD9C\uCC98 (\uC0AC\uB78C\uC778/\uC6D0\uD2F0\uB4DC/\uC9C1\uC811 \uC785\uB825 \uB4F1)"),
4711
4856
  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
4857
  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)")
4858
+ core_values: strList.optional().describe("\uD68C\uC0AC \uD575\uC2EC\uAC00\uCE58 (\uD0A4\uC6CC\uB4DC \uBC30\uC5F4)"),
4859
+ reputation: optBody.describe("\uD68C\uC0AC \uD3C9\uD310 \uC694\uC57D \u2014 \uD3C9\uC810\xB7\uC6CC\uB77C\uBC38\xB7\uBB38\uD654 \uB9AC\uC2A4\uD06C \uB4F1 (AI\uAC00 company-research \uD50C\uB808\uC774\uBD81\uC73C\uB85C \uB9AC\uC11C\uCE58\uD574 \uCC44\uC6C0; \uCD9C\uCC98\xB7\uCD5C\uADFC\uC131 \uD45C\uAE30)"),
4860
+ questions: external_exports.array(JobQuestionSchema).max(MAX_ITEMS).optional().describe("\uD68C\uC0AC \uC9C0\uC815 \uC790\uC18C\uC11C \uBB38\uD56D\xB7\uAE00\uC790\uC218 \u2014 \uC0AC\uC6A9\uC790\uAC00 \uBB38\uD56D\uC744 \uC8FC\uBA74 \uAD6C\uC870\uD654 \uC800\uC7A5"),
4861
+ schedule: external_exports.array(JobScheduleItemSchema).max(MAX_ITEMS).optional().describe("\uC804\uD615 \uC77C\uC815 \u2014 \uBA74\uC811\uCD08\uB300 \uBA54\uC77C\xB7\uBC1C\uD45C \uACF5\uC9C0\uB97C \uBC1B\uC73C\uBA74 \uAD6C\uC870\uD654 \uC800\uC7A5(label\xB7date \uD544\uC218, time \uC120\uD0DD). \uC11C\uB958 \uB9C8\uAC10\uC740 deadline \uD544\uB4DC\uB97C \uC4F0\uC138\uC694.")
4714
4862
  });
4715
4863
  var JobRecordSchema = external_exports.object({
4716
4864
  ...baseRecord,
@@ -4726,7 +4874,46 @@ var JobRecordSchema = external_exports.object({
4726
4874
  source: external_exports.string().nullable(),
4727
4875
  company_overview: external_exports.string().nullable(),
4728
4876
  talent_profile: external_exports.string().nullable(),
4729
- core_values: external_exports.array(external_exports.string())
4877
+ core_values: external_exports.array(external_exports.string()),
4878
+ reputation: external_exports.string().nullable(),
4879
+ questions: external_exports.array(JobQuestionSchema),
4880
+ schedule: external_exports.array(JobScheduleItemSchema)
4881
+ });
4882
+ var OFFER_STATUSES = ["open", "accepted", "declined"];
4883
+ var OfferInputSchema = external_exports.object({
4884
+ job_id: reqLine,
4885
+ offer_status: external_exports.enum(OFFER_STATUSES).optional().describe("\uC624\uD37C \uC0C1\uD0DC \u2014 open(\uC9C4\uD589 \uC911)/accepted(\uC218\uB77D)/declined(\uAC70\uC808). \uC0DD\uB7B5 \uC2DC \uAE30\uC874 \uAC12 \uC720\uC9C0(\uC2E0\uADDC \uC800\uC7A5\uC740 open)"),
4886
+ base_salary: external_exports.number().nonnegative().optional().describe("\uAE30\uBCF8\uAE09/\uC5F0\uBD09 (\uB9CC\uC6D0)"),
4887
+ bonus_amount: external_exports.number().nonnegative().optional().describe("\uC5F0 \uC131\uACFC\uAE09\xB7\uC0C1\uC5EC \uAE30\uB300\uC561 (\uB9CC\uC6D0)"),
4888
+ welfare_amount: external_exports.number().nonnegative().optional().describe("\uBCF5\uC9C0\uD3EC\uC778\uD2B8 \uB4F1 \uC5F0\uD658\uC0B0 (\uB9CC\uC6D0)"),
4889
+ signing_amount: external_exports.number().nonnegative().optional().describe("\uC0AC\uC774\uB2DD\uBCF4\uB108\uC2A4 \uC77C\uC2DC\uAE08 (\uB9CC\uC6D0)"),
4890
+ equity_note: optNote.describe("\uC8FC\uC2DD/\uC2A4\uD1A1\uC635\uC158 (\uC815\uC131 \uC11C\uC220 \u2014 \uAC00\uCE58\uD3C9\uAC00\uAC00 \uBAA8\uD638\uD558\uBBC0\uB85C \uD14D\uC2A4\uD2B8)"),
4891
+ comp_note: optBody.describe("\uAE30\uD0C0 \uBCF4\uC0C1 \uC790\uC720 \uC11C\uC220"),
4892
+ work_arrangement: optLine.describe("\uADFC\uBB34\uD615\uD0DC (\uC7AC\uD0DD/\uD558\uC774\uBE0C\uB9AC\uB4DC/\uCD9C\uADFC)"),
4893
+ contract_type: optLine.describe("\uACC4\uC57D\uD615\uD0DC (\uC815\uADDC/\uACC4\uC57D \uB4F1)"),
4894
+ accept_deadline: optLine.describe("\uC218\uB77D \uB9C8\uAC10\uC77C YYYY-MM-DD"),
4895
+ ai_score: external_exports.number().min(0).max(100).optional().describe("AI\uAC00 \uB9E4\uAE34 \uC885\uD569 \uC810\uC218 0~100 (\uC800\uC7A5 \uC2DC AI\uAC00 \uCC44\uC6C0)"),
4896
+ verdict: optBody.describe("\uCD5C\uC885\uD3C9 \u2014 \uC5B4\uB5A4 \uC0C1\uD669\uC774\uBA74 \uC5B4\uB514\uAC00 \uC88B\uC740\uC9C0 \uB4F1 (AI\uAC00 \uC800\uC7A5 \uC2DC \uC791\uC131)"),
4897
+ notes: optNote
4898
+ });
4899
+ var OfferRecordSchema = external_exports.object({
4900
+ ...baseRecord,
4901
+ job_id: external_exports.string(),
4902
+ offer_status: external_exports.enum(OFFER_STATUSES),
4903
+ base_salary: external_exports.number().nullable(),
4904
+ bonus_amount: external_exports.number().nullable(),
4905
+ welfare_amount: external_exports.number().nullable(),
4906
+ signing_amount: external_exports.number().nullable(),
4907
+ equity_note: external_exports.string().nullable(),
4908
+ comp_note: external_exports.string().nullable(),
4909
+ work_arrangement: external_exports.string().nullable(),
4910
+ contract_type: external_exports.string().nullable(),
4911
+ accept_deadline: external_exports.string().nullable(),
4912
+ ai_score: external_exports.number().nullable(),
4913
+ verdict: external_exports.string().nullable(),
4914
+ notes: external_exports.string().nullable(),
4915
+ /** Server-computed annual cash estimate = base + bonus + welfare (signing is one-time, excluded). Null if no numeric pieces. */
4916
+ total_comp_annual_est: external_exports.number().nullable()
4730
4917
  });
4731
4918
  var FitAnalysisInputSchema = external_exports.object({
4732
4919
  job_id: reqLine.describe("\uBD84\uC11D \uB300\uC0C1 \uACF5\uACE0 ID"),
@@ -4749,6 +4936,7 @@ var FitAnalysisRecordSchema = external_exports.object({
4749
4936
  missing_keywords: external_exports.array(external_exports.string()),
4750
4937
  recommendations: external_exports.array(external_exports.string())
4751
4938
  });
4939
+ var REJECTED_STAGES = ["document", "assessment", "interview", "final", "other"];
4752
4940
  var ApplicationInputSchema = external_exports.object({
4753
4941
  job_id: reqLine,
4754
4942
  status: external_exports.enum(APPLICATION_STATUSES).optional(),
@@ -4769,7 +4957,10 @@ var ApplicationStatusUpdateSchema = external_exports.object({
4769
4957
  application_id: optLine,
4770
4958
  status: external_exports.enum(APPLICATION_STATUSES),
4771
4959
  note: optNote.describe("\uC0C1\uD0DC \uBCC0\uACBD \uC0AC\uC720/\uBA54\uBAA8"),
4772
- submission: ApplicationSubmissionSchema.optional().describe("\uC9C0\uC6D0 \uC644\uB8CC \uAE30\uB85D\uC5D0 \uB0A8\uAE38 \uC81C\uCD9C \uC790\uB8CC \uC2A4\uB0C5\uC0F7")
4960
+ submission: ApplicationSubmissionSchema.optional().describe("\uC9C0\uC6D0 \uC644\uB8CC \uAE30\uB85D\uC5D0 \uB0A8\uAE38 \uC81C\uCD9C \uC790\uB8CC \uC2A4\uB0C5\uC0F7"),
4961
+ rejected_stage: external_exports.enum(REJECTED_STAGES).optional().describe(
4962
+ "\uD0C8\uB77D \uD68C\uACE0 \u2014 'rejected'\uB85C \uBC14\uAFC0 \uB54C \uC5B4\uB290 \uB2E8\uACC4\uC5D0\uC11C \uB5A8\uC5B4\uC84C\uB294\uC9C0: \uC11C\uB958=document, \uC778\uC801\uC131\xB7AI\uC5ED\uAC80=assessment, \uBA74\uC811=interview, \uCD5C\uC885\xB7\uCC98\uC6B0=final, \uAE30\uD0C0=other. rejected\uAC00 \uC544\uB2CC \uC0C1\uD0DC\uC5D0\uB294 \uC800\uC7A5\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4."
4963
+ )
4773
4964
  });
4774
4965
  var ApplicationRecordSchema = external_exports.object({
4775
4966
  ...baseRecord,
@@ -4778,7 +4969,8 @@ var ApplicationRecordSchema = external_exports.object({
4778
4969
  resume_id: external_exports.string().nullable(),
4779
4970
  cover_letter_id: external_exports.string().nullable(),
4780
4971
  applied_at: external_exports.string().nullable(),
4781
- notes: external_exports.string().nullable()
4972
+ notes: external_exports.string().nullable(),
4973
+ rejected_stage: external_exports.enum(REJECTED_STAGES).nullable()
4782
4974
  });
4783
4975
  var StarGuideSchema = external_exports.object({
4784
4976
  question: external_exports.string().max(MAX_NOTE),
@@ -1,5 +1,16 @@
1
1
  // Applications page.
2
2
  export default {
3
+ // offers comparison
4
+ 'applications.offers.title': 'Offer comparison',
5
+ 'applications.offers.est': 'Est. annual cash',
6
+ 'applications.offers.unit': '만원',
7
+ 'applications.offers.noScore': 'No score',
8
+ 'applications.offers.acceptBy': 'Accept by {{date}}',
9
+ 'applications.offers.expired': 'Deadline passed',
10
+ 'applications.offers.past': 'Past offers · {{count}} (accepted/declined)',
11
+ 'applications.offers.status.accepted': 'Accepted',
12
+ 'applications.offers.status.declined': 'Declined',
13
+
3
14
  // deadline D-day chip
4
15
  'applications.dday.future': 'Due in {{n}}d',
5
16
  'applications.dday.today': 'Due today',
@@ -16,6 +27,14 @@ export default {
16
27
  // app row
17
28
  'applications.row.score': '{{score}} pts',
18
29
 
30
+ // Rejection retrospective — which stage the rejection happened at (rejected rows only)
31
+ 'applications.rejectedStage.label': 'Rejected at:',
32
+ 'applications.rejectedStage.document': 'Screening',
33
+ 'applications.rejectedStage.assessment': 'Assessment/AI',
34
+ 'applications.rejectedStage.interview': 'Interview',
35
+ 'applications.rejectedStage.final': 'Final/offer',
36
+ 'applications.rejectedStage.other': 'Other',
37
+
19
38
  // status select + toasts
20
39
  'applications.statusSelect.title': 'Change status',
21
40
  'applications.toast.statusChanged': 'Application status updated.',
@@ -14,6 +14,12 @@ export default {
14
14
  'common.fmtHtml': 'HTML',
15
15
  'common.fmtMarkdown': 'Markdown',
16
16
 
17
+ // Hiring-schedule stage D-day badge (shared by Home action lane + job detail).
18
+ // Separate from the deadline D-day labels (applications.dday.*).
19
+ 'schedule.dday.today': 'D-day',
20
+ 'schedule.dday.future': 'D-{{n}}',
21
+ 'schedule.dday.past': 'Past',
22
+
17
23
  'toast.copied': 'Copied to clipboard.',
18
24
  'toast.copyFailed': "Couldn't copy. Please select and copy it manually.",
19
25
  'toast.errorTitle': 'Error',
@@ -73,6 +73,11 @@ export default {
73
73
  'jobs.detail.openPosting': 'Original posting',
74
74
 
75
75
  // ---- posting card ----
76
+ 'jobs.company.title': 'Company research',
77
+ 'jobs.company.overview': 'Overview',
78
+ 'jobs.company.talent': 'Ideal candidate',
79
+ 'jobs.company.values': 'Core values',
80
+ 'jobs.company.reputation': 'Reputation',
76
81
  'jobs.posting.title': 'Posting details',
77
82
  'jobs.posting.location': 'Location',
78
83
  'jobs.posting.employment': 'Employment type',
@@ -85,6 +90,18 @@ export default {
85
90
  'jobs.posting.requirements': 'Requirements · Preferred',
86
91
  'jobs.posting.rawText': 'Original posting',
87
92
 
93
+ // ---- cover letter questions (문항형 자소서) ----
94
+ 'jobs.questions.title': 'Cover letter questions',
95
+ 'jobs.questions.min': 'min {{n}}{{unit}}',
96
+ 'jobs.questions.max': 'max {{n}}{{unit}}',
97
+ 'jobs.questions.unitChar': ' chars',
98
+ 'jobs.questions.unitByte': ' bytes',
99
+ 'jobs.questions.modeWithSpace': ' (incl. spaces)',
100
+ 'jobs.questions.modeNoSpace': ' (excl. spaces)',
101
+
102
+ // ---- hiring schedule (전형 일정) ---- (D-day badge uses shared schedule.dday.*)
103
+ 'jobs.schedule.title': 'Hiring schedule',
104
+
88
105
  // ---- fit card ----
89
106
  'jobs.fit.title': 'Fit analysis',
90
107
  'jobs.fit.emptyBody': 'Not analyzed yet. The comparison between this posting and your profile will appear here.',
@@ -105,18 +105,21 @@ export default {
105
105
  'settings.import.confirm': 'Import',
106
106
  'settings.import.doneToast': 'Data imported.',
107
107
 
108
+ // language
109
+ 'settings.lang.sub': 'Dashboard display language',
110
+
108
111
  // danger zone
109
112
  'settings.reset.gateWord': 'RESET',
110
113
  'settings.reset.title': 'Reset all data',
111
114
  'settings.reset.calloutTitle': "This can't be undone",
112
- 'settings.reset.calloutBody': 'All data, including your profile, postings, applications, documents, and interview prep, will be permanently deleted. For safety, a backup is created automatically right before the reset.',
115
+ 'settings.reset.calloutBody': 'All data, including your profile, postings, applications, documents, and interview prep, will be permanently deleted. For safety, a backup is created automatically right before the reset — that backup file remains in the backups folder inside your data directory, so delete it too if you want no trace left.',
113
116
  'settings.reset.instruction': 'To continue, type {{word}} in the field below.',
114
117
  'settings.reset.confirm': 'Reset all data',
115
118
  'settings.reset.doneToast': 'All data has been reset.',
116
119
  'settings.danger.title': 'Danger zone',
117
120
  'settings.danger.sub': "This action can't be undone",
118
121
  'settings.danger.resetTitle': 'Reset all data',
119
- 'settings.danger.resetBody': 'Deletes all CareerMate data stored on this computer. A backup is created automatically before the reset.',
122
+ 'settings.danger.resetBody': 'Deletes all CareerMate data stored on this computer. An automatic backup is created before the reset and remains in the backups folder.',
120
123
 
121
124
  // help / report a problem
122
125
  'settings.support.title': 'Help & report a problem',
@@ -1,5 +1,16 @@
1
1
  // 지원 현황 페이지.
2
2
  export default {
3
+ // offers comparison
4
+ 'applications.offers.title': '오퍼 비교',
5
+ 'applications.offers.est': '추정 연 현금성',
6
+ 'applications.offers.unit': '만원',
7
+ 'applications.offers.noScore': '점수 미정',
8
+ 'applications.offers.acceptBy': '수락 마감 {{date}}',
9
+ 'applications.offers.expired': '수락 마감 지남',
10
+ 'applications.offers.past': '지난 오퍼 {{count}}건 (수락·거절)',
11
+ 'applications.offers.status.accepted': '수락',
12
+ 'applications.offers.status.declined': '거절',
13
+
3
14
  // deadline D-day chip
4
15
  'applications.dday.future': '마감 D-{{n}}',
5
16
  'applications.dday.today': '마감 D-day',
@@ -16,6 +27,14 @@ export default {
16
27
  // app row
17
28
  'applications.row.score': '{{score}}점',
18
29
 
30
+ // 탈락 회고 — 어느 단계에서 떨어졌는지 병기(불합격 행에만 표시)
31
+ 'applications.rejectedStage.label': '탈락 단계:',
32
+ 'applications.rejectedStage.document': '서류',
33
+ 'applications.rejectedStage.assessment': '인적성·AI역검',
34
+ 'applications.rejectedStage.interview': '면접',
35
+ 'applications.rejectedStage.final': '최종·처우',
36
+ 'applications.rejectedStage.other': '기타',
37
+
19
38
  // status select + toasts
20
39
  'applications.statusSelect.title': '상태 변경',
21
40
  'applications.toast.statusChanged': '지원 상태를 변경했어요.',
@@ -14,6 +14,11 @@ export default {
14
14
  'common.fmtHtml': 'HTML',
15
15
  'common.fmtMarkdown': 'Markdown',
16
16
 
17
+ // 전형 일정 D-day 배지(홈 액션레인 + 공고 상세 공유). 마감 D-day(applications.dday.*)와는 별개.
18
+ 'schedule.dday.today': 'D-day',
19
+ 'schedule.dday.future': 'D-{{n}}',
20
+ 'schedule.dday.past': '지남',
21
+
17
22
  'toast.copied': '클립보드에 복사했습니다.',
18
23
  'toast.copyFailed': '복사에 실패했습니다. 직접 선택해 복사해 주세요.',
19
24
  'toast.errorTitle': '오류',
@@ -73,6 +73,11 @@ export default {
73
73
  'jobs.detail.openPosting': '공고 원문',
74
74
 
75
75
  // ---- posting card ----
76
+ 'jobs.company.title': '회사 리서치',
77
+ 'jobs.company.overview': '사업 개요',
78
+ 'jobs.company.talent': '인재상',
79
+ 'jobs.company.values': '핵심가치',
80
+ 'jobs.company.reputation': '평판',
76
81
  'jobs.posting.title': '공고 정보',
77
82
  'jobs.posting.location': '위치',
78
83
  'jobs.posting.employment': '고용형태',
@@ -85,6 +90,18 @@ export default {
85
90
  'jobs.posting.requirements': '자격요건 · 우대사항',
86
91
  'jobs.posting.rawText': '공고 원문',
87
92
 
93
+ // ---- cover letter questions (문항형 자소서) ----
94
+ 'jobs.questions.title': '자소서 문항',
95
+ 'jobs.questions.min': '최소 {{n}}{{unit}}',
96
+ 'jobs.questions.max': '최대 {{n}}{{unit}}',
97
+ 'jobs.questions.unitChar': '자',
98
+ 'jobs.questions.unitByte': 'byte',
99
+ 'jobs.questions.modeWithSpace': '(공백포함)',
100
+ 'jobs.questions.modeNoSpace': '(공백제외)',
101
+
102
+ // ---- hiring schedule (전형 일정) ---- (D-day 배지는 공용 schedule.dday.* 사용)
103
+ 'jobs.schedule.title': '전형 일정',
104
+
88
105
  // ---- fit card ----
89
106
  'jobs.fit.title': '적합도 분석',
90
107
  'jobs.fit.emptyBody': '아직 분석 전이에요. 이 공고와 내 프로필을 비교한 결과가 여기에 표시됩니다.',
@@ -105,18 +105,21 @@ export default {
105
105
  'settings.import.confirm': '가져오기',
106
106
  'settings.import.doneToast': '데이터를 가져왔어요.',
107
107
 
108
+ // 언어
109
+ 'settings.lang.sub': '대시보드 표시 언어',
110
+
108
111
  // 위험 구역
109
112
  'settings.reset.gateWord': '초기화',
110
113
  'settings.reset.title': '모든 데이터 초기화',
111
114
  'settings.reset.calloutTitle': '되돌릴 수 없습니다',
112
- 'settings.reset.calloutBody': '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성됩니다.',
115
+ 'settings.reset.calloutBody': '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성되며, 이 백업 파일은 데이터 폴더의 backups에 남습니다 — 흔적 없이 지우려면 초기화 후 그 파일도 직접 삭제하세요.',
113
116
  'settings.reset.instruction': '계속하려면 아래 입력란에 {{word}} 를 입력하세요.',
114
117
  'settings.reset.confirm': '모든 데이터 초기화',
115
118
  'settings.reset.doneToast': '모든 데이터를 초기화했어요.',
116
119
  'settings.danger.title': '위험 구역',
117
120
  'settings.danger.sub': '이 작업은 되돌릴 수 없습니다',
118
121
  'settings.danger.resetTitle': '모든 데이터 초기화',
119
- 'settings.danger.resetBody': '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동으로 백업이 생성됩니다.',
122
+ 'settings.danger.resetBody': '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동 백업이 생성되어 backups 폴더에 남습니다.',
120
123
 
121
124
  // help / report a problem
122
125
  'settings.support.title': '도움말·문제 신고',
@@ -184,6 +184,24 @@ export function scoreClass(score) {
184
184
  if (score >= 50) return 'score-mid';
185
185
  return 'score-weak';
186
186
  }
187
+ /* --------------------------------------------------------------- date math */
188
+ // Local-calendar helpers. The server (core daysUntilDate) computes D-days from LOCAL
189
+ // midnight; mirror that here so the dashboard never disagrees by a day near midnight
190
+ // or across timezones — toISOString() would use UTC and can drift the date.
191
+ /** Today as a local YYYY-MM-DD string (not UTC). */
192
+ export function todayLocalISO() {
193
+ const d = new Date();
194
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
195
+ }
196
+ /** Whole days from local midnight today to a YYYY-MM-DD date (future > 0, today = 0, past < 0); null if unparseable. */
197
+ export function daysUntil(dateStr) {
198
+ if (!dateStr) return null;
199
+ const d = new Date(`${dateStr}T00:00:00`);
200
+ if (isNaN(d.getTime())) return null;
201
+ const today = new Date();
202
+ today.setHours(0, 0, 0, 0);
203
+ return Math.round((d.getTime() - today.getTime()) / 86400000);
204
+ }
187
205
  /** Textarea (newline-separated) → trimmed array, empties dropped. */
188
206
  export function linesToArray(str) { return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean); }
189
207
  /** CSV ("a, b, c") → trimmed array, empties dropped. */
@@ -208,6 +226,17 @@ export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '
208
226
  export function Badge(status, label) {
209
227
  return el('span', { class: `badge badge--${status}` }, el('span', { class: 'dot' }), label);
210
228
  }
229
+ /**
230
+ * Hiring-schedule stage D-day badge (면접·인적성·발표 date), shared by the Home action
231
+ * lane and the job-detail schedule list so label + colour never diverge: past = grey
232
+ * "지남", today = red "D-day", future = amber "D-{{n}}". Pass a D-day computed with the
233
+ * same local-midnight rule as the server (see daysUntil / core daysUntilDate).
234
+ */
235
+ export function scheduleDdayBadge(dDay) {
236
+ if (dDay < 0) return Badge('draft', t('schedule.dday.past'));
237
+ if (dDay === 0) return Badge('rejected', t('schedule.dday.today'));
238
+ return Badge('interview', t('schedule.dday.future', { n: dDay }));
239
+ }
211
240
  export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled, title } = {}) {
212
241
  const cls = ['btn', variant && `btn--${variant}`, sm && 'btn--sm'].filter(Boolean).join(' ');
213
242
  return el('button', { class: cls, onClick, type: type || 'button', disabled, title }, ic && icon(ic), label && el('span', {}, label));