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:
co-authored by
Claude Opus 5
parent
b2fa0e1b58
commit
261c6fa9ad
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user