import { intervalSchema } from "../schema-fields"; import { StackAssertionError } from "./errors"; import { remainder } from "./math"; export function isWeekend(date: Date): boolean { return date.getDay() === 0 || date.getDay() === 6; } import.meta.vitest?.test("isWeekend", ({ expect }) => { // Sunday (day 0) expect(isWeekend(new Date(2023, 0, 1))).toBe(true); // Saturday (day 6) expect(isWeekend(new Date(2023, 0, 7))).toBe(true); // Monday (day 1) expect(isWeekend(new Date(2023, 0, 2))).toBe(false); // Friday (day 5) expect(isWeekend(new Date(2023, 0, 6))).toBe(false); }); const agoUnits = [ [60, 'second'], [60, 'minute'], [24, 'hour'], [7, 'day'], [5, 'week'], ] as const; export function fromNow(date: Date): string { return fromNowDetailed(date).result; } import.meta.vitest?.test("fromNow", ({ expect }) => { // Set a fixed date for testing const fixedDate = new Date("2023-01-15T12:00:00.000Z"); // Use Vitest's fake timers import.meta.vitest?.vi.useFakeTimers(); import.meta.vitest?.vi.setSystemTime(fixedDate); // Test past times expect(fromNow(new Date("2023-01-15T11:59:50.000Z"))).toBe("just now"); expect(fromNow(new Date("2023-01-15T11:59:00.000Z"))).toBe("1 minute ago"); expect(fromNow(new Date("2023-01-15T11:00:00.000Z"))).toBe("1 hour ago"); expect(fromNow(new Date("2023-01-14T12:00:00.000Z"))).toBe("1 day ago"); expect(fromNow(new Date("2023-01-08T12:00:00.000Z"))).toBe("1 week ago"); // Test future times expect(fromNow(new Date("2023-01-15T12:00:10.000Z"))).toBe("just now"); expect(fromNow(new Date("2023-01-15T12:01:00.000Z"))).toBe("in 1 minute"); expect(fromNow(new Date("2023-01-15T13:00:00.000Z"))).toBe("in 1 hour"); expect(fromNow(new Date("2023-01-16T12:00:00.000Z"))).toBe("in 1 day"); expect(fromNow(new Date("2023-01-22T12:00:00.000Z"))).toBe("in 1 week"); // Test very old dates (should use date format) expect(fromNow(new Date("2022-01-15T12:00:00.000Z"))).toMatch(/Jan 15, 2022/); // Restore real timers import.meta.vitest?.vi.useRealTimers(); }); export function fromNowDetailed(date: Date): { result: string, /** * May be Infinity if the result will never change. */ secondsUntilChange: number, } { if (!(date instanceof Date)) { throw new Error(`fromNow only accepts Date objects (received: ${date})`); } const now = new Date(); const elapsed = now.getTime() - date.getTime(); let remainingInUnit = Math.abs(elapsed) / 1000; if (remainingInUnit < 15) { return { result: 'just now', secondsUntilChange: 15 - remainingInUnit, }; } let unitInSeconds = 1; for (const [nextUnitSize, unitName] of agoUnits) { const rounded = Math.round(remainingInUnit); if (rounded < nextUnitSize) { if (elapsed < 0) { return { result: `in ${rounded} ${unitName}${rounded === 1 ? '' : 's'}`, secondsUntilChange: remainder((remainingInUnit - rounded + 0.5) * unitInSeconds, unitInSeconds), }; } else { return { result: `${rounded} ${unitName}${rounded === 1 ? '' : 's'} ago`, secondsUntilChange: remainder((rounded - remainingInUnit - 0.5) * unitInSeconds, unitInSeconds), }; } } unitInSeconds *= nextUnitSize; remainingInUnit /= nextUnitSize; } return { result: date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }), secondsUntilChange: Infinity, }; } /** * Returns a string representation of the given date in the format expected by the `datetime-local` input type. */ export function getInputDatetimeLocalString(date: Date): string { date = new Date(date); date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); return date.toISOString().slice(0, 19); } import.meta.vitest?.test("getInputDatetimeLocalString", ({ expect }) => { // Use Vitest's fake timers to ensure consistent timezone behavior import.meta.vitest?.vi.useFakeTimers(); // Test with a specific date const mockDate = new Date("2023-01-15T12:30:45.000Z"); const result = getInputDatetimeLocalString(mockDate); // The result should be in the format YYYY-MM-DDTHH:MM:SS expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); // Test with different dates const dates = [ new Date("2023-01-01T00:00:00.000Z"), new Date("2023-06-15T23:59:59.000Z"), new Date("2023-12-31T12:34:56.000Z"), ]; for (const date of dates) { const result = getInputDatetimeLocalString(date); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); } // Restore real timers import.meta.vitest?.vi.useRealTimers(); }); export type Interval = [number, 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year']; export type DayInterval = [number, 'day' | 'week' | 'month' | 'year']; function applyInterval(date: Date, times: number, interval: Interval): Date { if (!intervalSchema.isValidSync(interval)) { throw new StackAssertionError(`Invalid interval`, { interval }); } const [amount, unit] = interval; switch (unit) { case 'millisecond': { date.setMilliseconds(date.getMilliseconds() + amount * times); break; } case 'second': { date.setSeconds(date.getSeconds() + amount * times); break; } case 'minute': { date.setMinutes(date.getMinutes() + amount * times); break; } case 'hour': { date.setHours(date.getHours() + amount * times); break; } case 'day': { date.setDate(date.getDate() + amount * times); break; } case 'week': { date.setDate(date.getDate() + amount * times * 7); break; } case 'month': { date.setMonth(date.getMonth() + amount * times); break; } case 'year': { date.setFullYear(date.getFullYear() + amount * times); break; } default: { throw new StackAssertionError(`Invalid interval despite schema validation`, { interval }); } } return date; } export function subtractInterval(date: Date, interval: Interval): Date { return applyInterval(date, -1, interval); } export function addInterval(date: Date, interval: Interval): Date { return applyInterval(date, 1, interval); } export const FAR_FUTURE_DATE = new Date(8640000000000000); // 13 Sep 275760 00:00:00 UTC function getMsPerDayIntervalUnit(unit: 'day' | 'week'): number { if (unit === 'day') { return 24 * 60 * 60 * 1000; } return 7 * 24 * 60 * 60 * 1000; } export function getIntervalsElapsed(anchor: Date, to: Date, repeat: DayInterval): number { const [amount, unit] = repeat; if (to <= anchor) return 0; if (unit === 'day' || unit === 'week') { const msPerUnit = getMsPerDayIntervalUnit(unit); const diffMs = to.getTime() - anchor.getTime(); return Math.floor(diffMs / (msPerUnit * amount)); } if (["month", "year"].includes(unit)) { let count = 0; let current = new Date(anchor); for (; ;) { const next = addInterval(new Date(current), [amount, unit]); if (next > to) break; current = next; count += 1; } return count; } return 0; } import.meta.vitest?.test("getIntervalsElapsed", ({ expect }) => { const anchor = new Date('2025-01-01T00:00:00.000Z'); const to = new Date('2025-01-15T00:00:00.000Z'); expect(getIntervalsElapsed(anchor, to, [1, 'week'])).toBe(2); expect(getIntervalsElapsed(anchor, to, [3, 'day'])).toBe(4); const mAnchor = new Date('2023-01-31T00:00:00.000Z'); const mTo = new Date('2023-03-01T00:00:00.000Z'); expect(getIntervalsElapsed(mAnchor, mTo, [1, 'month'])).toBe(0); const yAnchor = new Date('2020-01-01T00:00:00.000Z'); const yTo = new Date('2022-06-01T00:00:00.000Z'); expect(getIntervalsElapsed(yAnchor, yTo, [1, 'year'])).toBe(2); });