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
122 changes: 81 additions & 41 deletions packages/web/src/utils/time.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { safeParseTime } from "./time.utils";

describe("safeParseTime", () => {
describe("safeParseTime (success cases)", () => {
describe("return success", () => {
it("should convert UTC time correctly", () => {
const localTime = "2024-01-15T10:30:00";
const result = safeParseTime(localTime);

// Check if it matches the value after UTC conversion
const expectedUtc = new Date(new Date(localTime).toUTCString()).getTime();
expect(result).toBe(expectedUtc);
});

it("should convert valid ISO string to timestamp", () => {
const isoString = "2024-01-15T10:30:00.000Z";
const result = safeParseTime(isoString);
expect(result).toBe(new Date(new Date(isoString).toUTCString()).getTime());
expect(typeof result).toBe("number");
it("should convert UTC ISO string correctly", () => {
const isoUtc = "2024-01-15T10:30:00Z";
const result = safeParseTime(isoUtc);
expect(result).toBe(Date.parse(isoUtc));
});

it("should convert valid Date object to timestamp", () => {
it("should convert Date object to timestamp", () => {
const date = new Date("2024-01-15T10:30:00.000Z");
const result = safeParseTime(date);
expect(result).toBe(new Date(date.toUTCString()).getTime());
expect(typeof result).toBe("number");
});

it("should handle valid number (timestamp)", () => {
const timestamp = 1705315800000; // 2024-01-15 10:30:00 UTC
it("should handle valid timestamp", () => {
const timestamp = 1705315800000;
const result = safeParseTime(timestamp);
expect(result).toBe(new Date(new Date(timestamp).toUTCString()).getTime());
expect(typeof result).toBe("number");
});

it("should handle common date string formats", () => {
const dateString = "2024-01-15";
const result = safeParseTime(dateString);
expect(result).toBe(new Date(new Date(dateString).toUTCString()).getTime());
expect(typeof result).toBe("number");
});

it("should handle various ISO formats", () => {
const formats = [
"2024-01-15T10:30:00Z",
"2024-01-15T10:30:00.000Z",
"2024-01-15T10:30:00+00:00",
"2024-01-15T10:30:00-05:00",
];
const formats = ["2024-01-15T10:30:00.000Z", "2024-01-15T10:30:00+00:00", "2024-01-15T10:30:00-05:00"];

formats.forEach(format => {
const result = safeParseTime(format);
Expand All @@ -55,43 +32,106 @@ describe("safeParseTime", () => {
});
});

it("should handle date-only string as UTC midnight", () => {
const dateString = "2024-01-15";
const result = safeParseTime(dateString);
expect(result).toBe(Date.UTC(2024, 0, 15, 0, 0, 0, 0));
});

it("should handle negative timestamp (before 1970)", () => {
const negativeTimestamp = -86400000; // 1969-12-31
const negativeTimestamp = -86400000;
const result = safeParseTime(negativeTimestamp);
expect(result).not.toBe(null);
expect(typeof result).toBe("number");
});

it("should handle numeric strings (seconds vs milliseconds)", () => {
const seconds = "1705315800";
const millis = "1705315800000";
expect(safeParseTime(seconds)).toBe(1705315800 * 1000);
expect(safeParseTime(millis)).toBe(1705315800000);
});

it("should handle timezone offsets correctly", () => {
const withOffset = "2024-01-15T01:00:00+09:00";
const utc = "2024-01-14T16:00:00Z";
expect(safeParseTime(withOffset)).toBe(safeParseTime(utc));
});

it("should handle leap day correctly", () => {
expect(safeParseTime("2024-02-29T00:00:00Z")).toBe(Date.parse("2024-02-29T00:00:00Z"));
});
});

describe("return null", () => {
describe("return null (rejected inputs)", () => {
it("should return null for null and undefined", () => {
expect(safeParseTime(null)).toBe(null);
expect(safeParseTime(undefined)).toBe(null);
});

it("should return null for empty string", () => {
it("should return null for empty or whitespace strings", () => {
expect(safeParseTime("")).toBe(null);
expect(safeParseTime(" ")).toBe(null);
expect(safeParseTime("\n\t")).toBe(null);
});

it("should return null for invalid strings", () => {
expect(safeParseTime("invalid-date")).toBe(null);
expect(safeParseTime("not-a-date")).toBe(null);
expect(safeParseTime("2024-13-40")).toBe(null);
expect(safeParseTime("2023-02-29T00:00:00Z")).toBe(null); // Invalid leap day
});

it("should return null for invalid object types", () => {
it("should return null for invalid ISO formats", () => {
expect(safeParseTime("2024-01-15T25:00:00Z")).toBe(null);
expect(safeParseTime("2024-01-15T10:61:00Z")).toBe(null);
expect(safeParseTime("2024-01-15T10:30:61Z")).toBe(null);
});

it("should return null for invalid types", () => {
expect(safeParseTime({} as any)).toBe(null);
expect(safeParseTime([] as any)).toBe(null);
expect(safeParseTime(["2024-01-15"] as any)).toBe(null);
expect(safeParseTime(true as any)).toBe(null);
expect(safeParseTime((() => "2024-01-15") as any)).toBe(null);
});

it("should return null for NaN", () => {
it("should return null for special numeric values", () => {
expect(safeParseTime(NaN)).toBe(null);
});

it("should return null for Infinity", () => {
expect(safeParseTime(Infinity)).toBe(null);
expect(safeParseTime(-Infinity)).toBe(null);
expect(safeParseTime("NaN" as any)).toBe(null);
expect(safeParseTime("Infinity" as any)).toBe(null);
});

it("should return null for out-of-range timestamps", () => {
expect(safeParseTime(9e15)).toBe(null);
expect(safeParseTime(-9e15)).toBe(null);
});

it("should not trigger object side-effects", () => {
let sideEffect = 0;
const evil = {
valueOf() {
sideEffect += 1;
return 1705315800000;
},
toString() {
sideEffect += 1;
return "2024-01-15T10:30:00Z";
},
} as unknown as any;

const result = safeParseTime(evil);
expect(sideEffect).toBe(0);
expect(result).toBe(null);
});

it("should not mutate Date instances", () => {
const d = new Date("2024-01-15T10:30:00Z");
const before = d.getTime();
const t = safeParseTime(d);
expect(t).toBe(before);
expect(d.getTime()).toBe(before);
});
});
});
128 changes: 118 additions & 10 deletions packages/web/src/utils/time.utils.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,127 @@
/**
* Union of accepted time-like inputs.
*
* @remark
* - `string`: must match one of the accepted formats described in {@link safeParseTime}.
* - `number`: interpreted as epoch miliseconds (ms).
* - `Date`: returns `data.getTime()` (already UTC-based).
* - `null | undefined`: yields `null`.
*/
type TimeInput = string | number | Date | null | undefined;

export const safeParseTime = (time: TimeInput): number | null => {
if (time == null || time === "") return null;
/** @internal */
const isLeapYear = (y: number) => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;

try {
const date = new Date(time);
if (isNaN(date.getTime())) return null;
/** @internal */
const validYMD = (y: number, m1: number, d: number): boolean => {
if (m1 < 0 || m1 > 11) return false;
if (d < 1) return false;
const dim = [31, isLeapYear(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m1];
return d <= dim;
};

/**
* Parses a variety of inputs into a **UTC epoch milliseconds** timestamp.
* Returns `null` for invalid or unsupported inputs.
*
* @param input - A time-like value to parse. See {@link TimeInput}
* @returns The UTC epoch milliseconds, or `null` when parsing/validation fails
*
* @example
* ```ts
* safeParseTime("2024-01-15T10:30:00Z"); // epoch ms (number)
* ```
* @example
* ```ts
* // Numeric string: seconds vs milliseconds
* safeParseTime("1705315800"); // 1705315800000 (seconds → ms)
* safeParseTime("1705315800000"); // 1705315800000 (already ms)
* ```
* @example
* ```ts
* // Date-only is fixed to UTC midnight
* safeParseTime("2024-01-15"); // Date.UTC(2024, 0, 15)
* ```
* @example
* ```ts
* // Ambiguous local date-time (no offset) is rejected
* safeParseTime("2024-01-15T10:30:00"); // null
* ```
*/
export const safeParseTime = (input: TimeInput): number | null => {
if (input == null) return null;

// Reject clearly unsupported types before any coercion
const t = typeof input;
if (t === "boolean" || t === "function" || t === "symbol" || t === "bigint") {
return null;
}
if (Array.isArray(input)) return null;

// Date instance
if (input instanceof Date) {
const ms = input.getTime();
return Number.isFinite(ms) ? ms : null;
}

// number -> epoch ms
if (t === "number") {
if (!Number.isFinite(input)) return null;
const ms = Number(input);
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : ms;
}

if (t === "string") {
const s = (input as string).trim();
if (s === "") return null;

// numeric-only string: seconds vs milliseconds policy
//
// Note: In tests, `new Date(date.toUTCString()).getTime()` is used to compare values
// in milliseconds. Therefore, we have consistently converted to ms form.
if (/^\d+$/.test(s)) {
const n = Number(s);
if (!Number.isFinite(n)) return null;
// length is stable and cheaper than numeric threshold
// 11-digit seconds won't appear in real life for millennia.
const ms = s.length <= 10 ? n * 1000 : n;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : ms;
}

if (typeof time === "boolean" || (typeof time === "object" && !(time instanceof Date)) || Array.isArray(time)) {
return null;
// YYYY-MM-DD
const mDateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (mDateOnly) {
const y = Number(mDateOnly[1]);
const m1 = Number(mDateOnly[2]) - 1;
const d = Number(mDateOnly[3]);
if (!validYMD(y, m1, d)) return null;
return Date.UTC(y, m1, d, 0, 0, 0, 0); // fixed to UTC midnight
}

const utcTime = new Date(date.toUTCString()).getTime();
return isNaN(utcTime) ? null : utcTime;
} catch {
// ISO 8601 / RFC3339 with Z or explicit offset
const mISO = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+\-]\d{2}:\d{2})$/.exec(s);
if (mISO) {
const y = Number(mISO[1]);
const m1 = Number(mISO[2]) - 1; // subtract 1 to convert to 0-indexed month
const d = Number(mISO[3]);
const hh = Number(mISO[4]);
const mm = Number(mISO[5]);
const ss = Number(mISO[6]);

// validate Data's overflow normalization from invalid calendar values
if (!validYMD(y, m1, d)) return null;
if (hh > 23 || mm > 59 || ss > 59) return null;
const ms = Date.parse(s);
return Number.isNaN(ms) ? null : ms;
}

// Reject anything else to avoid engine-dependent parsing.
// Browser/engines may parse free-form data strings differently.
return null;
}

// plain objects (non-Date) and other unknowns
return null;
};
Loading