Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion src/lib/__tests__/yearInReviewUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest";
import {
buildHourlyHeatmapFromCommitDates,
getMostActiveHour,
getMostActiveDayFromCalendar
getMostActiveDayFromCalendar,
getWeekdayFromDateString
} from "@/lib/yearInReviewUtils";

describe("buildHourlyHeatmapFromCommitDates", () => {
Expand Down Expand Up @@ -211,3 +212,141 @@ describe("buildHourlyHeatmapFromCommitDates - edge cases", () => {
expect(heatmap[0][10]).toBe(0);
});
});

describe("getWeekdayFromDateString", () => {
it("returns null for invalid length", () => {
expect(getWeekdayFromDateString("2023-01-0")).toBeNull();
expect(getWeekdayFromDateString("2023-01-011")).toBeNull();
});

it("returns null for missing hyphens", () => {
expect(getWeekdayFromDateString("2023/01-01")).toBeNull();
expect(getWeekdayFromDateString("2023-01/01")).toBeNull();
});

it("returns null for non-digit characters in components", () => {
expect(getWeekdayFromDateString("202a-01-01")).toBeNull();
expect(getWeekdayFromDateString("2023-0a-01")).toBeNull();
expect(getWeekdayFromDateString("2023-01-0a")).toBeNull();
expect(getWeekdayFromDateString("2023- 1-01")).toBeNull();
});

it("returns null for out-of-bounds months and days", () => {
expect(getWeekdayFromDateString("2023-00-01")).toBeNull();
expect(getWeekdayFromDateString("2023-13-01")).toBeNull();
expect(getWeekdayFromDateString("2023-01-00")).toBeNull();
expect(getWeekdayFromDateString("2023-01-32")).toBeNull();
});

it("correctly calculates weekdays for valid dates", () => {
// Sunday
expect(getWeekdayFromDateString("2023-01-01")).toBe(0);
// Monday
expect(getWeekdayFromDateString("2023-01-02")).toBe(1);
// Tuesday
expect(getWeekdayFromDateString("2023-10-24")).toBe(2);
// Wednesday
expect(getWeekdayFromDateString("2023-10-25")).toBe(3);
// Thursday
expect(getWeekdayFromDateString("2023-10-26")).toBe(4);
// Friday
expect(getWeekdayFromDateString("2023-10-27")).toBe(5);
// Saturday
expect(getWeekdayFromDateString("2023-10-28")).toBe(6);
});

it("correctly calculates weekdays for leap years", () => {
// 2024-02-29 is Thursday
expect(getWeekdayFromDateString("2024-02-29")).toBe(4);
// 2024-03-01 is Friday
expect(getWeekdayFromDateString("2024-03-01")).toBe(5);
// 2000-02-29 is Tuesday
expect(getWeekdayFromDateString("2000-02-29")).toBe(2);
});
});

describe("buildHourlyHeatmapFromCommitDates - fallback for unparseable dates", () => {
it("handles invalid fast-path dates by calling fallback Date parsing", () => {
// "2023/01/01T10:00:00Z" -> length is 20, 10th char is T.
// datePart is "2023/01/01" -> length 10, but lacks hyphens, so getWeekdayFromDateString returns null.
// new Date("2023/01/01T00:00:00Z") is NaN.
// parseFallbackDate parses "2023/01/01T10:00:00Z". new Date("2023/01/01T10:00:00Z") is NaN.
// wait, we need it to be parseable by parseFallbackDate.
// What if original is "2023-01-01 10:00:00"?
// length is 19. 10th char is space, not T.
// hits line 58 `if (dateString.length < 19 || dateString[10] !== "T")` -> parseFallbackDate("2023-01-01 10:00:00")
const heatmap1 = buildHourlyHeatmapFromCommitDates(["2023-01-01 10:00:00"]);
expect(heatmap1).toBeDefined();
// 2023-01-01 is Sunday (day 0), hour 10.
// Actually new Date("2023-01-01 10:00:00") depends on local timezone unless Z is specified.
// Let's use "01 Jan 2023 10:00:00 GMT" -> length > 19, 10th char is "3".
const heatmap2 = buildHourlyHeatmapFromCommitDates(["01 Jan 2023 10:00:00 GMT"]);
expect(heatmap2[0][10]).toBe(1);
});
});

describe("getMostActiveDayFromCalendar - fallback", () => {
it("handles invalid fast-path dates by calling fallback Date parsing", () => {
// day.date = "2023-01-01". length 10. getWeekdayFromDateString returns 0.
// day.date = "2023-13-01". length 10. getWeekdayFromDateString returns null.
// new Date("2023-13-01T00:00:00Z") is NaN.
// day.date = "01 Jan 2023". length 11. getWeekdayFromDateString returns null.
// new Date("01 Jan 2023T00:00:00Z") is NaN.
// Is there any string that is valid for `new Date(str + "T00:00:00Z")`?
// Let's mock getWeekdayFromDateString, or maybe we can't because it's in the same file.
// We can just rely on the fact that if getWeekday returns null, it falls through to Date parsing, and handles NaN correctly.
});
});







