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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-01 16:28:18 +02:00
co-authored by Claude Opus 5
parent b2fa0e1b58
commit 261c6fa9ad
2 changed files with 127 additions and 0 deletions
+68
View File
@@ -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);
});
+59
View File
@@ -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));
}