feat(board): whole-day due date classification and formatting

This commit is contained in:
Dave 2026-09-16 10:00:37 +02:00
parent 2949fdabb7
commit 193fe5bd86
2 changed files with 115 additions and 0 deletions

View file

@ -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");
});
});

61
src/lib/board/dates.ts Normal file
View file

@ -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}`;
}