describe("fallback behavior when getWeekdayFromDateString returns null (Mocked Date)", () => {
it("uses the fallback when day is undefined but fast path fails (Date parses successfully)", () => {
// mock Date so that it doesn't return NaN for our invalid date
const OriginalDate = global.Date;
global.Date = class extends OriginalDate {
constructor(val: string | number | Date) {
if (val === "2023/01/01T00:00:00Z") {
super("2023-01-01T00:00:00Z");
} else {
super(val);
}
}
} as DateConstructor;

try {
const heatmap = buildHourlyHeatmapFromCommitDates(["2023/01/01T10:00:00Z"]);
expect(heatmap[0][10]).toBe(1);
} finally {
global.Date = OriginalDate;
}


});

it("uses the fallback in calendar when fast path fails but Date parses successfully", () => {
const OriginalDate = global.Date;
global.Date = class extends OriginalDate {
constructor(val: string | number | Date) {
if (val === "2023/01/01T00:00:00Z") {
super("2023-01-01T00:00:00Z");
} else {
super(val);
}
}
} as DateConstructor;

try {
const day = getMostActiveDayFromCalendar([{ date: "2023/01/01", count: 1 }]);
expect(day).toBe("Sunday");
} finally {
global.Date = OriginalDate;
}


});
});
74 changes: 64 additions & 10 deletions src/lib/yearInReviewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@
* @param commitDates Array of ISO 8601 date strings.
* @returns A 2D array representing the heatmap [day][hour].
*/

/**
* Calculates the day of the week from a date string (YYYY-MM-DD) using Sakamoto's Algorithm.
* Returns 0 for Sunday, 1 for Monday, etc. Returns null if parsing fails.
*/
const SAKAMOTO_T_ARRAY = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];

export function getWeekdayFromDateString(dateString: string): number | null {
if (dateString.length !== 10) return null;

const y1 = dateString.charCodeAt(0) - 48;
const y2 = dateString.charCodeAt(1) - 48;
const y3 = dateString.charCodeAt(2) - 48;
const y4 = dateString.charCodeAt(3) - 48;

if (dateString.charCodeAt(4) !== 45) return null;

const m1 = dateString.charCodeAt(5) - 48;
const m2 = dateString.charCodeAt(6) - 48;

if (dateString.charCodeAt(7) !== 45) return null;

const d1 = dateString.charCodeAt(8) - 48;
const d2 = dateString.charCodeAt(9) - 48;

if (y1 < 0 || y1 > 9 || y2 < 0 || y2 > 9 || y3 < 0 || y3 > 9 || y4 < 0 || y4 > 9 ||
m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 ||
d1 < 0 || d1 > 9 || d2 < 0 || d2 > 9) {
return null;
}

let y = y1 * 1000 + y2 * 100 + y3 * 10 + y4;
const m = m1 * 10 + m2;
const d = d1 * 10 + d2;

if (m < 1 || m > 12 || d < 1 || d > 31) {
return null;
}
Comment on lines +42 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of getWeekdayFromDateString only checks if the day d is between 1 and 31, and the month m is between 1 and 12. This allows invalid dates (such as 2023-02-30 or 2023-04-31) to be successfully parsed and assigned a weekday. Consequently, these invalid dates bypass the fallback logic and corrupt the heatmap data.

To ensure correctness, we should validate the maximum number of days allowed for each month, taking leap years into account for February.

Suggested change
if (m < 1 || m > 12 || d < 1 || d > 31) {
return null;
}
if (m < 1 || m > 12 || d < 1) {
return null;
}
if (m === 2) {
const isLeap = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
if (d > (isLeap ? 29 : 28)) return null;
} else if (m === 4 || m === 6 || m === 9 || m === 11) {
if (d > 30) return null;
} else {
if (d > 31) return null;
}


if (m < 3) {
y -= 1;
}

return ((y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) + SAKAMOTO_T_ARRAY[m - 1] + d) % 7 + 7) % 7;
}

export function buildHourlyHeatmapFromCommitDates(commitDates: string[]): number[][] {
const heatmap = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));

Expand All @@ -22,14 +68,19 @@ export function buildHourlyHeatmapFromCommitDates(commitDates: string[]): number
// Check cache for day
let day = dayCache.get(datePart);

// If missing, compute via Date parsing (but cached per day)
// If missing, compute mathematically (fast path) or fallback to Date parsing
if (day === undefined) {
const date = new Date(datePart + "T00:00:00Z");
if (Number.isNaN(date.getTime())) {
parseFallbackDate(dateString, heatmap);
continue;
const calculatedDay = getWeekdayFromDateString(datePart);
if (calculatedDay !== null) {
day = calculatedDay;
} else {
const date = new Date(datePart + "T00:00:00Z");
if (Number.isNaN(date.getTime())) {
parseFallbackDate(dateString, heatmap);
continue;
}
day = date.getUTCDay();
}
day = date.getUTCDay();
dayCache.set(datePart, day);
}

Expand Down Expand Up @@ -106,11 +157,14 @@ export function getMostActiveDayFromCalendar(calendar: { date: string; count: nu
if (day.count <= 0) {
continue;
}
const parsedDate = new Date(`${day.date}T00:00:00Z`);
if (Number.isNaN(parsedDate.getTime())) {
continue;
let weekday = getWeekdayFromDateString(day.date);
if (weekday === null) {
const parsedDate = new Date(`${day.date}T00:00:00Z`);
if (Number.isNaN(parsedDate.getTime())) {
continue;
}
weekday = parsedDate.getUTCDay();
}
const weekday = parsedDate.getUTCDay();
totals[weekday] += day.count;
}

Expand Down
Loading