From bda01febd5dc839aedb3704332adcbb56f5090dc Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 18:20:18 +0200 Subject: [PATCH] feat(schema): structured resume dates with a read-time upgrade and dual write Dated entries and roles carry dates (start, end, present, and raw when the text couldn't be read exactly). The parser moves to the schema package and reports how each date was written; parseResumeData fills dates, infers the date format from how dates were typed and rewrites the legacy period/date text from them, so older clients and API readers keep working. API writes sync against the stored data, so an edit to the text alone is read back into dates. getById and getBySlug return upgraded data, ATS date rules and sorting read dates, JSON Resume and LinkedIn imports map their dates directly, entry titles may be empty (drafts aren't printed), and the MCP schema resource is generated live in place of the stale schema.json. --- docs/guides/json-resume-schema.mdx | 882 +++- .../features/resume/resume-data-validation.ts | 14 +- packages/api/src/features/resume/service.ts | 15 +- packages/import/src/date.test.ts | 16 +- packages/import/src/date.ts | 19 + packages/import/src/json-resume.tsx | 9 +- packages/import/src/linkedin.test.ts | 7 +- packages/import/src/linkedin.ts | 30 +- packages/import/src/plain-text.test.ts | 18 +- packages/import/src/plain-text.ts | 2 +- packages/mcp/src/resources.ts | 5 +- packages/mcp/src/tool-meta.ts | 4 + packages/resume/src/ats-pdf/analyze/dates.ts | 7 +- packages/resume/src/ats/index.ts | 2 - packages/resume/src/ats/rules.test.ts | 10 + packages/resume/src/ats/rules.ts | 41 +- packages/resume/src/section-sort.ts | 9 +- packages/schema/package.json | 1 - packages/schema/schema.json | 4399 ----------------- packages/schema/src/resume/data.test.ts | 10 +- packages/schema/src/resume/data.ts | 95 +- packages/schema/src/resume/dates.test.ts | 296 ++ packages/schema/src/resume/dates.ts | 288 ++ packages/schema/src/resume/default.ts | 1 + .../ats => schema/src/resume}/period.test.ts | 0 .../src/ats => schema/src/resume}/period.ts | 123 +- .../schema/src/resume/present-labels.json | 57 + skills/resume-builder/references/schema.md | 289 +- tooling/locales/generate.ts | 16 +- tooling/locales/section-titles.test.ts | 12 +- tooling/locales/section-titles.ts | 12 +- tooling/recovery/compare-resume.test.ts | 8 +- 32 files changed, 2082 insertions(+), 4615 deletions(-) delete mode 100644 packages/schema/schema.json create mode 100644 packages/schema/src/resume/dates.test.ts create mode 100644 packages/schema/src/resume/dates.ts rename packages/{resume/src/ats => schema/src/resume}/period.test.ts (100%) rename packages/{resume/src/ats => schema/src/resume}/period.ts (58%) create mode 100644 packages/schema/src/resume/present-labels.json diff --git a/docs/guides/json-resume-schema.mdx b/docs/guides/json-resume-schema.mdx index 06688e6ae..4a4de25a3 100644 --- a/docs/guides/json-resume-schema.mdx +++ b/docs/guides/json-resume-schema.mdx @@ -434,8 +434,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "network": { "type": "string", - "minLength": 1, - "description": "The name of the network or platform." + "description": "The name of the network or platform. Empty while the entry is a draft, which isn't printed." }, "username": { "type": "string", @@ -548,8 +547,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "company": { "type": "string", - "minLength": 1, - "description": "The name of the company or organization." + "description": "The name of the company or organization. Empty while the entry is a draft, which isn't printed." }, "position": { "type": "string", @@ -561,7 +559,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The overall period of time at the company. When multiple roles are used, this should reflect the total tenure." + "description": "The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -613,7 +659,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The period of time this role was held." + "description": "The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "description": { "type": "string", @@ -708,8 +802,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "school": { "type": "string", - "minLength": 1, - "description": "The name of the school or institution." + "description": "The name of the school or institution. Empty while the entry is a draft, which isn't printed." }, "degree": { "type": "string", @@ -729,7 +822,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The period of time the education was obtained over." + "description": "The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -845,12 +986,59 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the project." + "description": "The name of the project. Empty while the entry is a draft, which isn't printed." }, "period": { "type": "string", - "description": "The period of time the project was worked on." + "description": "The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -972,8 +1160,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the skill." + "description": "The name of the skill. Empty while the entry is a draft, which isn't printed." }, "proficiency": { "type": "string", @@ -1090,8 +1277,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "language": { "type": "string", - "minLength": 1, - "description": "The name of the language the author knows." + "description": "The name of the language the author knows. Empty while the entry is a draft, which isn't printed." }, "fluency": { "type": "string", @@ -1189,8 +1375,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the interest/hobby." + "description": "The name of the interest/hobby. Empty while the entry is a draft, which isn't printed." }, "keywords": { "default": [], @@ -1277,8 +1462,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "title": { "type": "string", - "minLength": 1, - "description": "The title of the award." + "description": "The title of the award. Empty while the entry is a draft, which isn't printed." }, "awarder": { "type": "string", @@ -1286,7 +1470,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "date": { "type": "string", - "description": "The date when the award was received." + "description": "The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -1399,8 +1631,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "title": { "type": "string", - "minLength": 1, - "description": "The title of the certification." + "description": "The title of the certification. Empty while the entry is a draft, which isn't printed." }, "issuer": { "type": "string", @@ -1408,7 +1639,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "date": { "type": "string", - "description": "The date when the certification was received." + "description": "The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -1521,8 +1800,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "title": { "type": "string", - "minLength": 1, - "description": "The title of the publication." + "description": "The title of the publication. Empty while the entry is a draft, which isn't printed." }, "publisher": { "type": "string", @@ -1530,7 +1808,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "date": { "type": "string", - "description": "The date when the publication was published." + "description": "The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -1643,8 +1969,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "organization": { "type": "string", - "minLength": 1, - "description": "The name of the organization or company." + "description": "The name of the organization or company. Empty while the entry is a draft, which isn't printed." }, "location": { "type": "string", @@ -1652,7 +1977,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The period of time the author was volunteered at the organization or company." + "description": "The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -1765,8 +2138,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the reference, or a note such as 'Available upon request'." + "description": "The name of the reference, or a note such as 'Available upon request'. Empty while the entry is a draft, which isn't printed." }, "position": { "type": "string", @@ -2108,8 +2480,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "network": { "type": "string", - "minLength": 1, - "description": "The name of the network or platform." + "description": "The name of the network or platform. Empty while the entry is a draft, which isn't printed." }, "username": { "type": "string", @@ -2234,8 +2605,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "company": { "type": "string", - "minLength": 1, - "description": "The name of the company or organization." + "description": "The name of the company or organization. Empty while the entry is a draft, which isn't printed." }, "position": { "type": "string", @@ -2247,7 +2617,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The overall period of time at the company. When multiple roles are used, this should reflect the total tenure." + "description": "The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -2299,7 +2717,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The period of time this role was held." + "description": "The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "description": { "type": "string", @@ -2406,8 +2872,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "school": { "type": "string", - "minLength": 1, - "description": "The name of the school or institution." + "description": "The name of the school or institution. Empty while the entry is a draft, which isn't printed." }, "degree": { "type": "string", @@ -2427,7 +2892,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The period of time the education was obtained over." + "description": "The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -2555,12 +3068,59 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the project." + "description": "The name of the project. Empty while the entry is a draft, which isn't printed." }, "period": { "type": "string", - "description": "The period of time the project was worked on." + "description": "The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -2701,8 +3261,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the skill." + "description": "The name of the skill. Empty while the entry is a draft, which isn't printed." }, "proficiency": { "type": "string", @@ -2814,8 +3373,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "language": { "type": "string", - "minLength": 1, - "description": "The name of the language the author knows." + "description": "The name of the language the author knows. Empty while the entry is a draft, which isn't printed." }, "fluency": { "type": "string", @@ -2925,8 +3483,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the interest/hobby." + "description": "The name of the interest/hobby. Empty while the entry is a draft, which isn't printed." }, "keywords": { "default": [], @@ -3025,8 +3582,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "title": { "type": "string", - "minLength": 1, - "description": "The title of the award." + "description": "The title of the award. Empty while the entry is a draft, which isn't printed." }, "awarder": { "type": "string", @@ -3034,7 +3590,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "date": { "type": "string", - "description": "The date when the award was received." + "description": "The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -3159,8 +3763,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "title": { "type": "string", - "minLength": 1, - "description": "The title of the certification." + "description": "The title of the certification. Empty while the entry is a draft, which isn't printed." }, "issuer": { "type": "string", @@ -3168,7 +3771,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "date": { "type": "string", - "description": "The date when the certification was received." + "description": "The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -3293,8 +3944,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "title": { "type": "string", - "minLength": 1, - "description": "The title of the publication." + "description": "The title of the publication. Empty while the entry is a draft, which isn't printed." }, "publisher": { "type": "string", @@ -3302,7 +3952,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "date": { "type": "string", - "description": "The date when the publication was published." + "description": "The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -3427,8 +4125,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "organization": { "type": "string", - "minLength": 1, - "description": "The name of the organization or company." + "description": "The name of the organization or company. Empty while the entry is a draft, which isn't printed." }, "location": { "type": "string", @@ -3436,7 +4133,55 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "period": { "type": "string", - "description": "The period of time the author was volunteered at the organization or company." + "description": "The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`." + }, + "dates": { + "type": "object", + "properties": { + "start": { + "default": null, + "description": "When it started, or the date itself for single-date entries (awards, certifications, publications).", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "description": "When it ended. Null while ongoing or for single dates.", + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}(?:-(?:0[1-9]|1[0-2]))?$", + "description": "A year (\"2022\") or a year and month (\"2022-03\")." + }, + { + "type": "null" + } + ] + }, + "present": { + "default": false, + "description": "Whether it's ongoing, printed as 'Present'.", + "type": "boolean" + }, + "raw": { + "description": "The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.", + "type": "string" + } + }, + "required": [ + "start", + "end", + "present" + ], + "description": "Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them." }, "website": { "default": { @@ -3561,8 +4306,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest }, "name": { "type": "string", - "minLength": 1, - "description": "The name of the reference, or a note such as 'Available upon request'." + "description": "The name of the reference, or a note such as 'Available upon request'. Empty while the entry is a draft, which isn't printed." }, "position": { "type": "string", @@ -3752,6 +4496,16 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest "type": "string", "description": "The locale of the page. Used for displaying pre-translated section headings, if not overridden." }, + "dateFormat": { + "description": "How dates print: 'short' (Mar 2022), 'long' (March 2022), 'numeric' (03/2022) or 'iso' (2022-03). When missing, it's read from how the dates were typed.", + "type": "string", + "enum": [ + "short", + "long", + "numeric", + "iso" + ] + }, "hideLinkUnderline": { "default": false, "type": "boolean", diff --git a/packages/api/src/features/resume/resume-data-validation.ts b/packages/api/src/features/resume/resume-data-validation.ts index defa7d890..e5ea476f0 100644 --- a/packages/api/src/features/resume/resume-data-validation.ts +++ b/packages/api/src/features/resume/resume-data-validation.ts @@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { ORPCError } from "@orpc/client"; import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet"; import { parseResumeData } from "@reactive-resume/schema/resume/data"; +import { syncResumeDates, upgradeResumeDates } from "@reactive-resume/schema/resume/dates"; import { parseResumeDataForWrite } from "@reactive-resume/schema/resume/write"; function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", message: string): ResumeData { @@ -21,8 +22,17 @@ function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVE } } -export const parseWritableResumeData = (data: unknown) => - parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema."); +/** + * Validates data before it's saved, then keeps structured dates and their legacy text in step. `previous` is + * the stored data being replaced, which tells an edit to the text alone (from a client that doesn't know + * structured dates) from an edit to the dates. + */ +export const parseWritableResumeData = (data: unknown, previous?: ResumeData) => { + const parsed = parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema."); + upgradeResumeDates(parsed); + syncResumeDates(parsed, previous); + return parsed; +}; export const parseStoredResumeData = (data: unknown) => parseApiResumeData(data, "INTERNAL_SERVER_ERROR", "Stored resume data does not match the canonical schema."); diff --git a/packages/api/src/features/resume/service.ts b/packages/api/src/features/resume/service.ts index 18288bb87..6a2058a0b 100644 --- a/packages/api/src/features/resume/service.ts +++ b/packages/api/src/features/resume/service.ts @@ -140,9 +140,10 @@ async function applyResumePatchTx( input.operations.forEach(assertValidPatchPointers); let patchedData: ResumeData; + const storedData = parseStoredResumeData(existing.data); try { - patchedData = applyResumePatches(parseStoredResumeData(existing.data), input.operations); + patchedData = applyResumePatches(storedData, input.operations); } catch (error) { if (error instanceof ResumePatchError) { throw new ORPCError("INVALID_PATCH_OPERATIONS", { @@ -158,7 +159,7 @@ async function applyResumePatchTx( }); } - patchedData = parseWritableResumeData(patchedData); + patchedData = parseWritableResumeData(patchedData, storedData); // The version guard is the ms-precision JS check above, under the SELECT ... FOR UPDATE lock. // Never compare expectedUpdatedAt in SQL: rows stamped by Postgres now() (defaultNow() on // insert) carry microseconds, while JS Dates are ms-truncated — SQL equality then matches @@ -509,7 +510,8 @@ export const resumeService = { if (!resume) throw new ORPCError("NOT_FOUND"); - return resume; + // Clients get the current data shape (structured dates and their text in step), not the stored one. + return { ...resume, data: parseStoredResumeData(resume.data) }; }, getBySlug: async (input: { @@ -562,7 +564,8 @@ export const resumeService = { } } - return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword); + const current = { ...resume, data: parseStoredResumeData(resume.data) }; + return toSharedResumeResponse(redactResumeForViewer(current, isOwner(current, viewer)), resume.hasPassword); }, create: async (input: { @@ -633,7 +636,9 @@ export const resumeService = { if (!existing) throw new ORPCError("NOT_FOUND"); if (existing.isLocked) throw new ORPCError("RESUME_LOCKED"); - const normalizedData = input.data ? parseWritableResumeData(input.data) : undefined; + const normalizedData = input.data + ? parseWritableResumeData(input.data, parseStoredResumeData(existing.data)) + : undefined; const updateData: Partial = { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.slug !== undefined ? { slug: input.slug } : {}), diff --git a/packages/import/src/date.test.ts b/packages/import/src/date.test.ts index fa869c6d8..00ebb9129 100644 --- a/packages/import/src/date.test.ts +++ b/packages/import/src/date.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatDate, formatPeriod, formatSingleDate } from "./date"; +import { formatDate, formatPeriod, formatSingleDate, toRangeDates, toSingleDates, toYearMonth } from "./date"; describe("formatDate", () => { it("formats YYYY-MM as 'Month Year'", () => { @@ -87,3 +87,17 @@ describe("formatSingleDate", () => { expect(formatSingleDate("2024")).toBe("2024"); }); }); + +describe("structured dates", () => { + it("keeps the year and month of ISO dates", () => { + expect(toYearMonth("2024-01-15")).toBe("2024-01"); + expect(toYearMonth("2024")).toBe("2024"); + expect(toYearMonth("soon")).toBeNull(); + }); + + it("marks a start without an end as ongoing", () => { + expect(toRangeDates("2020-03", undefined)).toEqual({ start: "2020-03", end: null, present: true }); + expect(toRangeDates("2020-03", "2021")).toEqual({ start: "2020-03", end: "2021", present: false }); + expect(toSingleDates("2019-11-02")).toEqual({ start: "2019-11", end: null, present: false }); + }); +}); diff --git a/packages/import/src/date.ts b/packages/import/src/date.ts index 2a4ce559c..6f6d0d53e 100644 --- a/packages/import/src/date.ts +++ b/packages/import/src/date.ts @@ -1,3 +1,5 @@ +import type { ResumeDates, YearMonth } from "@reactive-resume/schema/resume/dates"; + // ponytail: Intl replaces the 12-line MONTH_NAMES array; pinned to en-US so output is stable const fmt = new Intl.DateTimeFormat("en-US", { month: "long" }); @@ -49,3 +51,20 @@ export function formatSingleDate(date?: string): string { if (!date) return ""; return formatDate(date, true); } + +/** The year and month of an ISO 8601 date ("2024-01-15" → "2024-01"); null when it isn't one. */ +export function toYearMonth(date?: string): YearMonth | null { + const match = /^(\d{4})(?:-(0[1-9]|1[0-2]))?/.exec(date?.trim() ?? ""); + if (!match?.[1]) return null; + return match[2] ? `${match[1]}-${match[2]}` : match[1]; +} + +/** Structured dates for a range of ISO dates; a start with no end is ongoing, as `formatPeriod` prints it. */ +export function toRangeDates(startDate?: string, endDate?: string): ResumeDates { + const start = toYearMonth(startDate); + const end = toYearMonth(endDate); + return { start, end, present: Boolean(start) && !endDate }; +} + +/** Structured dates for a single ISO date. The day, if any, doesn't print. */ +export const toSingleDates = (date?: string): ResumeDates => ({ start: toYearMonth(date), end: null, present: false }); diff --git a/packages/import/src/json-resume.tsx b/packages/import/src/json-resume.tsx index 6d1e260c2..a4a233cb0 100644 --- a/packages/import/src/json-resume.tsx +++ b/packages/import/src/json-resume.tsx @@ -4,7 +4,7 @@ import { getNetworkIcon } from "@reactive-resume/resume/icons"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { generateId } from "@reactive-resume/utils/string"; -import { formatPeriod, formatSingleDate } from "./date"; +import { formatPeriod, formatSingleDate, toRangeDates, toSingleDates } from "./date"; import { rethrowAsImportError } from "./error"; import { arrayToHtmlList, toHtmlDescription } from "./html"; import { parseLevel } from "./level"; @@ -221,6 +221,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData { position: work.position || "", location: work.location || "", period: formatPeriod(work.startDate, work.endDate), + dates: toRangeDates(work.startDate, work.endDate), website: createItemWebsite(work.url), roles: [], description: toHtmlDescription(work.summary, work.highlights), @@ -243,6 +244,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData { grade: edu.score || "", location: "", period: formatPeriod(edu.startDate, edu.endDate), + dates: toRangeDates(edu.startDate, edu.endDate), website: createItemWebsite(edu.url), description: edu.courses && edu.courses.length > 0 ? arrayToHtmlList(edu.courses) : "", })), @@ -260,6 +262,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData { hidden: false, name: project.name || "", period: formatPeriod(project.startDate, project.endDate), + dates: toRangeDates(project.startDate, project.endDate), website: createItemWebsite(project.url), description: toHtmlDescription(project.description, project.highlights), })), @@ -330,6 +333,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData { title: award.title || "", awarder: award.awarder || "", date: formatSingleDate(award.date), + dates: toSingleDates(award.date), website: createItemWebsite(), description: award.summary ? `

${award.summary}

` : "", })), @@ -348,6 +352,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData { title: cert.name || "", issuer: cert.issuer || "", date: formatSingleDate(cert.date), + dates: toSingleDates(cert.date), website: createItemWebsite(cert.url), description: "", })), @@ -366,6 +371,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData { title: pub.name || "", publisher: pub.publisher || "", date: formatSingleDate(pub.releaseDate), + dates: toSingleDates(pub.releaseDate), website: createItemWebsite(pub.url), description: pub.summary ? `

${pub.summary}

` : "", })), @@ -384,6 +390,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData { organization: vol.organization || "", location: "", period: formatPeriod(vol.startDate, vol.endDate), + dates: toRangeDates(vol.startDate, vol.endDate), website: createItemWebsite(vol.url), description: toHtmlDescription(vol.summary, vol.highlights), })), diff --git a/packages/import/src/linkedin.test.ts b/packages/import/src/linkedin.test.ts index 1163333b6..37870375e 100644 --- a/packages/import/src/linkedin.test.ts +++ b/packages/import/src/linkedin.test.ts @@ -45,6 +45,7 @@ describe("parseLinkedInExport", () => { expect(item.company).toBe("Acme"); expect(item.position).toBe("Engineer"); expect(item.period).toBe("January 2020 - December 2022"); + expect(item.dates).toEqual({ start: "2020-01", end: "2022-12", present: false }); expect(item.description).toBe("

Built stuff

"); }); @@ -56,6 +57,7 @@ describe("parseLinkedInExport", () => { const result = parseLinkedInExport(zip); expect(result.sections.experience.items).toHaveLength(1); expect(result.sections.experience.items[0]!.period).toBe("January 2020 - Present"); + expect(result.sections.experience.items[0]!.dates).toEqual({ start: "2020-01", end: null, present: true }); }); it("skips positions without a company name", () => { @@ -123,7 +125,10 @@ describe("parseLinkedInExport", () => { "Positions.csv": "Company Name,Started On,Finished On\nAcme,Jan 2020,2021-06-30\n", }); - expect(parseLinkedInExport(zip).sections.experience.items[0]!.period).toBe("January 2020 - 2021-06-30"); + const [item] = parseLinkedInExport(zip).sections.experience.items; + expect(item!.period).toBe("January 2020 - 2021-06-30"); + // An end cell it couldn't read leaves the text to be read, and flagged, when saved. + expect(item!.dates).toBeUndefined(); }); it("reads headers behind a UTF-8 byte order mark", () => { diff --git a/packages/import/src/linkedin.ts b/packages/import/src/linkedin.ts index efa0124d0..e8fdcb2c5 100644 --- a/packages/import/src/linkedin.ts +++ b/packages/import/src/linkedin.ts @@ -1,9 +1,10 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { ResumeDates } from "@reactive-resume/schema/resume/dates"; import { unzipSync } from "fflate"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { generateId } from "@reactive-resume/utils/string"; -import { formatDate } from "./date"; +import { formatDate, toRangeDates, toSingleDates } from "./date"; import { rethrowAsImportError } from "./error"; import { toHtml } from "./html"; import { parseLevel } from "./level"; @@ -46,11 +47,18 @@ const MONTHS: Record = { // LinkedIn's "Started On" / "Finished On" cells are "Mon YYYY" (e.g. "Jan 2020") or a bare year. // Anything else is kept verbatim rather than dropped. -function formatLinkedInDate(value = ""): string { +// LinkedIn writes "Jan 2020" (or a bare year); anything else stays as written and asks for a review. +function toLinkedInIsoDate(value = ""): string | null { const trimmed = value.trim(); + if (/^\d{4}$/.test(trimmed)) return trimmed; const monthYear = /^([A-Za-z]{3})[a-z]*\s+(\d{4})$/.exec(trimmed); const month = MONTHS[monthYear?.[1]?.toLowerCase() ?? ""]; - return monthYear && month ? formatDate(`${monthYear[2]}-${month}`) : trimmed; + return monthYear && month ? `${monthYear[2]}-${month}` : null; +} + +function formatLinkedInDate(value = ""): string { + const iso = toLinkedInIsoDate(value); + return iso ? formatDate(iso) : value.trim(); } // Only an empty end cell means the entry is ongoing; an unrecognised one must not read as "Present". @@ -61,6 +69,19 @@ function formatLinkedInPeriod(start?: string, end?: string): string { return `${from} - ${to || "Present"}`; } +// Dates only when every cell was read; otherwise the text is read (and flagged if need be) when saved. +function linkedInPeriodDates(start?: string, end?: string): ResumeDates | undefined { + const from = toLinkedInIsoDate(start); + const to = toLinkedInIsoDate(end); + if (!from || (end?.trim() && !to)) return undefined; + return toRangeDates(from, to ?? undefined); +} + +function linkedInSingleDates(value?: string): ResumeDates | undefined { + const iso = toLinkedInIsoDate(value); + return iso ? toSingleDates(iso) : undefined; +} + const textToHtml = (text = "") => toHtml(text.split(/\r\n?|\n/)); const languageLevel = (proficiency = "") => @@ -203,6 +224,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData { position: position.Title || "", location: position.Location || "", period: formatLinkedInPeriod(position["Started On"], position["Finished On"]), + dates: linkedInPeriodDates(position["Started On"], position["Finished On"]), website: emptyWebsite, roles: [], description: textToHtml(position.Description), @@ -223,6 +245,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData { grade: "", location: "", period: formatLinkedInPeriod(edu["Start Date"], edu["End Date"]), + dates: linkedInPeriodDates(edu["Start Date"], edu["End Date"]), website: emptyWebsite, description: textToHtml(edu.Notes), })), @@ -270,6 +293,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData { title: cert.Name ?? "", issuer: cert.Authority || "", date: formatLinkedInDate(cert["Started On"]), + dates: linkedInSingleDates(cert["Started On"]), website: linkWebsite(cert.Url), description: "", })), diff --git a/packages/import/src/plain-text.test.ts b/packages/import/src/plain-text.test.ts index bd081b845..84e9c7377 100644 --- a/packages/import/src/plain-text.test.ts +++ b/packages/import/src/plain-text.test.ts @@ -61,12 +61,12 @@ describe("parseResumeText", () => { company: "Analytical Engines", position: "Senior Engineer", location: "Berlin", - period: "Jan 2020 - Present", + period: "Jan 2020 – Present", }); expect(data.sections.experience.items[1]).toMatchObject({ company: "Babbage Ltd", position: "Engineer", - period: "Mar 2016 - Dec 2019", + period: "Mar 2016 – Dec 2019", }); }); @@ -80,7 +80,7 @@ describe("parseResumeText", () => { expect(data.sections.education.items[0]).toMatchObject({ school: "University of London", degree: "BSc Mathematics", - period: "2012 - 2016", + period: "2012 – 2016", }); }); @@ -173,7 +173,7 @@ describe("parseResumeText review findings", () => { expect(data.sections.experience.items[0]).toMatchObject({ company: "Analytical Engines", position: "Senior Engineer", - period: "Jan 2020 - Present", + period: "Jan 2020 – Present", }); }); @@ -201,7 +201,7 @@ describe("parseResumeText multi-line entry preambles", () => { expect(data.sections.experience.items[0]).toMatchObject({ company: "ACME CORPORATION", position: "Senior Engineer", - period: "Jan 2020 - Present", + period: "Jan 2020 – Present", }); }); @@ -213,7 +213,7 @@ describe("parseResumeText multi-line entry preambles", () => { expect(data.sections.education.items[0]).toMatchObject({ school: "UNIVERSITY OF LONDON", degree: "BSc Mathematics", - period: "2012 - 2016", + period: "2012 – 2016", }); }); @@ -237,7 +237,7 @@ describe("parseResumeText four-line entry preambles", () => { company: "ACME CORPORATION", position: "Senior Engineer", location: "Berlin, Germany", - period: "Jan 2020 - Present", + period: "Jan 2020 – Present", }); }); @@ -250,7 +250,7 @@ describe("parseResumeText four-line entry preambles", () => { school: "UNIVERSITY OF LONDON", degree: "BSc Mathematics", location: "London, UK", - period: "2012 - 2016", + period: "2012 – 2016", }); }); }); @@ -314,7 +314,7 @@ describe("parseResumeText keeps content the layout hides", () => { ); expect(() => resumeDataSchema.parse(data)).not.toThrow(); - expect(data.sections.experience.items[0]).toMatchObject({ company: "Acme Corp", period: "Jan 2020 - Present" }); + expect(data.sections.experience.items[0]).toMatchObject({ company: "Acme Corp", period: "Jan 2020 – Present" }); expect(data.sections.education.items[0]).toMatchObject({ school: "University of London" }); expect(data.sections.certifications.items).toHaveLength(0); }); diff --git a/packages/import/src/plain-text.ts b/packages/import/src/plain-text.ts index bfcf76424..a75ef9460 100644 --- a/packages/import/src/plain-text.ts +++ b/packages/import/src/plain-text.ts @@ -1,7 +1,7 @@ import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; -import { parsePeriod, parseSingleDate } from "@reactive-resume/resume/ats"; import { parseResumeData } from "@reactive-resume/schema/resume/data"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { parsePeriod, parseSingleDate } from "@reactive-resume/schema/resume/period"; import { generateId } from "@reactive-resume/utils/string"; import { BULLET_PATTERN, toHtml } from "./html"; diff --git a/packages/mcp/src/resources.ts b/packages/mcp/src/resources.ts index ab350d256..be8ee1440 100644 --- a/packages/mcp/src/resources.ts +++ b/packages/mcp/src/resources.ts @@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { RouterClient } from "@orpc/server"; import type router from "@reactive-resume/api/routers"; import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import schemaJSON from "@reactive-resume/schema/schema.json"; +import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema"; import { MCP_TOOL_NAME as T } from "./mcp-tool-names"; export function registerResources(server: McpServer, client: RouterClient) { @@ -63,7 +63,8 @@ export function registerResources(server: McpServer, client: RouterClient