@jsonresume/utils 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +52 -0
- package/src/__tests__/calculations.test.js +56 -0
- package/src/__tests__/counts.test.js +118 -0
- package/src/__tests__/dates.test.js +167 -0
- package/src/__tests__/education.test.js +89 -0
- package/src/__tests__/experience.test.js +150 -0
- package/src/__tests__/fixtures.js +103 -0
- package/src/__tests__/keyMetrics.test.js +56 -0
- package/src/__tests__/resume.test.js +78 -0
- package/src/__tests__/url.test.js +113 -0
- package/src/__tests__/workHistory.test.js +107 -0
- package/src/dates.js +247 -0
- package/src/index.d.ts +76 -0
- package/src/index.js +54 -0
- package/src/metrics/counts.js +107 -0
- package/src/metrics/education.js +74 -0
- package/src/metrics/experience.js +111 -0
- package/src/metrics/index.js +45 -0
- package/src/metrics/keyMetrics.js +98 -0
- package/src/metrics/workHistory.js +80 -0
- package/src/resume.js +72 -0
- package/src/url.js +176 -0
package/src/dates.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure date utilities for JSON Resume.
|
|
3
|
+
*
|
|
4
|
+
* Framework-free: no React, no styled-components. The styled DateRange /
|
|
5
|
+
* RelativeDate components in @jsonresume/core import these functions back.
|
|
6
|
+
*
|
|
7
|
+
* @module @jsonresume/utils/dates
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Date formatting utility with Intl.DateTimeFormat
|
|
12
|
+
* Formats date ranges for work/education experience with full locale support
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} options - DateRange options
|
|
15
|
+
* @param {string|Date} options.startDate - Start date (ISO string or Date object)
|
|
16
|
+
* @param {string|Date} [options.endDate] - End date (ISO string, Date object, or null/undefined for "Present")
|
|
17
|
+
* @param {string} [options.format='short'] - Format style: 'short' (default), 'long', 'numeric'
|
|
18
|
+
* @param {string} [options.locale='en-US'] - BCP 47 locale (e.g., 'en-US', 'fr-FR', 'ar-SA')
|
|
19
|
+
* @param {string} [options.numberingSystem] - Numbering system (e.g., 'arab', 'latn', 'hanidec')
|
|
20
|
+
* @param {string} [options.presentLabel] - Custom label for present/ongoing (defaults to localized)
|
|
21
|
+
* @returns {string} Formatted date range
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* formatDateRange({ startDate: '2020-01-15', endDate: null })
|
|
25
|
+
* // 'Jan 2020 - Present'
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* formatDateRange({
|
|
29
|
+
* startDate: '2020-01-15',
|
|
30
|
+
* locale: 'fr-FR',
|
|
31
|
+
* format: 'long'
|
|
32
|
+
* })
|
|
33
|
+
* // 'janvier 2020 - Présent'
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* formatDateRange({
|
|
37
|
+
* startDate: '2020-01-15',
|
|
38
|
+
* locale: 'ar-SA',
|
|
39
|
+
* numberingSystem: 'arab'
|
|
40
|
+
* })
|
|
41
|
+
* // With Arabic numerals
|
|
42
|
+
*/
|
|
43
|
+
export function formatDateRange({
|
|
44
|
+
startDate,
|
|
45
|
+
endDate,
|
|
46
|
+
format = 'short',
|
|
47
|
+
locale = 'en-US',
|
|
48
|
+
numberingSystem,
|
|
49
|
+
presentLabel,
|
|
50
|
+
}) {
|
|
51
|
+
if (!startDate) return '';
|
|
52
|
+
|
|
53
|
+
// Determine "Present" label based on locale
|
|
54
|
+
const getPresentLabel = () => {
|
|
55
|
+
if (presentLabel) return presentLabel;
|
|
56
|
+
|
|
57
|
+
const labels = {
|
|
58
|
+
en: 'Present',
|
|
59
|
+
'en-US': 'Present',
|
|
60
|
+
'en-GB': 'Present',
|
|
61
|
+
fr: 'Présent',
|
|
62
|
+
'fr-FR': 'Présent',
|
|
63
|
+
es: 'Presente',
|
|
64
|
+
'es-ES': 'Presente',
|
|
65
|
+
de: 'Heute',
|
|
66
|
+
'de-DE': 'Heute',
|
|
67
|
+
it: 'Presente',
|
|
68
|
+
'it-IT': 'Presente',
|
|
69
|
+
pt: 'Presente',
|
|
70
|
+
'pt-BR': 'Presente',
|
|
71
|
+
ja: '現在',
|
|
72
|
+
'ja-JP': '現在',
|
|
73
|
+
zh: '至今',
|
|
74
|
+
'zh-CN': '至今',
|
|
75
|
+
'zh-TW': '至今',
|
|
76
|
+
ko: '현재',
|
|
77
|
+
'ko-KR': '현재',
|
|
78
|
+
ar: 'حاضر',
|
|
79
|
+
'ar-SA': 'حاضر',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return labels[locale] || labels[locale.split('-')[0]] || 'Present';
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const formatDate = (dateStr) => {
|
|
86
|
+
if (!dateStr) return getPresentLabel();
|
|
87
|
+
|
|
88
|
+
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
|
|
89
|
+
|
|
90
|
+
if (isNaN(date.getTime())) return dateStr; // Invalid date, return as-is
|
|
91
|
+
|
|
92
|
+
// Configure Intl.DateTimeFormat based on format style
|
|
93
|
+
const monthFormats = {
|
|
94
|
+
short: { month: 'short' },
|
|
95
|
+
long: { month: 'long' },
|
|
96
|
+
numeric: { month: '2-digit' },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const options = {
|
|
100
|
+
...monthFormats[format],
|
|
101
|
+
year: 'numeric',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Add numbering system if provided
|
|
105
|
+
if (numberingSystem) {
|
|
106
|
+
options.numberingSystem = numberingSystem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Use Intl.DateTimeFormat for proper locale support
|
|
110
|
+
const formatter = new Intl.DateTimeFormat(locale, options);
|
|
111
|
+
return formatter.format(date);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const start = formatDate(startDate);
|
|
115
|
+
|
|
116
|
+
// A genuinely absent endDate (key omitted -> undefined) is a single point in
|
|
117
|
+
// time (e.g. an award/certificate/publication date) and renders just the
|
|
118
|
+
// start. An explicit `null` endDate is the "ongoing" sentinel and renders
|
|
119
|
+
// start + separator + "Present" (formatDate returns the localized present
|
|
120
|
+
// label for a falsy-but-not-undefined value). This matches long-standing
|
|
121
|
+
// behavior; do not collapse the two — single-date sections depend on it.
|
|
122
|
+
if (endDate === undefined) {
|
|
123
|
+
return start;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const end = formatDate(endDate);
|
|
127
|
+
|
|
128
|
+
return `${start} - ${end}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Calculate relative time from a date
|
|
133
|
+
* @param {string|Date} date - Date to calculate from
|
|
134
|
+
* @param {boolean} [ago=true] - Include "ago" suffix
|
|
135
|
+
* @returns {string} Relative time string
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* getRelativeTime('2020-01-01') // '6 years ago'
|
|
139
|
+
*/
|
|
140
|
+
export function getRelativeTime(date, ago = true) {
|
|
141
|
+
const now = new Date();
|
|
142
|
+
const past = new Date(date);
|
|
143
|
+
const diffMs = now - past;
|
|
144
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
145
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
146
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
147
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
148
|
+
const diffWeek = Math.floor(diffDay / 7);
|
|
149
|
+
const diffMonth = Math.floor(diffDay / 30);
|
|
150
|
+
const diffYear = Math.floor(diffDay / 365);
|
|
151
|
+
|
|
152
|
+
const suffix = ago ? ' ago' : '';
|
|
153
|
+
|
|
154
|
+
if (diffYear > 0) {
|
|
155
|
+
return `${diffYear} year${diffYear !== 1 ? 's' : ''}${suffix}`;
|
|
156
|
+
}
|
|
157
|
+
if (diffMonth > 0) {
|
|
158
|
+
return `${diffMonth} month${diffMonth !== 1 ? 's' : ''}${suffix}`;
|
|
159
|
+
}
|
|
160
|
+
if (diffWeek > 0) {
|
|
161
|
+
return `${diffWeek} week${diffWeek !== 1 ? 's' : ''}${suffix}`;
|
|
162
|
+
}
|
|
163
|
+
if (diffDay > 0) {
|
|
164
|
+
return `${diffDay} day${diffDay !== 1 ? 's' : ''}${suffix}`;
|
|
165
|
+
}
|
|
166
|
+
if (diffHour > 0) {
|
|
167
|
+
return `${diffHour} hour${diffHour !== 1 ? 's' : ''}${suffix}`;
|
|
168
|
+
}
|
|
169
|
+
if (diffMin > 0) {
|
|
170
|
+
return `${diffMin} minute${diffMin !== 1 ? 's' : ''}${suffix}`;
|
|
171
|
+
}
|
|
172
|
+
return 'just now';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Calculate duration between two dates
|
|
177
|
+
* @param {string|Date} startDate - Start date
|
|
178
|
+
* @param {string|Date} [endDate=new Date()] - End date (defaults to now)
|
|
179
|
+
* @returns {string} Duration string
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* getDuration('2020-01-01', '2022-07-01') // '2 years, 6 months'
|
|
183
|
+
*/
|
|
184
|
+
export function getDuration(startDate, endDate = new Date()) {
|
|
185
|
+
const start = new Date(startDate);
|
|
186
|
+
const end = new Date(endDate);
|
|
187
|
+
const diffMs = end - start;
|
|
188
|
+
const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
189
|
+
const diffMonth = Math.floor(diffDay / 30);
|
|
190
|
+
const diffYear = Math.floor(diffDay / 365);
|
|
191
|
+
|
|
192
|
+
const years = diffYear;
|
|
193
|
+
const months = diffMonth % 12;
|
|
194
|
+
|
|
195
|
+
if (years > 0 && months > 0) {
|
|
196
|
+
return `${years} year${years !== 1 ? 's' : ''}, ${months} month${
|
|
197
|
+
months !== 1 ? 's' : ''
|
|
198
|
+
}`;
|
|
199
|
+
}
|
|
200
|
+
if (years > 0) {
|
|
201
|
+
return `${years} year${years !== 1 ? 's' : ''}`;
|
|
202
|
+
}
|
|
203
|
+
if (months > 0) {
|
|
204
|
+
return `${months} month${months !== 1 ? 's' : ''}`;
|
|
205
|
+
}
|
|
206
|
+
return `${diffDay} day${diffDay !== 1 ? 's' : ''}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Ensures all date fields in the resume are plain strings.
|
|
211
|
+
* Some themes (e.g. macchiato) use moment.js or Handlebars helpers that
|
|
212
|
+
* break when dates are Date objects instead of strings.
|
|
213
|
+
*
|
|
214
|
+
* @param {Object} resume - JSON Resume object
|
|
215
|
+
* @returns {Object} A shallow copy with Date-valued date fields stringified
|
|
216
|
+
*/
|
|
217
|
+
export function normalizeDates(resume) {
|
|
218
|
+
const dateFields = ['startDate', 'endDate', 'date', 'releaseDate'];
|
|
219
|
+
const sections = [
|
|
220
|
+
'work',
|
|
221
|
+
'education',
|
|
222
|
+
'volunteer',
|
|
223
|
+
'projects',
|
|
224
|
+
'awards',
|
|
225
|
+
'publications',
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const normalized = { ...resume };
|
|
229
|
+
for (const section of sections) {
|
|
230
|
+
if (Array.isArray(normalized[section])) {
|
|
231
|
+
normalized[section] = normalized[section].map((item) => {
|
|
232
|
+
const copy = { ...item };
|
|
233
|
+
for (const field of dateFields) {
|
|
234
|
+
if (copy[field] instanceof Date) {
|
|
235
|
+
copy[field] = copy[field].toISOString().split('T')[0];
|
|
236
|
+
}
|
|
237
|
+
// Non-Date, non-string values (plain objects, arrays, etc.) are left
|
|
238
|
+
// untouched. Previously these were String()-coerced, which turned a
|
|
239
|
+
// malformed object date into the literal "[object Object]" and joined
|
|
240
|
+
// arrays into comma strings — mangling the value handed to the theme.
|
|
241
|
+
}
|
|
242
|
+
return copy;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return normalized;
|
|
247
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public type surface for @jsonresume/utils.
|
|
3
|
+
*
|
|
4
|
+
* The runtime is plain JS (src/*.js); these declarations type the public API
|
|
5
|
+
* against @jsonresume/types so consumers get full inference.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
Resume,
|
|
9
|
+
ResumeLocation,
|
|
10
|
+
WorkItem,
|
|
11
|
+
VolunteerItem,
|
|
12
|
+
EducationItem,
|
|
13
|
+
SkillItem,
|
|
14
|
+
} from '@jsonresume/types';
|
|
15
|
+
|
|
16
|
+
// --- dates ---
|
|
17
|
+
|
|
18
|
+
export interface DateRangeOptions {
|
|
19
|
+
startDate?: string | Date;
|
|
20
|
+
endDate?: string | Date | null;
|
|
21
|
+
format?: 'short' | 'long' | 'numeric';
|
|
22
|
+
locale?: string;
|
|
23
|
+
numberingSystem?: string;
|
|
24
|
+
presentLabel?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatDateRange(options: DateRangeOptions): string;
|
|
28
|
+
export function getRelativeTime(date: string | Date, ago?: boolean): string;
|
|
29
|
+
export function getDuration(
|
|
30
|
+
startDate: string | Date,
|
|
31
|
+
endDate?: string | Date
|
|
32
|
+
): string;
|
|
33
|
+
export function normalizeDates(resume: Resume): Resume;
|
|
34
|
+
|
|
35
|
+
// --- metrics ---
|
|
36
|
+
|
|
37
|
+
export interface KeyMetric {
|
|
38
|
+
label: string;
|
|
39
|
+
value: string | number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function calculateTotalExperience(work?: WorkItem[]): number;
|
|
43
|
+
export function calculateCurrentRoleExperience(work?: WorkItem[]): number;
|
|
44
|
+
export function countCareerPositions(work?: WorkItem[]): number;
|
|
45
|
+
export function getCareerProgressionRate(work?: WorkItem[]): number;
|
|
46
|
+
export function countTotalHighlights(work?: WorkItem[]): number;
|
|
47
|
+
export function countCompanies(work?: WorkItem[]): number;
|
|
48
|
+
export function countProjects(projects?: unknown[]): number;
|
|
49
|
+
export function countPublications(publications?: unknown[]): number;
|
|
50
|
+
export function countAwards(awards?: unknown[]): number;
|
|
51
|
+
export function countTotalSkills(skills?: SkillItem[]): number;
|
|
52
|
+
export function countSkillCategories(skills?: SkillItem[]): number;
|
|
53
|
+
export function countLanguages(languages?: unknown[]): number;
|
|
54
|
+
export function calculateEducationYears(education?: EducationItem[]): number;
|
|
55
|
+
export function getHighestDegree(education?: EducationItem[]): string;
|
|
56
|
+
export function calculateVolunteerYears(volunteer?: VolunteerItem[]): number;
|
|
57
|
+
export function getUniqueIndustries(work?: WorkItem[]): string[];
|
|
58
|
+
export function getCurrentEmployer(work?: WorkItem[]): WorkItem | null;
|
|
59
|
+
export function isCurrentlyEmployed(work?: WorkItem[]): boolean;
|
|
60
|
+
export function calculateKeyMetrics(resume: Resume): KeyMetric[];
|
|
61
|
+
|
|
62
|
+
// --- url ---
|
|
63
|
+
|
|
64
|
+
export function safeUrl(url: string): string | null;
|
|
65
|
+
export function getLinkRel(url: string, openInNewTab?: boolean): string;
|
|
66
|
+
export function sanitizeHtml(html: string): string;
|
|
67
|
+
export function isExternalUrl(
|
|
68
|
+
url: string,
|
|
69
|
+
currentOrigin?: string | null
|
|
70
|
+
): boolean;
|
|
71
|
+
export function formatUrlForDisplay(url: string): string;
|
|
72
|
+
|
|
73
|
+
// --- resume shape ---
|
|
74
|
+
|
|
75
|
+
export function formatLocation(location?: ResumeLocation): string;
|
|
76
|
+
export function normalizeResume(resume?: Partial<Resume>): Resume;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jsonresume/utils
|
|
3
|
+
*
|
|
4
|
+
* Framework-free pure utilities for JSON Resume. No React, no
|
|
5
|
+
* styled-components — safe to use anywhere (server, CLI, browser, themes).
|
|
6
|
+
*
|
|
7
|
+
* Subpath exports: './dates', './metrics', './url'. The root barrel below
|
|
8
|
+
* re-exports everything for convenience.
|
|
9
|
+
*
|
|
10
|
+
* @module @jsonresume/utils
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Date utilities
|
|
14
|
+
export {
|
|
15
|
+
formatDateRange,
|
|
16
|
+
getRelativeTime,
|
|
17
|
+
getDuration,
|
|
18
|
+
normalizeDates,
|
|
19
|
+
} from './dates.js';
|
|
20
|
+
|
|
21
|
+
// Metrics / calculation helpers
|
|
22
|
+
export {
|
|
23
|
+
calculateTotalExperience,
|
|
24
|
+
calculateCurrentRoleExperience,
|
|
25
|
+
countCareerPositions,
|
|
26
|
+
getCareerProgressionRate,
|
|
27
|
+
countTotalHighlights,
|
|
28
|
+
countCompanies,
|
|
29
|
+
countProjects,
|
|
30
|
+
countPublications,
|
|
31
|
+
countAwards,
|
|
32
|
+
countTotalSkills,
|
|
33
|
+
countSkillCategories,
|
|
34
|
+
countLanguages,
|
|
35
|
+
calculateEducationYears,
|
|
36
|
+
getHighestDegree,
|
|
37
|
+
calculateVolunteerYears,
|
|
38
|
+
getUniqueIndustries,
|
|
39
|
+
getCurrentEmployer,
|
|
40
|
+
isCurrentlyEmployed,
|
|
41
|
+
calculateKeyMetrics,
|
|
42
|
+
} from './metrics/index.js';
|
|
43
|
+
|
|
44
|
+
// URL / security utilities
|
|
45
|
+
export {
|
|
46
|
+
safeUrl,
|
|
47
|
+
getLinkRel,
|
|
48
|
+
sanitizeHtml,
|
|
49
|
+
isExternalUrl,
|
|
50
|
+
formatUrlForDisplay,
|
|
51
|
+
} from './url.js';
|
|
52
|
+
|
|
53
|
+
// Resume-shape utilities
|
|
54
|
+
export { formatLocation, normalizeResume } from './resume.js';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume Count Calculation Helpers
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for tallying simple counts across JSON Resume sections.
|
|
5
|
+
*
|
|
6
|
+
* @module @jsonresume/utils/metrics/counts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Count total number of companies worked at
|
|
11
|
+
* @param {Array} work - Array of work experience objects
|
|
12
|
+
* @returns {number} Number of unique companies
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const companies = countCompanies(resume.work);
|
|
16
|
+
* // => 5
|
|
17
|
+
*/
|
|
18
|
+
export function countCompanies(work = []) {
|
|
19
|
+
if (!Array.isArray(work) || work.length === 0) return 0;
|
|
20
|
+
|
|
21
|
+
const uniqueCompanies = new Set(work.map((job) => job.name).filter(Boolean));
|
|
22
|
+
|
|
23
|
+
return uniqueCompanies.size;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Count total number of projects
|
|
28
|
+
* @param {Array} projects - Array of project objects
|
|
29
|
+
* @returns {number} Number of projects
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const projectCount = countProjects(resume.projects);
|
|
33
|
+
* // => 12
|
|
34
|
+
*/
|
|
35
|
+
export function countProjects(projects = []) {
|
|
36
|
+
return Array.isArray(projects) ? projects.length : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Count total number of publications
|
|
41
|
+
* @param {Array} publications - Array of publication objects
|
|
42
|
+
* @returns {number} Number of publications
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const pubCount = countPublications(resume.publications);
|
|
46
|
+
* // => 8
|
|
47
|
+
*/
|
|
48
|
+
export function countPublications(publications = []) {
|
|
49
|
+
return Array.isArray(publications) ? publications.length : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Count total number of awards received
|
|
54
|
+
* @param {Array} awards - Array of award objects
|
|
55
|
+
* @returns {number} Number of awards
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const awardCount = countAwards(resume.awards);
|
|
59
|
+
* // => 3
|
|
60
|
+
*/
|
|
61
|
+
export function countAwards(awards = []) {
|
|
62
|
+
return Array.isArray(awards) ? awards.length : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Count total skills across all skill categories
|
|
67
|
+
* @param {Array} skills - Array of skill objects with keywords
|
|
68
|
+
* @returns {number} Total number of skill keywords
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* const skillCount = countTotalSkills(resume.skills);
|
|
72
|
+
* // => 42
|
|
73
|
+
*/
|
|
74
|
+
export function countTotalSkills(skills = []) {
|
|
75
|
+
if (!Array.isArray(skills) || skills.length === 0) return 0;
|
|
76
|
+
|
|
77
|
+
return skills.reduce((total, skill) => {
|
|
78
|
+
const keywords = skill.keywords || [];
|
|
79
|
+
return total + keywords.length;
|
|
80
|
+
}, 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Count skill categories
|
|
85
|
+
* @param {Array} skills - Array of skill objects
|
|
86
|
+
* @returns {number} Number of skill categories
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const categories = countSkillCategories(resume.skills);
|
|
90
|
+
* // => 6
|
|
91
|
+
*/
|
|
92
|
+
export function countSkillCategories(skills = []) {
|
|
93
|
+
return Array.isArray(skills) ? skills.length : 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Count languages spoken
|
|
98
|
+
* @param {Array} languages - Array of language objects
|
|
99
|
+
* @returns {number} Number of languages
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const langCount = countLanguages(resume.languages);
|
|
103
|
+
* // => 3
|
|
104
|
+
*/
|
|
105
|
+
export function countLanguages(languages = []) {
|
|
106
|
+
return Array.isArray(languages) ? languages.length : 0;
|
|
107
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume Education Calculation Helpers
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for computing education metrics from JSON Resume data.
|
|
5
|
+
*
|
|
6
|
+
* @module @jsonresume/utils/metrics/education
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calculate total education years
|
|
11
|
+
* @param {Array} education - Array of education objects with startDate and endDate
|
|
12
|
+
* @returns {number} Total years of education
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const eduYears = calculateEducationYears(resume.education);
|
|
16
|
+
* // => 6
|
|
17
|
+
*/
|
|
18
|
+
export function calculateEducationYears(education = []) {
|
|
19
|
+
if (!Array.isArray(education) || education.length === 0) return 0;
|
|
20
|
+
|
|
21
|
+
const totalYears = education.reduce((acc, edu) => {
|
|
22
|
+
if (!edu.startDate) return acc;
|
|
23
|
+
|
|
24
|
+
const start = new Date(edu.startDate);
|
|
25
|
+
const end = edu.endDate ? new Date(edu.endDate) : new Date();
|
|
26
|
+
// Use 365.25 for leap years (matches calculateTotalExperience). Previously
|
|
27
|
+
// this divided by (ms-per-day / 365.25), inflating the result ~133M-fold.
|
|
28
|
+
const years = (end - start) / (1000 * 60 * 60 * 24 * 365.25);
|
|
29
|
+
|
|
30
|
+
return acc + years;
|
|
31
|
+
}, 0);
|
|
32
|
+
|
|
33
|
+
return Math.round(totalYears);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get highest education level
|
|
38
|
+
* @param {Array} education - Array of education objects with studyType
|
|
39
|
+
* @returns {string} Highest degree (PhD, Master's, Bachelor's, etc.)
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const degree = getHighestDegree(resume.education);
|
|
43
|
+
* // => "PhD"
|
|
44
|
+
*/
|
|
45
|
+
export function getHighestDegree(education = []) {
|
|
46
|
+
if (!Array.isArray(education) || education.length === 0) return '';
|
|
47
|
+
|
|
48
|
+
const degreeRanking = {
|
|
49
|
+
phd: 5,
|
|
50
|
+
doctorate: 5,
|
|
51
|
+
doctoral: 5,
|
|
52
|
+
master: 4,
|
|
53
|
+
mba: 4,
|
|
54
|
+
bachelor: 3,
|
|
55
|
+
associate: 2,
|
|
56
|
+
diploma: 1,
|
|
57
|
+
certificate: 1,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let highest = { level: 0, studyType: '' };
|
|
61
|
+
|
|
62
|
+
education.forEach((edu) => {
|
|
63
|
+
if (!edu.studyType) return;
|
|
64
|
+
|
|
65
|
+
const studyTypeLower = edu.studyType.toLowerCase();
|
|
66
|
+
for (const [key, level] of Object.entries(degreeRanking)) {
|
|
67
|
+
if (studyTypeLower.includes(key) && level > highest.level) {
|
|
68
|
+
highest = { level, studyType: edu.studyType };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return highest.studyType;
|
|
74
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume Experience Calculation Helpers
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for computing work tenure and career progression metrics
|
|
5
|
+
* from JSON Resume work history.
|
|
6
|
+
*
|
|
7
|
+
* @module @jsonresume/utils/metrics/experience
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calculate total years of professional experience from work history
|
|
12
|
+
* @param {Array} work - Array of work experience objects with startDate and endDate
|
|
13
|
+
* @returns {number} Total years of experience (rounded to nearest year)
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const years = calculateTotalExperience(resume.work);
|
|
17
|
+
* // => 8 (years)
|
|
18
|
+
*/
|
|
19
|
+
export function calculateTotalExperience(work = []) {
|
|
20
|
+
if (!Array.isArray(work) || work.length === 0) return 0;
|
|
21
|
+
|
|
22
|
+
const totalYears = work.reduce((acc, job) => {
|
|
23
|
+
if (!job.startDate) return acc;
|
|
24
|
+
|
|
25
|
+
const start = new Date(job.startDate);
|
|
26
|
+
const end = job.endDate ? new Date(job.endDate) : new Date();
|
|
27
|
+
const years = (end - start) / (1000 * 60 * 60 * 24 * 365.25); // Use 365.25 for leap years
|
|
28
|
+
|
|
29
|
+
return acc + years;
|
|
30
|
+
}, 0);
|
|
31
|
+
|
|
32
|
+
return Math.round(totalYears);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Calculate years of experience for the current/most recent role
|
|
37
|
+
* @param {Array} work - Array of work experience objects
|
|
38
|
+
* @returns {number} Years in current role (rounded to 1 decimal)
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* const currentYears = calculateCurrentRoleExperience(resume.work);
|
|
42
|
+
* // => 2.5 (years)
|
|
43
|
+
*/
|
|
44
|
+
export function calculateCurrentRoleExperience(work = []) {
|
|
45
|
+
if (!Array.isArray(work) || work.length === 0) return 0;
|
|
46
|
+
|
|
47
|
+
// Assume first item is most recent (no endDate or latest endDate)
|
|
48
|
+
const currentRole = work.find((job) => !job.endDate) || work[0];
|
|
49
|
+
if (!currentRole || !currentRole.startDate) return 0;
|
|
50
|
+
|
|
51
|
+
const start = new Date(currentRole.startDate);
|
|
52
|
+
const end = currentRole.endDate ? new Date(currentRole.endDate) : new Date();
|
|
53
|
+
const years = (end - start) / (1000 * 60 * 60 * 24 * 365.25);
|
|
54
|
+
|
|
55
|
+
return Math.round(years * 10) / 10; // Round to 1 decimal
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Calculate career trajectory (promotions/role changes)
|
|
60
|
+
* @param {Array} work - Array of work experience objects with position
|
|
61
|
+
* @returns {number} Number of distinct positions held
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* const positions = countCareerPositions(resume.work);
|
|
65
|
+
* // => 7
|
|
66
|
+
*/
|
|
67
|
+
export function countCareerPositions(work = []) {
|
|
68
|
+
if (!Array.isArray(work) || work.length === 0) return 0;
|
|
69
|
+
|
|
70
|
+
const uniquePositions = new Set(
|
|
71
|
+
work.map((job) => job.position).filter(Boolean)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return uniquePositions.size;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get career progression rate (positions per year)
|
|
79
|
+
* @param {Array} work - Array of work experience objects
|
|
80
|
+
* @returns {number} Average years per position
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* const rate = getCareerProgressionRate(resume.work);
|
|
84
|
+
* // => 2.3 (years per position)
|
|
85
|
+
*/
|
|
86
|
+
export function getCareerProgressionRate(work = []) {
|
|
87
|
+
const totalYears = calculateTotalExperience(work);
|
|
88
|
+
const positions = countCareerPositions(work);
|
|
89
|
+
|
|
90
|
+
if (totalYears === 0 || positions === 0) return 0;
|
|
91
|
+
|
|
92
|
+
return Math.round((totalYears / positions) * 10) / 10;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Calculate total highlights/achievements across work experience
|
|
97
|
+
* @param {Array} work - Array of work experience objects with highlights
|
|
98
|
+
* @returns {number} Total number of highlights
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const achievements = countTotalHighlights(resume.work);
|
|
102
|
+
* // => 28
|
|
103
|
+
*/
|
|
104
|
+
export function countTotalHighlights(work = []) {
|
|
105
|
+
if (!Array.isArray(work) || work.length === 0) return 0;
|
|
106
|
+
|
|
107
|
+
return work.reduce((total, job) => {
|
|
108
|
+
const highlights = job.highlights || [];
|
|
109
|
+
return total + highlights.length;
|
|
110
|
+
}, 0);
|
|
111
|
+
}
|