diff --git a/src/calendar/date-math.ts b/src/calendar/date-math.ts new file mode 100644 index 0000000..e2b0438 --- /dev/null +++ b/src/calendar/date-math.ts @@ -0,0 +1,27 @@ +// Small ISO-date ("YYYY-MM-DD") arithmetic helpers shared by easter.ts and +// temporal.ts. Parsed/formatted as UTC midnight throughout, same convention +// as weekday.ts, so results don't shift with the caller's local timezone. + +export interface CalendarDate { + year: number; + month: number; // 1-12 + day: number; +} + +export function toIsoDate(date: CalendarDate): string { + const mm = String(date.month).padStart(2, '0'); + const dd = String(date.day).padStart(2, '0'); + return `${date.year}-${mm}-${dd}`; +} + +export function addDays(isoDate: string, days: number): string { + const d = new Date(`${isoDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +export function daysBetween(fromIsoDate: string, toIsoDate: string): number { + const from = new Date(`${fromIsoDate}T00:00:00Z`).getTime(); + const to = new Date(`${toIsoDate}T00:00:00Z`).getTime(); + return Math.round((to - from) / 86_400_000); +} diff --git a/src/calendar/easter.ts b/src/calendar/easter.ts index 9de31fb..12ecb21 100644 --- a/src/calendar/easter.ts +++ b/src/calendar/easter.ts @@ -1,8 +1,29 @@ // Computus (the date of Easter, and everything the temporal cycle hangs off // of it — Septuagesima, Ash Wednesday, Ascension, Pentecost, Trinity Sunday). -// Not implemented yet: unused until milestone 4 (Lauds/Vespers), which is the -// first hour content that actually varies by season. Deliberately left empty -// rather than half-built ahead of need — see the "content stores" section of -// the plan for why calendar-rule logic is being kept swappable rather than -// front-loaded. -export {}; +// +// This is the Anonymous Gregorian algorithm (a.k.a. Meeus/Jones/Butcher), +// valid for the whole Gregorian era (1583 onward). Hand-rolled rather than +// pulling in a dependency: it's ~15 lines of pure integer arithmetic with a +// long history of independent verification (see calendar/easter.test.ts), +// not something that benefits from a library's abstraction or maintenance +// surface for an app with zero runtime dependencies otherwise. +import type { CalendarDate } from './date-math'; + +export function easterSunday(year: number): CalendarDate { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const n = h + l - 7 * m + 114; + const month = Math.floor(n / 31); + const day = (n % 31) + 1; + return { year, month, day }; +} diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 6a3a393..7c82352 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -1,21 +1,20 @@ import type { LiturgicalDay } from './types'; import { weekdayOf } from './weekday'; +import { resolveSeason } from './temporal'; /** * Resolves everything about a given day *except* hour content — weekday, - * season, and any occurring feasts. Season and occurring feasts are stubs - * until calendar/easter.ts and calendar/feasts.ts land at milestone 4; - * hours that only need weekday (Prime, Compline, Terce, Sext, None) can - * already rely on this fully. + * season, and any occurring feasts. Season is real (see + * calendar/temporal.ts); occurring feasts are still a stub until + * calendar/feasts.ts lands (the sanctoral calendar — which saint, if any, + * is kept on a given day, and Double-vs-not ranking — is a separate, + * larger project from temporal-cycle season resolution). */ export function resolveDay(isoDate: string): LiturgicalDay { return { date: isoDate, weekday: weekdayOf(isoDate), - // Placeholder string, not a real resolution — real season/temporal-id - // lookup (data/calendar/easter-offsets.yml + fixed-date-calendar.yml) - // lands at milestone 4. - season: 'trinitytide', + season: resolveSeason(isoDate), occurring: [], }; } diff --git a/src/calendar/temporal.ts b/src/calendar/temporal.ts index 030b3b0..0bc23d4 100644 --- a/src/calendar/temporal.ts +++ b/src/calendar/temporal.ts @@ -1,8 +1,96 @@ -// Temporal-cycle occurrence resolution: given a date, which temporal-id -// applies (season, proper Sunday/feria), by combining two offset systems — -// data/calendar/easter-offsets.yml (Septuagesima through Trinitytide) and -// data/calendar/fixed-date-calendar.yml (Christmas, Epiphany). Also where -// the Advent-start and Epiphanytide-length wrinkles noted in -// fixed-date-calendar.yml get arbitrated. Depends on calendar/easter.ts for -// the Easter date itself. Unused until milestone 4. -export {}; +// Temporal-cycle occurrence resolution: given a date, which season applies. +// Combines two independent anchor systems that hand off to each other once +// a year, right around Epiphanytide -> Septuagesima: +// - Christmas-relative (data/calendar/fixed-date-calendar.yml): Advent, +// Christmastide, Epiphanytide. Advent's start is "the Sunday nearest +// Nov 30", not a plain fixed date, so that rule lives in code here. +// - Easter-relative (data/calendar/easter-offsets.yml, via +// calendar/easter.ts): Septuagesima through the end of Trinitytide. +// +// Does NOT resolve occurring feasts (calendar/feasts.ts, still a stub) — +// a season is the temporal-cycle backdrop a day sits on; which saint (if +// any) is being kept that day, and whether it outranks the season, is a +// separate, larger project. +import type { Season } from './types'; +import { easterSunday } from './easter'; +import { addDays, daysBetween, toIsoDate } from './date-math'; +import fixedDateData from '../data/calendar/fixed-date-calendar.yml'; +import easterOffsetsData from '../data/calendar/easter-offsets.yml'; + +const fixedDates = fixedDateData as { dates: Record }; +const easterOffsets = easterOffsetsData as { + ranges: { season: string; fromOffset: number }[]; + days?: Record; +}; + +function fixedDateFor(id: string): { month: number; day: number } { + const entry = Object.entries(fixedDates.dates).find(([, value]) => value === id); + if (!entry) { + throw new Error(`fixed-date-calendar.yml has no entry for '${id}'`); + } + const [monthStr, dayStr] = entry[0].split('-'); + return { month: Number(monthStr), day: Number(dayStr) }; +} + +const CHRISTMAS = fixedDateFor('christmas-day'); +const EPIPHANY = fixedDateFor('epiphany'); + +/** The Sunday nearest Nov 30 (St. Andrew's Day) — Advent's real start rule. */ +function adventStart(year: number): string { + const nov30 = `${year}-11-30`; + const dow = new Date(`${nov30}T00:00:00Z`).getUTCDay(); // 0 = Sunday + const delta = dow <= 3 ? -dow : 7 - dow; + return addDays(nov30, delta); +} + +function seasonFromEasterOffset(offset: number): Season { + const dayOverride = easterOffsets.days?.[String(offset)]; + if (dayOverride) { + return dayOverride; + } + const sorted = [...easterOffsets.ranges].sort((a, b) => a.fromOffset - b.fromOffset); + const first = sorted[0]; + if (!first) { + throw new Error('easter-offsets.yml has no ranges'); + } + let season: Season = first.season; + for (const range of sorted) { + if (offset >= range.fromOffset) { + season = range.season; + } + } + return season; +} + +export function resolveSeason(isoDate: string): Season { + const [, monthStr, dayStr] = isoDate.split('-'); + const month = Number(monthStr); + const day = Number(dayStr); + const year = Number(isoDate.slice(0, 4)); + + // Advent: the Sunday nearest Nov 30 through Dec 24. + const advent = adventStart(year); + const isChristmasDayOrLater = month === CHRISTMAS.month && day >= CHRISTMAS.day; + if (isoDate >= advent && !isChristmasDayOrLater) { + return 'advent'; + } + + // Christmastide: Christmas Day through Jan 5, wrapping across the civil + // year boundary (so checked as two separate month/day windows, not one + // date range). + if (isChristmasDayOrLater || (month === 1 && day < EPIPHANY.day)) { + return 'christmastide'; + } + + // Epiphanytide: Epiphany until Septuagesima cuts in. Septuagesima's + // earliest possible date (Jan 18, when Easter falls on its earliest + // possible date, Mar 22) is always after Epiphany, so no year-rollover + // Easter lookup is ever needed to make this comparison. + const easterIso = toIsoDate(easterSunday(year)); + const septuagesimaStart = addDays(easterIso, -63); + if (isoDate < septuagesimaStart) { + return 'epiphanytide'; + } + + return seasonFromEasterOffset(daysBetween(easterIso, isoDate)); +} diff --git a/src/calendar/types.ts b/src/calendar/types.ts index e5f2125..895a5f2 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -14,6 +14,26 @@ export type Weekday = // actually resolves the "opinionated but configurable" tension: the opinion // (trinitytide) lives in editable data, not in a TS enum you'd recompile to // change. +// +// A real limitation this doesn't fully solve: a single `season` string can +// only hold one mutually-exclusive value per day, but several independent +// liturgical windows overlap without sharing boundaries. For example, on a +// real day between Candlemas (Feb 2) and Ash Wednesday, Compline's Marian +// antiphon should already be Ave Regina Caelorum, but Lent's Alleluia +// suppression and hymn swap shouldn't have started yet — two things that +// are both true at once, which one `season` value can't represent. +// +// Real season resolution now exists (calendar/temporal.ts, calendar/easter.ts), +// and the Marian-antiphon case above is handled — but not by adding a +// `season` value for it. hours/marian-antiphon.ts checks the real date +// directly instead of going through `season` at all for that one window. +// That's a fine, scoped fix for one known overlap; it isn't a general +// solution. If another mechanism turns up with the same shape (a window +// that doesn't nest inside one `season` bucket), reach for the same +// pattern — a direct date check bypassing `season` — rather than trying to +// force `season` to hold two truths at once. Only worth generalizing into +// several independent named windows/flags on `LiturgicalDay` itself if a +// third case shows up and the duplication starts to hurt. export type Season = string; // Open-ended on purpose — the actual ranking scheme (double/semidouble/simple, @@ -35,11 +55,7 @@ export interface LiturgicalDay { /** ISO date, e.g. "2026-08-09" */ date: string; weekday: Weekday; - /** - * Real season resolution depends on Easter's date (see calendar/easter.ts, - * not implemented until milestone 4). Until then this is a placeholder and - * must not be trusted by any hour's logic. - */ + /** Real temporal-cycle season, computed via calendar/temporal.ts. */ season: Season; /** Always [] until milestone 4 wires up calendar/feasts.ts. */ occurring: OccurringFeast[]; diff --git a/src/data/calendar/easter-offsets.yml b/src/data/calendar/easter-offsets.yml index 44746f3..1e07fcd 100644 --- a/src/data/calendar/easter-offsets.yml +++ b/src/data/calendar/easter-offsets.yml @@ -1,18 +1,30 @@ # Offsets in days from Easter Sunday (0). Negative = before Easter, positive # = after. Covers the whole Easter-anchored span: Septuagesima through the -# last Sunday of Trinitytide. A temporal-id's *name* (and hence whether it -# reads "trinitytide" or "time after pentecost") lives here as data, not as -# a compiled TS enum — see calendar/types.ts's Season type. +# last Sunday of Trinitytide. A season's *name* (and hence whether it reads +# "trinitytide" or "time after pentecost") lives here as data, not as a +# compiled TS enum — see calendar/types.ts's Season type. # -# PLACEHOLDER: only a handful of anchor points, to prove the shape. The full -# set of Sundays/ferias between anchors is content-authoring work for -# milestone 4. -offsets: - -63: septuagesima-sunday - -56: sexagesima-sunday - -49: quinquagesima-sunday - -46: ash-wednesday - 0: easter-sunday - 39: ascension-thursday - 49: pentecost-sunday - 56: trinity-sunday +# `ranges` gives the season that starts at each offset and runs until the +# next range's offset (or, for the last entry, until Advent cuts it off — +# see calendar/temporal.ts). `days` overrides a single specific offset with +# its own one-day season, for movable feasts that aren't a whole season +# but that other by-season tables (prime-chapter-responsory-by-season.yml, +# hymn-doxology-by-season.yml) already reference by name. +ranges: + - season: septuagesima + fromOffset: -63 + - season: lent + fromOffset: -46 + - season: passiontide + fromOffset: -14 + - season: eastertide + fromOffset: 0 + - season: ascensiontide + fromOffset: 39 + - season: pentecost + fromOffset: 49 + - season: trinitytide + fromOffset: 56 +days: + 60: corpus-christi # Thursday after Trinity Sunday + 68: sacred-heart # Friday after the octave of Corpus Christi diff --git a/tests/calendar/easter.test.ts b/tests/calendar/easter.test.ts new file mode 100644 index 0000000..9939242 --- /dev/null +++ b/tests/calendar/easter.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { easterSunday } from '../../src/calendar/easter'; +import { toIsoDate } from '../../src/calendar/date-math'; + +// Reference dates are public record (Western/Gregorian Easter Sunday), +// spanning different centuries and both early and late extremes. +const KNOWN_EASTER_DATES: Record = { + 1583: '1583-04-10', // first year the Gregorian algorithm is valid + 1900: '1900-04-15', + 1954: '1954-04-18', + 2000: '2000-04-23', + 2019: '2019-04-21', + 2020: '2020-04-12', + 2021: '2021-04-04', + 2022: '2022-04-17', + 2023: '2023-04-09', + 2024: '2024-03-31', // one of the earliest possible dates + 2025: '2025-04-20', + 2026: '2026-04-05', + 2027: '2027-03-28', + 2028: '2028-04-16', + 2038: '2038-04-25', // one of the latest possible dates + 2100: '2100-03-28', +}; + +describe('easterSunday', () => { + for (const [year, expected] of Object.entries(KNOWN_EASTER_DATES)) { + it(`resolves ${year} to ${expected}`, () => { + expect(toIsoDate(easterSunday(Number(year)))).toBe(expected); + }); + } +}); diff --git a/tests/calendar/temporal.test.ts b/tests/calendar/temporal.test.ts new file mode 100644 index 0000000..c46cdcb --- /dev/null +++ b/tests/calendar/temporal.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSeason } from '../../src/calendar/temporal'; + +// Easter 2026 = Apr 5. Reference boundaries computed by hand from that. +describe('resolveSeason', () => { + it('resolves Advent (the Sunday nearest Nov 30) through Dec 24', () => { + // Nov 30, 2025 is a Sunday -> Advent I starts that day. + expect(resolveSeason('2025-11-29')).toBe('trinitytide'); + expect(resolveSeason('2025-11-30')).toBe('advent'); + expect(resolveSeason('2025-12-24')).toBe('advent'); + }); + + it('resolves Christmastide across the year boundary (Dec 25 - Jan 5)', () => { + expect(resolveSeason('2025-12-25')).toBe('christmastide'); + expect(resolveSeason('2025-12-31')).toBe('christmastide'); + expect(resolveSeason('2026-01-01')).toBe('christmastide'); + expect(resolveSeason('2026-01-05')).toBe('christmastide'); + }); + + it('resolves Epiphanytide from Jan 6 until Septuagesima', () => { + expect(resolveSeason('2026-01-06')).toBe('epiphanytide'); + // Easter 2026 is Apr 5, so Septuagesima (-63 days) is Feb 1. + expect(resolveSeason('2026-01-31')).toBe('epiphanytide'); + }); + + it('resolves the Easter-offset seasons for 2026 (Easter = Apr 5)', () => { + expect(resolveSeason('2026-02-01')).toBe('septuagesima'); // Easter - 63 + expect(resolveSeason('2026-02-18')).toBe('lent'); // Ash Wednesday, Easter - 46 + expect(resolveSeason('2026-03-22')).toBe('passiontide'); // Easter - 14 + expect(resolveSeason('2026-04-05')).toBe('eastertide'); // Easter Sunday + expect(resolveSeason('2026-05-14')).toBe('ascensiontide'); // Easter + 39 + expect(resolveSeason('2026-05-24')).toBe('pentecost'); // Easter + 49 + expect(resolveSeason('2026-05-31')).toBe('trinitytide'); // Easter + 56 + }); + + it('resolves the Corpus Christi and Sacred Heart single-day overrides', () => { + expect(resolveSeason('2026-06-04')).toBe('corpus-christi'); // Easter + 60 + expect(resolveSeason('2026-06-12')).toBe('sacred-heart'); // Easter + 68 + // The days immediately around them are still plain Trinitytide. + expect(resolveSeason('2026-06-05')).toBe('trinitytide'); + }); + + it('resolves plain Trinitytide between Trinity Sunday and Advent', () => { + expect(resolveSeason('2026-09-15')).toBe('trinitytide'); + }); + + it('never needs a cross-year Easter lookup for January dates', () => { + // A year with a very early Easter (2027-03-28) still keeps Septuagesima + // (Jan 24) safely after Epiphany (Jan 6). + expect(resolveSeason('2027-01-06')).toBe('epiphanytide'); + expect(resolveSeason('2027-01-23')).toBe('epiphanytide'); + expect(resolveSeason('2027-01-24')).toBe('septuagesima'); + }); +});