feat(domain): day arithmetic without Date

Recurrences of 28 and 30 days do not align to months, so the domain needs
day-level maths for the first time. It gets it the way months.ts does: an
integer ordinal, no Date anywhere. A Date would make 2026-03-29 a different
instant per timezone while being the same day everywhere, and a 28-day step
must not gain or lose a day at a DST boundary.

Hinnant's days_from_civil, which shifts the year to start in March so the leap
day is the last day rather than an insertion, removing every special case.

No isDayGranular export: nothing in the plan consumes it, later tasks branch
on a recurrence kind and import validation uses a regex directly.
This commit is contained in:
Lucas Winther
2026-09-03 03:34:32 +02:00
parent 94e7a189b6
commit de333685a2
3 changed files with 179 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import { addDays, fromDayOrdinal, toDayOrdinal, weekdayOf } from "./days.ts";
describe("day ordinals", () => {
test("the epoch is day zero", () => {
expect(toDayOrdinal("1970-01-01")).toBe(0);
});
test("known dates convert to known ordinals", () => {
expect(toDayOrdinal("1969-12-31")).toBe(-1);
expect(toDayOrdinal("2000-03-01")).toBe(11017);
expect(toDayOrdinal("2026-09-03")).toBe(20699);
});
test("every date in a four-year window round-trips", () => {
// 2024 is a leap year, 2025 and 2026 are not, so this covers both.
for (let ordinal = toDayOrdinal("2024-01-01"); ordinal <= toDayOrdinal("2027-12-31"); ordinal++) {
expect(toDayOrdinal(fromDayOrdinal(ordinal))).toBe(ordinal);
}
});
test("leap day exists in 2024 and not in 2026", () => {
expect(fromDayOrdinal(toDayOrdinal("2024-02-28") + 1)).toBe("2024-02-29");
expect(fromDayOrdinal(toDayOrdinal("2026-02-28") + 1)).toBe("2026-03-01");
});
test("century rules: 1900 is not a leap year, 2000 is", () => {
expect(fromDayOrdinal(toDayOrdinal("1900-02-28") + 1)).toBe("1900-03-01");
expect(fromDayOrdinal(toDayOrdinal("2000-02-28") + 1)).toBe("2000-02-29");
});
test("a month-only date is rejected, not silently treated as the first", () => {
expect(() => toDayOrdinal("2026-09")).toThrow(/day-granular/);
});
test("an impossible day is rejected", () => {
expect(() => toDayOrdinal("2026-02-30")).toThrow();
expect(() => toDayOrdinal("2026-13-01")).toThrow();
});
});
describe("addDays", () => {
test("crosses a month boundary", () => {
expect(addDays("2026-01-30", 3)).toBe("2026-02-02");
});
test("crosses a year boundary", () => {
expect(addDays("2026-12-30", 5)).toBe("2027-01-04");
});
test("a 28-day cycle lands on the same weekday every time", () => {
let date = "2026-01-05";
const weekday = weekdayOf(date);
for (let i = 0; i < 13; i++) {
date = addDays(date, 28);
expect(weekdayOf(date)).toBe(weekday);
}
});
test("a 30-day cycle drifts through the calendar", () => {
// The phone bill's shape: 30 days is not a month, so it walks backwards
// through the month over a year.
expect(addDays("2026-01-31", 30)).toBe("2026-03-02");
});
});
describe("weekdayOf", () => {
test("the epoch was a Thursday", () => {
expect(weekdayOf("1970-01-01")).toBe(4);
});
test("known weekdays", () => {
expect(weekdayOf("2026-09-03")).toBe(4); // Thursday
expect(weekdayOf("2026-09-06")).toBe(0); // Sunday
expect(weekdayOf("2026-09-07")).toBe(1); // Monday
});
test("seven consecutive days cover every weekday exactly once", () => {
const seen = new Set<number>();
for (let i = 0; i < 7; i++) seen.add(weekdayOf(addDays("2026-09-03", i)));
expect(seen.size).toBe(7);
});
});
+95
View File
@@ -0,0 +1,95 @@
import type { IsoDate } from "./types.ts";
/**
* Day-level arithmetic, in the same character as `months.ts`: integer maths on
* a day ordinal, never a `Date`.
*
* `months.ts` avoids `Date` to avoid timezones, and the reasoning applies here
* with more force. "2026-03-29" names the same day everywhere; the instant a
* `Date` would give it does not, and a recurrence that steps 28 days must not
* gain or lose one at a DST boundary or in a different host timezone.
*
* Only the day-granular recurrences need this. `everyNMonths` stays on
* `months.ts`, because rent occurs IN a month rather than on a date.
*/
const DAY_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function daysInMonth(year: number, month: number): number {
if (month === 2) return isLeapYear(year) ? 29 : 28;
return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
}
/**
* Days since 1970-01-01, by Howard Hinnant's `days_from_civil`. Proleptic
* Gregorian, exact for every date this app can hold, and pure integer maths.
*
* The algorithm shifts the year to start in March so that the leap day is the
* LAST day of the year rather than an insertion in the middle, which is what
* removes every special case from the day-of-year term.
*/
export function toDayOrdinal(date: IsoDate): number {
const match = DAY_DATE.exec(date);
if (!match) {
throw new Error(`Not a day-granular IsoDate: ${date}`);
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (month < 1 || month > 12) {
throw new Error(`Invalid month in IsoDate: ${date}`);
}
if (day < 1 || day > daysInMonth(year, month)) {
throw new Error(`Invalid day in IsoDate: ${date}`);
}
const shifted = month <= 2 ? year - 1 : year;
const era = Math.floor(shifted / 400);
const yearOfEra = shifted - era * 400;
const dayOfYear =
Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
const dayOfEra =
yearOfEra * 365 +
Math.floor(yearOfEra / 4) -
Math.floor(yearOfEra / 100) +
dayOfYear;
return era * 146097 + dayOfEra - 719468;
}
/** The exact inverse of `toDayOrdinal` (Hinnant's `civil_from_days`). */
export function fromDayOrdinal(ordinal: number): IsoDate {
const z = ordinal + 719468;
const era = Math.floor(z / 146097);
const dayOfEra = z - era * 146097;
const yearOfEra = Math.floor(
(dayOfEra -
Math.floor(dayOfEra / 1460) +
Math.floor(dayOfEra / 36524) -
Math.floor(dayOfEra / 146096)) /
365,
);
const year = yearOfEra + era * 400;
const dayOfYear =
dayOfEra -
(365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
const shiftedMonth = Math.floor((5 * dayOfYear + 2) / 153);
const day = dayOfYear - Math.floor((153 * shiftedMonth + 2) / 5) + 1;
const month = shiftedMonth + (shiftedMonth < 10 ? 3 : -9);
const calendarYear = month <= 2 ? year + 1 : year;
return `${String(calendarYear).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
export function addDays(date: IsoDate, count: number): IsoDate {
return fromDayOrdinal(toDayOrdinal(date) + count);
}
/** 0 = Sunday. Day 0 of the ordinal was a Thursday, hence the + 4. */
export function weekdayOf(date: IsoDate): number {
const shifted = (toDayOrdinal(date) + 4) % 7;
return shifted < 0 ? shifted + 7 : shifted;
}
+1
View File
@@ -3,6 +3,7 @@ export const DOMAIN_READY = true;
export * from "./types.ts";
export * from "./money.ts";
export * from "./months.ts";
export * from "./days.ts";
export * from "./fx.ts";
export * from "./allocation.ts";
export * from "./charges.ts";