mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 09:24:54 +10:00
refactor(utils→import): move single-consumer date/html/level helpers, inline field into fonts
Finding 4 — consumer-count verification: @reactive-resume/utils/date → 1 consumer (packages/import/src/json-resume.tsx) @reactive-resume/utils/html → 1 consumer (packages/import/src/json-resume.tsx) @reactive-resume/utils/level → 1 consumer (packages/import/src/json-resume.tsx) @reactive-resume/utils/url → 4 consumers (auth, api/ai, import, server) — SKIPPED, stays in utils @reactive-resume/utils/field → 1 consumer (packages/fonts/src/index.ts) Move date/html/level source + test files into packages/import and update json-resume.tsx imports. Inline the two-line unique() helper into fonts/src/index.ts and drop the ./field subpath. Drop the three moved subpaths from packages/utils exports. Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDate, formatPeriod, formatSingleDate } from "./date";
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats YYYY-MM as 'Month Year'", () => {
|
||||
expect(formatDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD as 'Month Year' by default (day excluded)", () => {
|
||||
expect(formatDate("2024-03-15")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD with includeDay=true as 'Month DD, Year'", () => {
|
||||
expect(formatDate("2024-03-15", true)).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("returns YYYY unchanged when only year provided", () => {
|
||||
expect(formatDate("2024")).toBe("2024");
|
||||
});
|
||||
|
||||
it("handles January (month 1)", () => {
|
||||
expect(formatDate("2024-01")).toBe("January 2024");
|
||||
});
|
||||
|
||||
it("handles December (month 12)", () => {
|
||||
expect(formatDate("2024-12")).toBe("December 2024");
|
||||
});
|
||||
|
||||
it("includes day even with single-digit days", () => {
|
||||
expect(formatDate("2024-03-05", true)).toBe("March 05, 2024");
|
||||
});
|
||||
|
||||
it("returns 'undefined' month name when out-of-range month is supplied", () => {
|
||||
// Defensive: behavior documents what happens with bad input
|
||||
expect(formatDate("2024-13")).toBe("undefined 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriod", () => {
|
||||
it("returns empty string when both dates missing", () => {
|
||||
expect(formatPeriod()).toBe("");
|
||||
});
|
||||
|
||||
it("returns end date alone when start is missing", () => {
|
||||
expect(formatPeriod(undefined, "2024-05")).toBe("2024-05");
|
||||
});
|
||||
|
||||
it("returns start - Present when end is missing", () => {
|
||||
expect(formatPeriod("2024-01")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("formats both ends when both provided", () => {
|
||||
expect(formatPeriod("2020-06", "2024-03")).toBe("June 2020 - March 2024");
|
||||
});
|
||||
|
||||
it("treats empty string start same as undefined", () => {
|
||||
expect(formatPeriod("", "2024-05")).toBe("2024-05");
|
||||
});
|
||||
|
||||
it("treats empty string end same as undefined", () => {
|
||||
expect(formatPeriod("2024-01", "")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("returns empty when start empty and end empty", () => {
|
||||
expect(formatPeriod("", "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSingleDate", () => {
|
||||
it("returns empty string when date is undefined", () => {
|
||||
expect(formatSingleDate()).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when date is empty string", () => {
|
||||
expect(formatSingleDate("")).toBe("");
|
||||
});
|
||||
|
||||
it("formats full date with day included", () => {
|
||||
expect(formatSingleDate("2024-03-15")).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("formats year-month even with includeDay flag implied", () => {
|
||||
expect(formatSingleDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("returns plain year when only year present", () => {
|
||||
expect(formatSingleDate("2024")).toBe("2024");
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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" });
|
||||
|
||||
function getMonthName(month: string | undefined): string {
|
||||
const index = Number.parseInt(month ?? "1", 10) - 1;
|
||||
if (index < 0 || index > 11) return "undefined";
|
||||
return fmt.format(new Date(2000, index, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a partial ISO 8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||
* into a human-readable format like "January 2024" or "January 15, 2024".
|
||||
*/
|
||||
export function formatDate(date: string, includeDay = false): string {
|
||||
const parts = date.split("-");
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const [year, month] = parts;
|
||||
const monthName = getMonthName(month);
|
||||
|
||||
if (parts.length === 3 && includeDay) {
|
||||
return `${monthName} ${parts[2]}, ${year}`;
|
||||
}
|
||||
|
||||
return `${monthName} ${year}`;
|
||||
}
|
||||
|
||||
// YYYY only
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date range from start and end dates.
|
||||
* Returns "Start - End", "Start - Present" if no end, or just the end date if no start.
|
||||
*/
|
||||
export function formatPeriod(startDate?: string, endDate?: string): string {
|
||||
if (!startDate && !endDate) return "";
|
||||
if (!startDate) return endDate || "";
|
||||
if (!endDate) return `${formatDate(startDate)} - Present`;
|
||||
|
||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a single date with day included (e.g., "January 15, 2024").
|
||||
* Falls back to month-year or year-only for partial dates.
|
||||
*/
|
||||
export function formatSingleDate(date?: string): string {
|
||||
if (!date) return "";
|
||||
return formatDate(date, true);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function unique<T>(items: T[]) {
|
||||
return [...new Set(items)];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { arrayToHtmlList, toHtmlDescription } from "./html";
|
||||
|
||||
describe("toHtmlDescription", () => {
|
||||
it("returns empty string when no inputs provided", () => {
|
||||
expect(toHtmlDescription()).toBe("");
|
||||
});
|
||||
|
||||
it("returns just <p> with summary when no highlights", () => {
|
||||
expect(toHtmlDescription("Summary here")).toBe("<p>Summary here</p>");
|
||||
});
|
||||
|
||||
it("returns just <ul> when only highlights", () => {
|
||||
expect(toHtmlDescription(undefined, ["a", "b"])).toBe("<ul><li>a</li><li>b</li></ul>");
|
||||
});
|
||||
|
||||
it("returns combined output with both summary and highlights", () => {
|
||||
expect(toHtmlDescription("Sum", ["a"])).toBe("<p>Sum</p><ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
it("omits ul when highlights is empty array", () => {
|
||||
expect(toHtmlDescription("Sum", [])).toBe("<p>Sum</p>");
|
||||
});
|
||||
|
||||
it("preserves order: summary first, list second", () => {
|
||||
const result = toHtmlDescription("S", ["x", "y", "z"]);
|
||||
expect(result.indexOf("<p>")).toBeLessThan(result.indexOf("<ul>"));
|
||||
});
|
||||
|
||||
it("treats empty string summary as falsy", () => {
|
||||
expect(toHtmlDescription("", ["a"])).toBe("<ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
it("does not escape HTML in inputs (caller's responsibility)", () => {
|
||||
// Document existing behavior: caller must sanitize before passing
|
||||
expect(toHtmlDescription("<script>")).toBe("<p><script></p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrayToHtmlList", () => {
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(arrayToHtmlList([])).toBe("");
|
||||
});
|
||||
|
||||
it("renders single item list", () => {
|
||||
expect(arrayToHtmlList(["one"])).toBe("<ul><li>one</li></ul>");
|
||||
});
|
||||
|
||||
it("renders multi-item list", () => {
|
||||
expect(arrayToHtmlList(["one", "two", "three"])).toBe("<ul><li>one</li><li>two</li><li>three</li></ul>");
|
||||
});
|
||||
|
||||
it("preserves item order", () => {
|
||||
const result = arrayToHtmlList(["c", "a", "b"]);
|
||||
expect(result.indexOf("c")).toBeLessThan(result.indexOf("a"));
|
||||
expect(result.indexOf("a")).toBeLessThan(result.indexOf("b"));
|
||||
});
|
||||
|
||||
it("does not escape HTML in items", () => {
|
||||
expect(arrayToHtmlList(["<b>bold</b>"])).toBe("<ul><li><b>bold</b></li></ul>");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Converts a summary string and optional highlights array into an HTML description.
|
||||
* Summary becomes a <p> tag, highlights become a <ul> list.
|
||||
*/
|
||||
export function toHtmlDescription(summary?: string, highlights?: string[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (summary) {
|
||||
parts.push(`<p>${summary}</p>`);
|
||||
}
|
||||
|
||||
if (highlights && highlights.length > 0) {
|
||||
parts.push("<ul>");
|
||||
|
||||
for (const highlight of highlights) {
|
||||
parts.push(`<li>${highlight}</li>`);
|
||||
}
|
||||
|
||||
parts.push("</ul>");
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of strings into an HTML unordered list.
|
||||
*/
|
||||
export function arrayToHtmlList(items: string[]): string {
|
||||
if (items.length === 0) return "";
|
||||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLevel } from "./level";
|
||||
|
||||
describe("parseLevel", () => {
|
||||
describe("invalid input", () => {
|
||||
it("returns 0 for undefined", () => {
|
||||
expect(parseLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for empty string", () => {
|
||||
expect(parseLevel("")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for unrecognized text", () => {
|
||||
expect(parseLevel("hello world")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("numeric values", () => {
|
||||
it("returns 0 for '0'", () => {
|
||||
expect(parseLevel("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 5 for '5'", () => {
|
||||
expect(parseLevel("5")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 3 for '3'", () => {
|
||||
expect(parseLevel("3")).toBe(3);
|
||||
});
|
||||
|
||||
it("falls through to text matching for out-of-range numerics", () => {
|
||||
// 6 is out of range so the numeric branch fails, no text matches → 0
|
||||
expect(parseLevel("6")).toBe(0);
|
||||
});
|
||||
|
||||
it("falls through for negative numerics", () => {
|
||||
expect(parseLevel("-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("parses leading-numeric strings via parseInt", () => {
|
||||
// "3 stars" → parseInt yields 3, in [0,5] → returns 3
|
||||
expect(parseLevel("3 stars")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("text levels", () => {
|
||||
it("returns 5 for 'native'", () => {
|
||||
expect(parseLevel("native")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 5 for 'expert'", () => {
|
||||
expect(parseLevel("Expert")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 5 for 'master'", () => {
|
||||
expect(parseLevel("master")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 4 for 'fluent'", () => {
|
||||
expect(parseLevel("fluent")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 4 for 'advanced'", () => {
|
||||
expect(parseLevel("Advanced")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 4 for 'proficient'", () => {
|
||||
expect(parseLevel("proficient")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 3 for 'intermediate'", () => {
|
||||
expect(parseLevel("intermediate")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 3 for 'conversational'", () => {
|
||||
expect(parseLevel("conversational")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 2 for 'beginner'", () => {
|
||||
expect(parseLevel("beginner")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 2 for 'basic'", () => {
|
||||
expect(parseLevel("basic")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 2 for 'elementary'", () => {
|
||||
expect(parseLevel("Elementary")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 1 for 'novice'", () => {
|
||||
expect(parseLevel("novice")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CEFR levels", () => {
|
||||
it("returns 5 for C2", () => {
|
||||
expect(parseLevel("C2")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 4 for C1", () => {
|
||||
expect(parseLevel("c1")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 3 for B2", () => {
|
||||
expect(parseLevel("B2")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 2 for B1", () => {
|
||||
expect(parseLevel("b1")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 1 for A2", () => {
|
||||
expect(parseLevel("A2")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 for A1", () => {
|
||||
expect(parseLevel("a1")).toBe(1);
|
||||
});
|
||||
|
||||
it("matches CEFR levels embedded in surrounding text", () => {
|
||||
expect(parseLevel("Level: B2")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority of matchers", () => {
|
||||
it("expert beats CEFR when both substrings appear", () => {
|
||||
// "expert" matches first and short-circuits before CEFR check
|
||||
expect(parseLevel("expert C2")).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Parses a skill/language level string to a numeric value (0-5).
|
||||
* Supports numeric values, text levels (beginner/intermediate/advanced/expert),
|
||||
* and CEFR levels (A1-C2).
|
||||
*/
|
||||
export function parseLevel(level?: string): number {
|
||||
if (!level) return 0;
|
||||
|
||||
const levelLower = level.toLowerCase();
|
||||
|
||||
// Try to parse numeric values
|
||||
const numeric = Number.parseInt(levelLower, 10);
|
||||
if (!Number.isNaN(numeric) && numeric >= 0 && numeric <= 5) return numeric;
|
||||
|
||||
// Map text levels to numbers
|
||||
if (levelLower.includes("native") || levelLower.includes("expert") || levelLower.includes("master")) return 5;
|
||||
if (levelLower.includes("fluent") || levelLower.includes("advanced") || levelLower.includes("proficient")) return 4;
|
||||
if (levelLower.includes("intermediate") || levelLower.includes("conversational")) return 3;
|
||||
if (levelLower.includes("beginner") || levelLower.includes("basic") || levelLower.includes("elementary")) return 2;
|
||||
if (levelLower.includes("novice")) return 1;
|
||||
|
||||
// CEFR levels
|
||||
if (levelLower.includes("c2")) return 5;
|
||||
if (levelLower.includes("c1")) return 4;
|
||||
if (levelLower.includes("b2")) return 3;
|
||||
if (levelLower.includes("b1")) return 2;
|
||||
if (levelLower.includes("a2")) return 1;
|
||||
if (levelLower.includes("a1")) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user