From 261c6fa9ad3bd401167f15059e82e29f596f645e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 16:28:18 +0200 Subject: [PATCH] feat(domain): add MonthId arithmetic Uses a month ordinal rather than Date, so month stepping is timezone-proof. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/months.test.ts | 68 +++++++++++++++++++++++++++++++++++++++ src/domain/months.ts | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/domain/months.test.ts create mode 100644 src/domain/months.ts diff --git a/src/domain/months.test.ts b/src/domain/months.test.ts new file mode 100644 index 0000000..7b32bd2 --- /dev/null +++ b/src/domain/months.test.ts @@ -0,0 +1,68 @@ +// src/domain/months.test.ts +import { test, expect, describe } from "bun:test"; +import { + monthOf, + nextMonth, + prevMonth, + compareMonths, + monthRange, + monthsOfYear, + addMonths, + yearOf, +} from "./months.ts"; + +test("monthOf extracts the month from an ISO date", () => { + expect(monthOf("2026-09-14")).toBe("2026-09"); +}); + +describe("month stepping", () => { + test("nextMonth crosses the year boundary", () => { + expect(nextMonth("2026-11")).toBe("2026-12"); + expect(nextMonth("2026-12")).toBe("2027-01"); + }); + + test("prevMonth crosses the year boundary", () => { + expect(prevMonth("2026-01")).toBe("2025-12"); + }); + + test("addMonths handles multi-year spans in both directions", () => { + expect(addMonths("2026-09", 6)).toBe("2027-03"); + expect(addMonths("2026-09", -12)).toBe("2025-09"); + expect(addMonths("2026-09", 0)).toBe("2026-09"); + }); +}); + +test("compareMonths orders chronologically", () => { + expect(compareMonths("2026-01", "2026-02")).toBeLessThan(0); + expect(compareMonths("2027-01", "2026-12")).toBeGreaterThan(0); + expect(compareMonths("2026-05", "2026-05")).toBe(0); +}); + +describe("monthRange", () => { + test("is inclusive of both ends", () => { + expect(monthRange("2026-11", "2027-01")).toEqual([ + "2026-11", + "2026-12", + "2027-01", + ]); + }); + + test("returns a single month when both ends match", () => { + expect(monthRange("2026-03", "2026-03")).toEqual(["2026-03"]); + }); + + test("returns empty when `to` precedes `from`", () => { + expect(monthRange("2026-05", "2026-04")).toEqual([]); + }); +}); + +test("monthsOfYear returns twelve padded months", () => { + const months = monthsOfYear(2026); + expect(months).toHaveLength(12); + expect(months[0]).toBe("2026-01"); + expect(months[11]).toBe("2026-12"); +}); + +test("yearOf parses the year", () => { + expect(yearOf("2026-09")).toBe(2026); +}); diff --git a/src/domain/months.ts b/src/domain/months.ts new file mode 100644 index 0000000..d586cfe --- /dev/null +++ b/src/domain/months.ts @@ -0,0 +1,59 @@ +import type { IsoDate, MonthId } from "./types.ts"; + +function parse(id: MonthId): { year: number; month: number } { + const match = /^(\d{4})-(\d{2})$/.exec(id); + if (!match) throw new Error(`Invalid MonthId: ${id}`); + return { year: Number(match[1]), month: Number(match[2]) }; +} + +function format(year: number, month: number): MonthId { + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`; +} + +/** Months since year 0. Avoids Date and therefore avoids timezones entirely. */ +function toOrdinal(id: MonthId): number { + const { year, month } = parse(id); + return year * 12 + (month - 1); +} + +function fromOrdinal(ordinal: number): MonthId { + return format(Math.floor(ordinal / 12), (ordinal % 12) + 1); +} + +export function monthOf(date: IsoDate): MonthId { + const match = /^(\d{4})-(\d{2})/.exec(date); + if (!match) throw new Error(`Invalid IsoDate: ${date}`); + return `${match[1]}-${match[2]}`; +} + +export function yearOf(id: MonthId): number { + return parse(id).year; +} + +export function addMonths(id: MonthId, count: number): MonthId { + return fromOrdinal(toOrdinal(id) + count); +} + +export function nextMonth(id: MonthId): MonthId { + return addMonths(id, 1); +} + +export function prevMonth(id: MonthId): MonthId { + return addMonths(id, -1); +} + +export function compareMonths(a: MonthId, b: MonthId): number { + return toOrdinal(a) - toOrdinal(b); +} + +export function monthRange(from: MonthId, to: MonthId): MonthId[] { + const start = toOrdinal(from); + const end = toOrdinal(to); + const out: MonthId[] = []; + for (let i = start; i <= end; i++) out.push(fromOrdinal(i)); + return out; +} + +export function monthsOfYear(year: number): MonthId[] { + return monthRange(format(year, 1), format(year, 12)); +}