diff --git a/src/lib/board/dates.test.ts b/src/lib/board/dates.test.ts new file mode 100644 index 0000000..84c0a87 --- /dev/null +++ b/src/lib/board/dates.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { classifyDueDate, formatDueDate } from "@/lib/board/dates"; + +const today = new Date("2026-09-16T10:00:00Z"); + +describe("classifyDueDate", () => { + it("returns none for an unset date", () => { + expect(classifyDueDate("", today)).toBe("none"); + expect(classifyDueDate("2026-09-16 00:00:00.000Z", today)).toBe("today"); + }); + + it("returns overdue for a past date", () => { + expect(classifyDueDate("2026-09-15", today)).toBe("overdue"); + }); + + it("returns today for the current date", () => { + expect(classifyDueDate("2026-09-16", today)).toBe("today"); + }); + + it("returns soon for within three days", () => { + expect(classifyDueDate("2026-09-17", today)).toBe("soon"); + expect(classifyDueDate("2026-09-19", today)).toBe("soon"); + }); + + it("returns later beyond three days", () => { + expect(classifyDueDate("2026-09-20", today)).toBe("later"); + }); + + it("compares whole days, ignoring the time of day", () => { + // A date due "today" must not flip to overdue at 00:01 simply because the + // stored timestamp is midnight and the clock has moved past it. + const lateInDay = new Date("2026-09-16T23:59:00Z"); + expect(classifyDueDate("2026-09-16 00:00:00.000Z", lateInDay)).toBe("today"); + }); +}); + +describe("formatDueDate", () => { + it("returns an empty string for an unset date", () => { + expect(formatDueDate("", today)).toBe(""); + }); + + it("writes today and tomorrow in words", () => { + expect(formatDueDate("2026-09-16", today)).toBe("Today"); + expect(formatDueDate("2026-09-17", today)).toBe("Tomorrow"); + }); + + it("writes a nearby date as a short day and month", () => { + expect(formatDueDate("2026-09-24", today)).toBe("24 Sep"); + }); + + it("includes the year when the date is in another year", () => { + expect(formatDueDate("2027-02-03", today)).toBe("3 Feb 2027"); + }); +}); \ No newline at end of file diff --git a/src/lib/board/dates.ts b/src/lib/board/dates.ts new file mode 100644 index 0000000..28246b3 --- /dev/null +++ b/src/lib/board/dates.ts @@ -0,0 +1,61 @@ +/** + * Target date classification and formatting. + * + * Comparisons are whole-day, in UTC. PocketBase stores a date field as + * "2026-09-16 00:00:00.000Z", so a naive timestamp comparison marks anything + * due today as overdue the moment the clock passes midnight — the board would + * turn red every morning for cards that are perfectly on time. + */ + +export type DueClass = "none" | "overdue" | "today" | "soon" | "later"; + +/** Whole days between two dates, positive when `date` is in the future. */ +function dayDelta(date: string, now: Date): number { + const target = Date.UTC( + Number(date.slice(0, 4)), + Number(date.slice(5, 7)) - 1, + Number(date.slice(8, 10)) + ); + const reference = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + ); + return Math.round((target - reference) / 86_400_000); +} + +export function classifyDueDate( + targetDate: string, + now: Date = new Date() +): DueClass { + if (!targetDate) return "none"; + + const delta = dayDelta(targetDate, now); + if (delta < 0) return "overdue"; + if (delta === 0) return "today"; + if (delta <= 3) return "soon"; + return "later"; +} + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +export function formatDueDate( + targetDate: string, + now: Date = new Date() +): string { + if (!targetDate) return ""; + + const delta = dayDelta(targetDate, now); + if (delta === 0) return "Today"; + if (delta === 1) return "Tomorrow"; + + const year = Number(targetDate.slice(0, 4)); + const month = MONTHS[Number(targetDate.slice(5, 7)) - 1]; + const day = Number(targetDate.slice(8, 10)); + + if (year !== now.getUTCFullYear()) return `${day} ${month} ${year}`; + return `${day} ${month}`; +} \ No newline at end of file