Skip to content
Open
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
282 changes: 181 additions & 101 deletions test/date-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,168 +1,248 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { describe, it, expect } from "vitest";
import {
formatDate,
formatRelativeDate,
formatDisplayDate,
toDateStr,
daysBetween,
dateDiffDays,
dateDiff,
isToday,
isYesterday,
getThisWeekRange,
getLastWeekRange,
getLocalDateString,
utcToLocalDate,
areConsecutiveDays,
calculateStreak,
} from "../src/lib/date-utils";

afterEach(() => {
vi.useRealTimers();
});

describe("formatDate", () => {
it("formats date as MMM D, YYYY", () => {
const d = new Date("2026-05-24T12:00:00Z");
expect(formatDate(d)).toBe("May 24, 2026");
it("formats a Date object", () => {
const d = new Date("2026-05-15T12:00:00Z");
expect(formatDate(d)).toMatch(/May 15, 2026/);
});

it("handles single-digit month and day", () => {
const d = new Date("2026-01-05T12:00:00Z");
expect(formatDate(d)).toBe("Jan 5, 2026");
it("formats a string", () => {
expect(formatDate("2026-01-01")).toMatch(/Jan 1, 2026/);
});

it("accepts string and number timestamps", () => {
expect(formatDate("2026-12-25T00:00:00Z")).toBe("Dec 25, 2026");
const ms = new Date("2026-07-04T00:00:00Z").getTime();
expect(formatDate(ms)).toBe("Jul 4, 2026");
it("formats a unix timestamp", () => {
expect(formatDate(1747699200000)).toMatch(/May 20, 2025/);
});

it("throws error for invalid dates", () => {
expect(() => formatDate("invalid-date-string")).toThrow("Invalid date");
expect(() => formatDate(NaN)).toThrow("Invalid date");
it("throws on invalid input", () => {
expect(() => formatDate("not-a-date")).toThrow("Invalid date");
});
});

describe("formatRelativeDate", () => {
it("returns 'Today' for date within 24 hours", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
it("returns Today for today", () => {
expect(formatRelativeDate(new Date())).toBe("Today");
});

expect(formatRelativeDate(Date.now() - 2 * 60 * 60 * 1000)).toBe("Today");
expect(formatRelativeDate(Date.now() - 23 * 60 * 60 * 1000)).toBe("Today");
it("returns Yesterday for yesterday", () => {
const d = new Date();
d.setDate(d.getDate() - 1);
expect(formatRelativeDate(d)).toBe("Yesterday");
});

it("returns 'Yesterday' for a date between 24 and 48 hours ago", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
it("returns days ago for <30 days", () => {
const d = new Date();
d.setDate(d.getDate() - 5);
expect(formatRelativeDate(d)).toBe("5 days ago");
});

expect(formatRelativeDate(Date.now() - 25 * 60 * 60 * 1000)).toBe("Yesterday");
expect(formatRelativeDate(Date.now() - 47 * 60 * 60 * 1000)).toBe("Yesterday");
it("returns formatted date for 30+ days ago", () => {
const d = new Date();
d.setDate(d.getDate() - 45);
expect(formatRelativeDate(d)).toMatch(/[A-Z][a-z]+ \d+, \d{4}/);
});

it("returns 'X days ago' for dates between 2 and 29 days ago", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
it("throws on invalid input", () => {
expect(() => formatRelativeDate("invalid")).toThrow("Invalid date");
});
});

describe("toDateStr", () => {
it("returns YYYY-MM-DD", () => {
const d = new Date(Date.UTC(2026, 4, 15));
expect(toDateStr(d)).toBe("2026-05-15");
});

expect(formatRelativeDate(Date.now() - 2 * 24 * 60 * 60 * 1000 - 1000)).toBe("2 days ago");
expect(formatRelativeDate(Date.now() - 29 * 24 * 60 * 60 * 1000 - 1000)).toBe("29 days ago");
it("pads month and day", () => {
const d = new Date(Date.UTC(2026, 0, 5));
expect(toDateStr(d)).toBe("2026-01-05");
});
});

it("returns absolute formatted date for 30 or more days ago", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
describe("daysBetween", () => {
it("returns positive difference when a < b", () => {
expect(daysBetween("2026-01-01", "2026-01-10")).toBe(9);
});

const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000 - 1000;
expect(formatRelativeDate(thirtyDaysAgo)).toBe(formatDate(thirtyDaysAgo));
it("returns negative difference when a > b", () => {
expect(daysBetween("2026-01-10", "2026-01-01")).toBe(-9);
});

it("throws error for invalid dates", () => {
expect(() => formatRelativeDate("invalid-date")).toThrow("Invalid date");
it("returns 0 for same day", () => {
expect(daysBetween("2026-06-15", "2026-06-15")).toBe(0);
});

it("throws on invalid input", () => {
expect(() => daysBetween("invalid", "2026-01-01")).toThrow("Invalid date");
expect(() => daysBetween("2026-01-01", "invalid")).toThrow("Invalid date");
});
});

describe("daysBetween", () => {
it("returns positive difference when B is after A", () => {
expect(daysBetween("2026-05-01", "2026-05-10")).toBe(9);
describe("dateDiffDays", () => {
it("returns fractional days", () => {
const a = "2026-06-01T00:00:00Z";
const b = "2026-06-01T12:00:00Z";
expect(dateDiffDays(a, b)).toBe(0.5);
});
});

it("returns negative difference when B is before A", () => {
expect(daysBetween("2026-05-10", "2026-05-01")).toBe(-9);
describe("dateDiff alias", () => {
it("is the same as dateDiffDays", () => {
expect(dateDiff("2026-06-01", "2026-06-03")).toBe(2);
});
});

it("returns 0 for the same day in UTC", () => {
expect(daysBetween("2026-05-24T02:00:00Z", "2026-05-24T22:00:00Z")).toBe(0);
describe("isToday", () => {
it("returns true for today", () => {
expect(isToday(new Date())).toBe(true);
});

it("handles leap years correctly", () => {
// 2024 is a leap year (Feb has 29 days)
expect(daysBetween("2024-02-28", "2024-03-01")).toBe(2);
// 2023 is not a leap year (Feb has 28 days)
expect(daysBetween("2023-02-28", "2023-03-01")).toBe(1);
it("returns false for yesterday", () => {
const d = new Date();
d.setDate(d.getDate() - 1);
expect(isToday(d)).toBe(false);
});

it("handles year boundaries crossing", () => {
expect(daysBetween("2025-12-31", "2026-01-01")).toBe(1);
expect(daysBetween("2025-12-31", "2026-12-31")).toBe(365);
it("returns false for invalid date", () => {
expect(isToday("invalid")).toBe(false);
});
});

it("handles DST boundaries correctly (avoiding off-by-one errors)", () => {
// US DST Start in 2026: March 8 (clocks go forward by 1 hour)
expect(daysBetween("2026-03-07T12:00:00Z", "2026-03-09T12:00:00Z")).toBe(2);
describe("isYesterday", () => {
it("returns true for yesterday", () => {
const d = new Date();
d.setDate(d.getDate() - 1);
expect(isYesterday(d)).toBe(true);
});

// US DST End in 2026: November 1 (clocks go back by 1 hour)
expect(daysBetween("2026-10-31T12:00:00Z", "2026-11-02T12:00:00Z")).toBe(2);
it("returns false for today", () => {
expect(isYesterday(new Date())).toBe(false);
});

it("throws error for invalid date inputs", () => {
expect(() => daysBetween("invalid", "2026-01-01")).toThrow("Invalid date");
expect(() => daysBetween("2026-01-01", "invalid")).toThrow("Invalid date");
it("returns false for invalid date", () => {
expect(isYesterday("invalid")).toBe(false);
});
});

describe("isToday", () => {
it("returns true only for today's date", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z")); // Local Monday
describe("getThisWeekRange", () => {
it("returns start and end with ISO strings", () => {
const result = getThisWeekRange();
expect(typeof result.start).toBe("string");
expect(typeof result.end).toBe("string");
expect(new Date(result.start) < new Date(result.end)).toBe(true);
});
});

const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
describe("getLastWeekRange", () => {
it("returns start and end with ISO strings", () => {
const result = getLastWeekRange();
expect(typeof result.start).toBe("string");
expect(typeof result.end).toBe("string");
expect(new Date(result.start) < new Date(result.end)).toBe(true);
});

expect(isToday(startOfToday)).toBe(true);
expect(isToday(endOfToday)).toBe(true);
it("end is before this week's start", () => {
const last = getLastWeekRange();
const current = getThisWeekRange();
expect(new Date(last.end) < new Date(current.start)).toBe(true);
});
});

const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
describe("getLocalDateString", () => {
it("returns a YYYY-MM-DD string", () => {
expect(getLocalDateString("UTC")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

it("returns UTC date for UTC timezone", () => {
const result = getLocalDateString("UTC");
const today = new Date().toISOString().slice(0, 10);
expect(result).toBe(today);
});
});

expect(isToday(tomorrow)).toBe(false);
expect(isToday(yesterday)).toBe(false);
describe("utcToLocalDate", () => {
it("converts UTC timestamp to YYYY-MM-DD", () => {
const result = utcToLocalDate("2026-06-15T12:00:00Z", "UTC");
expect(result).toBe("2026-06-15");
});

it("returns false for invalid date input", () => {
expect(isToday("invalid-date-format")).toBe(false);
it("handles string input", () => {
const result = utcToLocalDate("2026-06-15T12:00:00Z");
expect(result).toBeDefined();
});

it("handles Date input", () => {
const result = utcToLocalDate(new Date("2026-06-15T12:00:00Z"));
expect(result).toBeDefined();
});
});

describe("isYesterday", () => {
it("returns true only for yesterday's date", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
describe("areConsecutiveDays", () => {
it("returns true for consecutive days", () => {
expect(areConsecutiveDays("2026-06-01", "2026-06-02")).toBe(true);
});

it("returns true regardless of timezone param", () => {
expect(areConsecutiveDays("2026-06-01", "2026-06-02", "America/New_York")).toBe(true);
});

const startOfYesterday = new Date();
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
startOfYesterday.setHours(0, 0, 0, 0);

const endOfYesterday = new Date();
endOfYesterday.setDate(endOfYesterday.getDate() - 1);
endOfYesterday.setHours(23, 59, 59, 999);
it("returns false for same day", () => {
expect(areConsecutiveDays("2026-06-01", "2026-06-01")).toBe(false);
});

expect(isYesterday(startOfYesterday)).toBe(true);
expect(isYesterday(endOfYesterday)).toBe(true);
it("returns false for non-consecutive days", () => {
expect(areConsecutiveDays("2026-06-01", "2026-06-05")).toBe(false);
});
});

const today = new Date();
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
describe("calculateStreak", () => {
it("returns 0 for empty array", () => {
expect(calculateStreak([])).toBe(0);
});

it("returns 0 for null/undefined", () => {
expect(calculateStreak(null as any)).toBe(0);
});

expect(isYesterday(today)).toBe(false);
expect(isYesterday(twoDaysAgo)).toBe(false);
it("returns 1 for a single date matching today or yesterday", () => {
const today = new Date().toISOString().slice(0, 10);
expect(calculateStreak([today])).toBe(1);
});

it("returns false for invalid date input", () => {
expect(isYesterday("invalid-date-format")).toBe(false);
it("returns 0 for a date not matching today or yesterday", () => {
expect(calculateStreak(["2020-01-01"])).toBe(0);
});

it("counts consecutive days", () => {
const today = new Date();
const d1 = today.toISOString().slice(0, 10);
const d2 = new Date(today.getTime() - 86400000).toISOString().slice(0, 10);
const d3 = new Date(today.getTime() - 172800000).toISOString().slice(0, 10);
expect(calculateStreak([d1, d2, d3])).toBe(3);
});

it("breaks streak on gap", () => {
const today = new Date();
const d1 = today.toISOString().slice(0, 10);
const d2 = new Date(today.getTime() - 86400000).toISOString().slice(0, 10);
const d3 = new Date(today.getTime() - 345600000).toISOString().slice(0, 10);
expect(calculateStreak([d1, d2, d3])).toBe(2);
});
});
Loading