diff --git a/test/date-utils.test.ts b/test/date-utils.test.ts index bba1a6c33..b2eb8eedb 100644 --- a/test/date-utils.test.ts +++ b/test/date-utils.test.ts @@ -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); }); });