calendar: compute real Easter dates and temporal-cycle seasons
resolveDay() no longer hardcodes season: 'trinitytide'. Easter is computed via the Anonymous Gregorian algorithm (verified against 16 known reference dates spanning 1583-2100); Advent/Christmastide/ Epiphanytide follow the Christmas-anchored rules (including Advent's "Sunday nearest Nov 30"); Septuagesima through Trinitytide come from easter-offsets.yml, reshaped from single named anchor points into real ranges plus single-day overrides for Corpus Christi and Sacred Heart. occurring feasts (calendar/feasts.ts) are still a stub — the sanctoral calendar and Double-vs-not ranking are a separate, larger project.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+27
-6
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string> };
|
||||
const easterOffsets = easterOffsetsData as {
|
||||
ranges: { season: string; fromOffset: number }[];
|
||||
days?: Record<string, string>;
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
+21
-5
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user