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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user