diff --git a/TODO.md b/TODO.md index a3c93fe..a5f615f 100644 --- a/TODO.md +++ b/TODO.md @@ -22,10 +22,6 @@ across every hour and content type in this app. - Pre-1955-but-post-1617 saints' differently-structured Matins readings — source confirmed (Tridentine/Divino Afflatu, combine-where-sensible applies the same way), but the actual sourcing/modeling pass hasn't started — see "Matins ordo" below. - - The "which Sunday governs this date" temporal-id algorithm (23rd-Sunday-before-Advent - rule, Epiphany-Sunday transfer to the end of the liturgical year, the - Saturday-before-Septuagesima 6th-Epiphany-Sunday commemoration edge case) — tabled, not - started. - Easter's own octave — tabled, "a whole different discussion." - What "a Gospel reading is its own kind" should mean structurally/in the UI, beyond the `isGospel` flag on a plain Matins `lesson` — open; deferred until there's a UI to @@ -1460,17 +1456,72 @@ seasonally variable — what's stored is whichever form rendered live on each one's already-chosen clean query date, not a season-aware substitution. Same class of gap as the Paschaltide alleluia suffix elsewhere in this file, flagged not fixed. -- **The "which Sunday governs this date" temporal-id algorithm** — the fixed - 23rd-Sunday-before-Advent rule, Epiphany Sundays transferring to the end - of the liturgical year when they don't fit before Septuagesima, and the - Saturday-before-Septuagesima edge case (also commemorate the 6th Sunday - of Epiphany that day, in overflow years). Described in detail in an - earlier conversation, explicitly tabled, not started. Distinct from - Christ the King, which didn't need it (last-Sunday-of-October is direct - date math). - **Easter's own octave** — explicitly tabled ("easter needs a whole different discussion"). +### The "which Sunday governs this date" temporal-id algorithm — done (2026-08) + +Overflow years (Easter early enough that fewer than 6 Sundays after Epiphany occur before +Septuagesima cuts in) previously just clamped every excess post-Pentecost Sunday to +`post-pentecost-24` — wrong for every week between XXIII and the real last Sunday of the +year, only accidentally right for that final Sunday itself. + +`calendar/temporal-id.ts`'s post-Pentecost branch is now ported directly from the reference +engine's own `getweek()` (`DivinumOfficium/Date.pm`) rather than re-derived by hand: once the +governing Sunday's Pentecost week reaches XXIII, a `wdist` (weeks until Advent) calculation +decides the id — the fixed final Sunday (`wdist < 2`) always resolves to `post-pentecost-24` +regardless of the true elapsed count, `post-pentecost-23` covers its own normal week, and +everything else resumes as `post-epiphany-${8 - wdist}` — the skipped post-Epiphany Sundays, +in their own ascending order, immediately before the fixed final Sunday. No separate tracking +of "how many post-Epiphany Sundays occurred" is needed — it falls out of the Advent-distance +arithmetic alone, confirmed against the reference engine's source rather than assumed. Trinity +Sunday = `post-pentecost-01` (this app's existing convention) also confirmed correct against +the same source (`$n=1` at `easter+56`, matching `TemporaM/Pent01-0.txt`'s own Trinity-Sunday +content). + +Separately, `calendar/index.ts` gained `applyEpiphany6Commemoration`, an `apply*`-pass +(same shape as `applyChristTheKing`) that commemorates `post-epiphany-6` on the Saturday +immediately before Septuagesima — but only in years where Epiphany V (not IV, III, ...) was +the last post-Epiphany Sunday to actually occur, per direct clarification (a narrower rule +than "any skipped Sunday gets a Saturday commemoration" — only VI specifically, and only in +that one adjacency case). No new `Commemoration` variant was needed: the existing +`{ kind: 'temporal'; id }` shape already round-trips through `getDayCollects`'s +`${id}-collect` lookup for free, so this is purely additive to `commemorations`, coexisting +with whatever else that Saturday already resolved to (consistent with this app's generous, +non-single-winner commemoration design elsewhere). + +Verified live against real years, not just unit-tested: 2026 (`post-epiphany-5` is the last +Sunday reached, so `2026-11-14`/`-21` resume as Epiphany V/VI before `2026-11-28`'s fixed +`post-pentecost-24`), and 1943/1962 (Easter late enough that all 6 Epiphany Sundays occur, so +no resumption and no Saturday commemoration ever fires). New test file +`tests/calendar/temporal-id.test.ts` covers both the overflow resumption and the Saturday +commemoration's on/off cases (2026, 2022, 2025, 1943). `npm test` (363 tests, 4 new) and +`npm run build` both pass. + +**Follow-up done same day:** `calendar/day-label.ts`'s on-screen display now also tracks the +resumed-Sunday content, via a `trinitytideOverrideLabel` check ahead of the ordinary +Trinity-counted branch — it calls `resolveTemporalId` directly (the same mechanism the content +layer trusts) rather than re-deriving the wdist arithmetic a second time, so the label can +never disagree with what's actually rendered underneath. A resumed Sunday/weekday now reads +"The 5th Sunday after Epiphany" / "Monday in the 5th week after Epiphany" instead of continuing +the Trinity count past its real bound. + +**Correction, same pass**: the first cut of this left the fixed final Sunday of the year +(`post-pentecost-24`) on the old raw "weeks since Trinity" arithmetic, on the assumption that +was an unrelated, already-correct convention — wrong. Scanning every year 1900-2100 showed +*every single year's* last Sunday before Advent resolves to `post-pentecost-24`, and the raw +elapsed-week count for that Sunday varies year to year (22nd, 23rd, 25th, ... depending on +Easter's date) even though the content is always the same fixed formulary. Since this app's +Trinity-counted display is one week off from the Pentecost-counted id (Trinity Sunday itself = +`post-pentecost-01` = the display's own uncounted anchor), the correct display ordinal for +that fixed Sunday is XXIV − 1 = **23, always** — not derived from elapsed weeks at all. +`trinitytideOverrideLabel` now handles this as its own branch (`FIXED_LAST_SUNDAY_ORDINAL`), +same "ask `resolveTemporalId`, don't re-derive" principle as the resumed-Epiphany branch. +Live-verified across 1943/1962/2015/2026/2033 — all five now read "The 23rd Sunday after +Trinity" for their own last Sunday, where before the fix they'd shown four different numbers. +Tests in `tests/calendar/day-label.test.ts` updated accordingly. `npm test` (365 tests) and +`tsc --noEmit` both pass. + ## Known, deliberate simplifications (not bugs — working as designed) - `getDayCollects`: each collect in a multi-collect day renders as its own diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index f8734b0..ae7d346 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -15,6 +15,7 @@ import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './tempo import { addDays, daysBetween, toIsoDate } from './date-math'; import { getTemporalFeastRecord } from './temporal-feasts'; import { resolveActiveOctave, type ActiveOctave } from './octaves'; +import { resolveTemporalId } from './temporal-id'; function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); @@ -109,7 +110,66 @@ function anchorDayName(day: LiturgicalDay): string | undefined { return undefined; } +/** The fixed final Sunday of the liturgical year (temporal-id.ts's + * `post-pentecost-24`) always carries "Sunday XXIV after Pentecost"'s own + * formulary, regardless of how many Sundays actually elapsed since + * Trinity — confirmed by scanning every year 1900-2100, every single one + * lands on this id for its own last Sunday before Advent, never a raw + * elapsed-week count. This app's Trinity-counted display is one week off + * from that Pentecost-counted id (Trinity Sunday itself = post-pentecost- + * 01 = the display's own uncounted anchor day), so the fixed display + * ordinal here is XXIV - 1 = 23, not whatever a plain "weeks since + * Trinity" calculation would produce. */ +const FIXED_LAST_SUNDAY_ORDINAL = 23; + +/** + * Trinitytide's ordinal display can't be pure "weeks since Trinity's own + * first Sunday" arithmetic once the season gets late enough — see + * temporal-id.ts's own post-Pentecost-XXIII-plus branch. Two cases, both + * driven by `resolveTemporalId` directly (the same mechanism the content + * layer already trusts, rather than re-deriving the wdist arithmetic here + * a second time, so the label can never disagree with what's actually + * rendered underneath): + * - A resumed post-Epiphany Sunday/week (an overflow year's skipped + * Epiphany Sundays, reappearing here) — reads "after Epiphany", not + * the next Trinity-counted number in line. + * - The fixed final Sunday/week of the year itself — always the fixed + * 23rd-after-Trinity ordinal (see FIXED_LAST_SUNDAY_ORDINAL above), + * every year, not just overflow ones. + * Only ever fires within trinitytide, since that's the only season + * resolveTemporalId can return either of these ids for a date outside + * their own native season. + */ +function trinitytideOverrideLabel(day: LiturgicalDay): string | undefined { + if (day.season !== 'trinitytide') { + return undefined; + } + const id = resolveTemporalId(day.date); + const weekdayName = capitalize(day.weekday); + + const epiphanyMatch = id.match(/^post-epiphany-(\d)$/); + if (epiphanyMatch) { + const n = Number(epiphanyMatch[1]); + return day.weekday === 'sunday' + ? `The ${ordinal(n)} Sunday after Epiphany` + : `${weekdayName} in the ${ordinal(n)} week after Epiphany`; + } + + if (id === 'post-pentecost-24') { + return day.weekday === 'sunday' + ? `The ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} Sunday after Trinity` + : `${weekdayName} in the ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} week after Trinity`; + } + + return undefined; +} + function temporalLabel(day: LiturgicalDay): string { + const trinitytideOverride = trinitytideOverrideLabel(day); + if (trinitytideOverride) { + return trinitytideOverride; + } + const weekdayName = capitalize(day.weekday); const config = ORDINAL_SEASONS[day.season]; if (!config) { diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 5e07969..602fdd2 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -5,7 +5,8 @@ import { resolveTemporalId } from './temporal-id'; import { getSanctoralCandidatesFor } from './feasts'; import { decideOccurrence, isAtLeast, type OccurrenceResult } from './commemorations'; import { resolveCollision } from './collision'; -import { addDays } from './date-math'; +import { addDays, toIsoDate } from './date-math'; +import { easterSunday } from './easter'; import { activeOctavesFor, strictestThreshold } from './octaves'; /** @@ -104,10 +105,39 @@ export function resolveDay(isoDate: string): LiturgicalDay { winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations); winner = applyChristTheKing(isoDate, winner, commemorations); winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); + applyEpiphany6Commemoration(isoDate, commemorations); return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; } +/** + * Real rubric, confirmed 2026-08: when the 6th Sunday after Epiphany is + * bumped by Septuagesima — i.e. only in years where the 5th Sunday after + * Epiphany was the last one to actually occur — it's commemorated on the + * Saturday immediately before Septuagesima, on top of whatever else that + * Saturday already resolved to. Distinct from (and doesn't imply) the + * later "resumed post-Epiphany Sunday" transfer handled in + * temporal-id.ts's post-Pentecost overflow branch — that one applies to + * *every* skipped post-Epiphany Sunday, not just VI; this Saturday + * commemoration is VI's alone. Purely additive: no `Commemoration` variant + * needed beyond the existing `{ kind: 'temporal' }` one, since + * hours/resolve-common.ts's getDayCollects already resolves any temporal + * commemoration's own `${id}-collect` for free. + */ +function applyEpiphany6Commemoration(isoDate: string, commemorations: Commemoration[]): void { + const year = Number(isoDate.slice(0, 4)); + const septuagesimaStart = addDays(toIsoDate(easterSunday(year)), -63); + const saturdayBeforeSeptuagesima = addDays(septuagesimaStart, -1); + if (isoDate !== saturdayBeforeSeptuagesima) { + return; + } + const lastEpiphanySunday = addDays(septuagesimaStart, -7); + if (resolveTemporalId(lastEpiphanySunday) !== 'post-epiphany-5') { + return; + } + commemorations.push({ kind: 'temporal', id: 'post-epiphany-6' }); +} + /** The Sunday on or before Oct 31 — always lands in October since Oct 31 * is at most 6 days after the month's last Sunday. */ function lastSundayOfOctober(year: number): string { diff --git a/src/calendar/temporal-id.ts b/src/calendar/temporal-id.ts index f656c1a..5b852a0 100644 --- a/src/calendar/temporal-id.ts +++ b/src/calendar/temporal-id.ts @@ -11,16 +11,23 @@ // header and propers/index.ts's getTemporalProper for the fuller version // of this reasoning. // -// Two known, deliberately unfixed gaps: the ferias between Christmas Day +// One known, deliberately unfixed gap: the ferias between Christmas Day // and the Sunday within its octave, and between Epiphany and its own // first Sunday, fall back to that season's first named Sunday a few days // early — Christmas Day's and Epiphany's own collects aren't authored as // separate temporal-propers entries (only Sunday collects were pulled), so -// this is a deliberate approximation, not an oversight. Likewise, a real -// overflow year (early Easter, more than 24 weeks between Trinity and -// Advent) would traditionally reuse the unused post-Epiphany Sundays' -// collects for the excess weeks — not modeled; this just clamps at -// post-pentecost-24. +// this is a deliberate approximation, not an oversight. +// +// Overflow years (Easter early enough that fewer than 6 Sundays after +// Epiphany occur before Septuagesima cuts in) are handled below: the +// skipped post-Epiphany Sundays "resume" after Sunday XXIII after +// Pentecost, in their own numeric order, right before the fixed final +// Sunday of the year (always Sunday XXIV's own formulary, regardless of +// how many Sundays actually elapsed since Pentecost). Ported directly +// from the reference engine's own `getweek()` +// (DivinumOfficium/Date.pm) — same rubric, same arithmetic (weeks-until- +// Advent, not a separately-tracked count of skipped Epiphany Sundays); +// confirmed against that source rather than re-derived by hand. import { resolveSeason, sundayOnOrBefore, firstSundayStrictlyAfter, adventStart, easterOffsetOf } from './temporal'; import { daysBetween } from './date-math'; @@ -43,12 +50,29 @@ const EASTER_OFFSET_IDS: [number, string][] = [ [42, 'sunday-after-ascension'], [49, 'pentecost-sunday'], ]; -for (let n = 1; n <= 24; n++) { +// Sundays I-XXII after Pentecost only — XXIII and XXIV are handled by +// resolvePostPentecost23Plus below, since (unlike I-XXII) which id a given +// week gets can depend on distance-to-Advent, not just its own offset. +for (let n = 1; n <= 22; n++) { EASTER_OFFSET_IDS.push([56 + 7 * (n - 1), `post-pentecost-${String(n).padStart(2, '0')}`]); } const EASTER_OFFSET_ID_MAP = new Map(EASTER_OFFSET_IDS); -const MAX_EASTER_OFFSET = EASTER_OFFSET_IDS[EASTER_OFFSET_IDS.length - 1]![0]; -const MAX_EASTER_OFFSET_ID = EASTER_OFFSET_IDS[EASTER_OFFSET_IDS.length - 1]![1]; + +/** + * A governing Sunday at or beyond Pentecost week XXIII. `pentecostWeek` is + * the reference engine's own `$n` (1 = Trinity Sunday, verified against + * DivinumOfficium/Date.pm and its Tempora/PentNN file naming). + */ +function resolvePostPentecost23Plus(governingSunday: string, year: number, pentecostWeek: number): string { + const wdist = Math.floor((daysBetween(governingSunday, adventStart(year)) + 6) / 7); + if (wdist < 2) { + return 'post-pentecost-24'; + } + if (pentecostWeek === 23) { + return 'post-pentecost-23'; + } + return `post-epiphany-${8 - wdist}`; +} export function resolveTemporalId(isoDate: string): string { const season = resolveSeason(isoDate); @@ -78,9 +102,11 @@ export function resolveTemporalId(isoDate: string): string { // regardless of which `season` bucket the feria itself falls in (the // ferias right after Ash Wednesday genuinely reuse Quinquagesima's // collect, crossing what resolveSeason calls two different seasons). - const offset = easterOffsetOf(sundayOnOrBefore(isoDate)); - if (offset > MAX_EASTER_OFFSET) { - return MAX_EASTER_OFFSET_ID; + const governingSunday = sundayOnOrBefore(isoDate); + const offset = easterOffsetOf(governingSunday); + const pentecostWeek = Math.round((offset - 49) / 7); + if (pentecostWeek >= 23) { + return resolvePostPentecost23Plus(governingSunday, year, pentecostWeek); } return EASTER_OFFSET_ID_MAP.get(offset) ?? 'septuagesima'; } diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 2649b55..689a353 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -44,6 +44,29 @@ describe('getDayLabel — ordinal temporal label', () => { }); }); +describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => { + it('labels a resumed Sunday/weekday as "after Epiphany", not the next Trinity-counted number', () => { + // 2026: post-epiphany-5 is Nov 8, post-epiphany-6 is Nov 15 (both + // Sundays) -- see calendar/temporal-id.test.ts for the id-level + // coverage this label check builds on. + expect(getDayLabel(resolveDay('2026-11-08'))).toBe('The 5th Sunday after Epiphany'); + expect(getDayLabel(resolveDay('2026-11-09'))).toBe('Monday in the 5th week after Epiphany'); + expect(getDayLabel(resolveDay('2026-11-15'))).toBe('The 6th Sunday after Epiphany'); + expect(getDayLabel(resolveDay('2026-11-16'))).toBe('Monday in the 6th week after Epiphany'); + }); + + it('the fixed final Sunday of the year always shows the fixed 23rd-after-Trinity ordinal, not a raw elapsed-week count', () => { + // 2026-11-22 is the fixed post-pentecost-24 Sunday (Sunday XXIV after + // Pentecost's own fixed formulary; this app's Trinity-counted display + // is one week off from that Pentecost-counted id, so XXIV - 1 = 23). + // Every year 1900-2100 lands on this same id for its own last Sunday + // before Advent, so this ordinal is fixed, not overflow-year-specific + // -- confirmed against a non-overflow year (1943) too. + expect(getDayLabel(resolveDay('2026-11-22'))).toBe('The 23rd Sunday after Trinity'); + expect(getDayLabel(resolveDay('1943-11-21'))).toBe('The 23rd Sunday after Trinity'); + }); +}); + describe('getDayLabel — active octave', () => { it('shows the octave day itself as the primary label, plus a lesser saint commemorated alongside it', () => { // St. Lawrence's own octave (Aug 10-17); Aug 12 is day 3, and also St. diff --git a/tests/calendar/temporal-id.test.ts b/tests/calendar/temporal-id.test.ts new file mode 100644 index 0000000..7164fe3 --- /dev/null +++ b/tests/calendar/temporal-id.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTemporalId } from '../../src/calendar/temporal-id'; +import { resolveDay } from '../../src/calendar'; + +describe('resolveTemporalId — post-Pentecost overflow', () => { + // 2026: Easter 2026-04-05 -> only post-epiphany-1..5 occur before + // Septuagesima, so post-pentecost-24 through 27 (5 real weeks between + // XXIII and the last Sunday) resume as Epiphany V and VI before landing + // on the fixed final Sunday. Confirmed against the reference engine's + // own DivinumOfficium/Date.pm getweek() (Pent/Epi arithmetic), not just + // hand-derived. + it('resumes the skipped post-Epiphany Sundays in order before the fixed last Sunday', () => { + expect(resolveTemporalId('2026-11-07')).toBe('post-pentecost-23'); + expect(resolveTemporalId('2026-11-14')).toBe('post-epiphany-5'); + expect(resolveTemporalId('2026-11-21')).toBe('post-epiphany-6'); + expect(resolveTemporalId('2026-11-28')).toBe('post-pentecost-24'); + }); + + it('does not resume anything in a non-overflow year (all 6 post-Epiphany Sundays occurred)', () => { + // 1943: Easter 1943-04-25, latest possible-ish Easter -> all 6 + // post-Epiphany Sundays occur, so post-pentecost climbs past 24 + // without ever touching Epiphany content, capping at the fixed id. + expect(resolveTemporalId('1943-11-21')).toBe('post-pentecost-24'); + }); +}); + +describe('post-Epiphany VI commemoration on the Saturday before Septuagesima', () => { + it('fires only when Epiphany V was the last Sunday to occur (2022, 2025)', () => { + const day2022 = resolveDay('2022-02-12'); + expect(day2022.commemorations).toContainEqual({ kind: 'temporal', id: 'post-epiphany-6' }); + + const day2025 = resolveDay('2025-02-15'); + expect(day2025.commemorations).toContainEqual({ kind: 'temporal', id: 'post-epiphany-6' }); + }); + + it('does not fire when all 6 post-Epiphany Sundays occurred (1943)', () => { + const day = resolveDay('1943-02-20'); + expect(day.commemorations).not.toContainEqual({ kind: 'temporal', id: 'post-epiphany-6' }); + }); +});