From de333685a2649e0a663bccab93df2a54cfc77b1e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 03:34:32 +0200 Subject: [PATCH] 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. --- src/domain/days.test.ts | 83 +++++++++++++++++++++++++++++++++++ src/domain/days.ts | 95 +++++++++++++++++++++++++++++++++++++++++ src/domain/index.ts | 1 + 3 files changed, 179 insertions(+) create mode 100644 src/domain/days.test.ts create mode 100644 src/domain/days.ts diff --git a/src/domain/days.test.ts b/src/domain/days.test.ts new file mode 100644 index 0000000..b7458f4 --- /dev/null +++ b/src/domain/days.test.ts @@ -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(); + for (let i = 0; i < 7; i++) seen.add(weekdayOf(addDays("2026-09-03", i))); + expect(seen.size).toBe(7); + }); +}); diff --git a/src/domain/days.ts b/src/domain/days.ts new file mode 100644 index 0000000..0951a4c --- /dev/null +++ b/src/domain/days.ts @@ -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; +} diff --git a/src/domain/index.ts b/src/domain/index.ts index 94c7962..f9804be 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -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";