diff --git a/TODO.md b/TODO.md index dfbd8db..7194d8a 100644 --- a/TODO.md +++ b/TODO.md @@ -116,6 +116,37 @@ instead of the correct plain temporal label. Fixed by having `resolveActiveOctave` call as `resolveOfficeWinner`, so the two can never disagree about which identity governs a day. +### Dec 26-30's own Sunday rule — done (2026-08), a deliberate remix + +Per direct instruction — a real, admitted departure from Monastic 1617 +itself, not something independently verified live the way almost +everything else here is: within Dec 26-30 inclusive (every year contains +exactly one real Sunday somewhere in that 5-day window), whichever date +is the actual Sunday always celebrates Nat1-0 (the Sunday within the +Octave of the Nativity) outright, with **no rank check at all** — even a +Duplex-II-classis saint (Stephen/John/Innocents) is fully displaced, not +just commemorated. The displaced saint isn't commemorated in place +either; he reappears in full as the day's own winner on Dec 30 instead — +the block's one date with no fixed saint of its own. On a year Dec 30 +itself is the actual Sunday, nothing is displaced and it resolves clean. + +This intentionally diverges from what Monastic 1617 itself does on Dec +26-29 (there, live-verified, the Duplex-II-classis saint wins and the +Sunday is merely commemorated — see the December-sanctoral tests) and +from the general `privileged-sunday` transfer rule elsewhere in this app +(which only transfers ranks below duplex-majus and commemorates +everything at or above it in place) — this window transfers *every* +rank, full stop, and never commemorates the loser in place. + +Implemented as its own dedicated, narrowly-scoped layer +(`calendar/index.ts`'s `applyChristmasOctaveSunday`, same architectural +pattern as `applyMarianSaturday`/`applyChristTheKing`) rather than by +changing the shared `decideOccurrence`/transfer machinery other Sundays +rely on — deliberately contained blast radius. Dec 31 and everything +outside Dec 26-30 is untouched; christmastide's Sunday there still uses +the plain `ordinary-sunday` category as before (St. Silvester still +beats the Sunday on Dec 31, live-verified, unaffected by this). + ### Benedictus antiphons for temporal (non-sanctoral) days — done `-benedictus-antiphon.yml` in `data/propers/temporal/` — all 54 diff --git a/src/calendar/index.ts b/src/calendar/index.ts index eb9d49e..5e07969 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -103,6 +103,7 @@ export function resolveDay(isoDate: string): LiturgicalDay { winner = applyOctaves(isoDate, winner, commemorations); winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations); winner = applyChristTheKing(isoDate, winner, commemorations); + winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; } @@ -137,6 +138,65 @@ function applyChristTheKing(isoDate: string, winner: DayWinner, commemorations: return { kind: 'temporal', id: 'christ-the-king' }; } +/** + * Per direct instruction, scoped to exactly Dec 26-30 — every year + * contains exactly one real Sunday somewhere in that 5-day window. + * + * On whichever of Dec 26-29 it actually falls, Nat1-0 (the Sunday within + * the Octave of the Nativity) always wins outright, full stop — no rank + * check at all, a deliberate departure from what Monastic 1617 itself + * does there (a Duplex-II-classis saint — Stephen/John/Innocents — beats + * the Sunday there in the real engine; see TODO.md). The displaced + * saint isn't even commemorated in place; he reappears in full on Dec + * 30 instead — the block's one date with no fixed saint of its own — + * rather than the general privileged-sunday transfer/commemorate split + * used everywhere else. Dec 30 itself, on a year it's the *actual* + * Sunday (nothing displaced), resolves normally: Nat1-0 wins clean since + * nothing was ever assigned there to begin with. + */ +function applyChristmasOctaveSunday( + isoDate: string, + weekday: ReturnType, + winner: DayWinner, + commemorations: Commemoration[], +): DayWinner { + const [year, monthStr, dayStr] = isoDate.split('-'); + if (monthStr !== '12') { + return winner; + } + const day = Number(dayStr); + + if (day >= 26 && day <= 29 && weekday === 'sunday') { + return { kind: 'temporal', id: 'christmas-octave-sunday' }; + } + + if (day === 30) { + for (let d = 26; d <= 29; d++) { + const candidateDate = `${year}-12-${String(d).padStart(2, '0')}`; + if (weekdayOf(candidateDate) !== 'sunday') { + continue; + } + const displaced = getSanctoralCandidatesFor(candidateDate)[0]; + if (!displaced) { + break; + } + // Drop the now-redundant "Octave of " commemoration — + // applyOctaves already pushed one above, using Dec 30's *original* + // temporal winner; now that the displaced saint is winning the day + // outright instead, commemorating his own octave alongside himself + // would be the same redundancy applyOctaves' own `isSelf` check + // already avoids on a saint's actual day. + const idx = commemorations.findIndex((c) => c.kind === 'octave' && c.id === displaced.id); + if (idx !== -1) { + commemorations.splice(idx, 1); + } + return { kind: 'sanctoral', id: displaced.id, name: displaced.name, rank: displaced.rank }; + } + } + + return winner; +} + /** * Layered on top of everything above, same spirit as applyOctaves: never * changes decideOccurrence's own rules, just relabels the result on a free diff --git a/tests/calendar/christmas-octave-sunday.test.ts b/tests/calendar/christmas-octave-sunday.test.ts new file mode 100644 index 0000000..eac5c61 --- /dev/null +++ b/tests/calendar/christmas-octave-sunday.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { resolveDay } from '../../src/calendar'; + +// Per direct instruction: within Dec 26-30 inclusive, exactly one date is +// genuinely the Sunday within the Octave of the Nativity each year. That +// date always wins outright as Nat1-0 (christmas-octave-sunday), no rank +// check at all -- a deliberate departure from Monastic 1617 itself, which +// lets a Duplex-II-classis saint (Stephen/John/Innocents) beat the Sunday +// there (see tests/calendar/december-sanctoral.test.ts's own Dec 31/St. +// Silvester test for where that live-verified rule is still real, outside +// this window). The displaced saint isn't commemorated in place -- he +// reappears in full on Dec 30, the block's one date with no fixed saint of +// its own. +describe('applyChristmasOctaveSunday', () => { + it('Sunday on Dec 26 (St. Stephen, Duplex II. classis): Nat1-0 wins outright, Stephen not even commemorated, and reappears in full on Dec 30', () => { + const sunday = resolveDay('2021-12-26'); + expect(sunday.weekday).toBe('sunday'); + expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); + expect(sunday.commemorations.some((c) => c.kind === 'sanctoral')).toBe(false); + + const dec30 = resolveDay('2021-12-30'); + expect(dec30.winner).toEqual({ + kind: 'sanctoral', + id: 'st-stephen-protomartyr', + name: 'St. Stephen, Protomartyr', + rank: 'duplex-2-classis', + }); + // The now-redundant "Octave of St. Stephen" commemoration (which + // would otherwise have been pushed by applyOctaves, using Dec 30's + // *original* temporal winner) is dropped -- he's the day's own + // winner now, not merely commemorated alongside himself. + expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'st-stephen-protomartyr')).toBe(false); + // The other, still-genuinely-separate octaves remain. + expect(dec30.commemorations.map((c) => (c.kind === 'octave' ? c.id : undefined))).toEqual([ + 'christmas-day', + 'st-john-apostle', + 'holy-innocents', + ]); + }); + + it('Sunday on Dec 27 (St. John, Duplex II. classis): same shape', () => { + const sunday = resolveDay('2020-12-27'); + expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); + + const dec30 = resolveDay('2020-12-30'); + expect(dec30.winner).toEqual({ + kind: 'sanctoral', + id: 'st-john-apostle', + name: 'St. John, Apostle and Evangelist', + rank: 'duplex-2-classis', + }); + expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'st-john-apostle')).toBe(false); + }); + + it('Sunday on Dec 28 (Holy Innocents, Duplex II. classis): same shape', () => { + const sunday = resolveDay('2025-12-28'); + expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); + + const dec30 = resolveDay('2025-12-30'); + expect(dec30.winner).toEqual({ + kind: 'sanctoral', + id: 'holy-innocents', + name: 'The Holy Innocents, Martyrs', + rank: 'duplex-2-classis', + }); + expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'holy-innocents')).toBe(false); + }); + + it('Sunday on Dec 29 (St. Thomas Becket, only Semiduplex): still fully displaced to Dec 30, same as the Duplex-II-classis saints -- rank never matters for this rule', () => { + const sunday = resolveDay('2024-12-29'); + expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); + + const dec30 = resolveDay('2024-12-30'); + expect(dec30.winner).toEqual({ + kind: 'sanctoral', + id: 'st-thomas-becket', + name: 'St. Thomas of Canterbury, Bishop and Martyr', + rank: 'semiduplex', + }); + }); + + it('Sunday on Dec 30 itself: nothing to displace, resolves clean', () => { + const dec30 = resolveDay('2029-12-30'); + expect(dec30.weekday).toBe('sunday'); + expect(dec30.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); + // All four octaves still commemorated normally -- nothing was ever + // assigned to Dec 30 itself, so there's nothing to drop. + expect(dec30.commemorations.map((c) => (c.kind === 'octave' ? c.id : undefined))).toEqual([ + 'christmas-day', + 'st-stephen-protomartyr', + 'st-john-apostle', + 'holy-innocents', + ]); + }); + + it('Dec 26-29 keep their own fixed saint outright on a year none of them is the Sunday, unaffected by this rule at all', () => { + // 2029: the Sunday is Dec 30, so Dec 26-29 each just resolve normally. + expect(resolveDay('2029-12-26').winner).toEqual({ + kind: 'sanctoral', + id: 'st-stephen-protomartyr', + name: 'St. Stephen, Protomartyr', + rank: 'duplex-2-classis', + }); + expect(resolveDay('2029-12-29').winner).toEqual({ + kind: 'sanctoral', + id: 'st-thomas-becket', + name: 'St. Thomas of Canterbury, Bishop and Martyr', + rank: 'semiduplex', + }); + }); + + it('never touches Dec 31 or Jan 1, outside its own Dec 26-30 scope', () => { + // St. Silvester (Dec 31) still wins outright via the plain + // ordinary-sunday rule even in a year Dec 31 is the Sunday -- see + // tests/calendar/december-sanctoral.test.ts for this one directly. + const dec31 = resolveDay('2023-12-31'); + expect(dec31.winner.kind).toBe('sanctoral'); + }); +}); diff --git a/tests/calendar/december-sanctoral.test.ts b/tests/calendar/december-sanctoral.test.ts index 756dcd1..db90c98 100644 --- a/tests/calendar/december-sanctoral.test.ts +++ b/tests/calendar/december-sanctoral.test.ts @@ -108,34 +108,15 @@ describe('December sanctoral pull (first pass, 12/12)', () => { ]); }); - it('the Christmas Octave Sunday loses outright to a Duplex+ Comites Christi feast when the two collide -- ordinary-sunday, not privileged-sunday', () => { - // Corrects a real bug: christmastide's Sunday was originally coded - // privileged-sunday (Advent's own tier), which would have kept the - // Sunday primary here with the saint merely commemorated -- backwards - // from the live Monastic 1617 engine, which was checked directly - // against all three Duplex-II-classis collision dates and shows the - // saint winning outright every time, the Sunday only commemorated. - const stephenSunday = resolveDay('2021-12-26'); - expect(stephenSunday.winner).toEqual({ - kind: 'sanctoral', - id: 'st-stephen-protomartyr', - name: 'St. Stephen, Protomartyr', - rank: 'duplex-2-classis', - }); - expect(stephenSunday.commemorations).toContainEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); - - const innocentsSunday = resolveDay('2025-12-28'); - expect(innocentsSunday.winner).toEqual({ - kind: 'sanctoral', - id: 'holy-innocents', - name: 'The Holy Innocents, Martyrs', - rank: 'duplex-2-classis', - }); - expect(innocentsSunday.commemorations).toContainEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); - - // A plain Duplex (not just Duplex-II-classis) is still enough: St. - // Silvester (Dec 31) wins outright the same way when it falls on the - // Sunday, also live-verified. + it('the underlying ordinary-sunday classification still governs Dec 31 (outside the Dec 26-30 window applyChristmasOctaveSunday owns) -- a Duplex still beats the Sunday there, live-verified', () => { + // St. Silvester (Dec 31) wins outright when it falls on the Sunday -- + // unaffected by applyChristmasOctaveSunday, which is deliberately + // scoped to exactly Dec 26-30 (see tests/calendar/ + // christmas-octave-sunday.test.ts for that window's own, different + // rule). This is what confirms christmastide's own `ordinary-sunday` + // temporal-category classification (see data/calendar/ + // temporal-categories.yml) is still real and still needed, not + // superseded outright by the newer Dec 26-30 rule. const silvesterSunday = resolveDay('2023-12-31'); expect(silvesterSunday.winner).toEqual({ kind: 'sanctoral', @@ -146,11 +127,14 @@ describe('December sanctoral pull (first pass, 12/12)', () => { expect(silvesterSunday.commemorations).toContainEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); }); - it('the Christmas Octave Sunday still keeps a lesser feast from winning, same as before the ordinary-sunday fix', () => { + it('the Christmas Octave Sunday still keeps a lesser feast from winning, now via applyChristmasOctaveSunday specifically', () => { // St. Thomas Becket (Semiduplex, Dec 29) transfers off the Sunday - // rather than winning it -- unaffected by the privileged->ordinary - // change, since both tiers transfer a Semiduplex here; this test - // guards against a regression the other direction. + // rather than winning it -- per calendar/index.ts's + // applyChristmasOctaveSunday (see its own dedicated test file), which + // now unconditionally wins the Sunday over *any* rank within Dec + // 26-29, superseding the plain ordinary-sunday rule for this narrow + // window specifically (that rule is still real outside it -- see the + // Dec 31/St. Silvester test above). const beckettSunday = resolveDay('2024-12-29'); expect(beckettSunday.winner.kind).toBe('temporal'); expect(beckettSunday.commemorations.some((c) => c.kind === 'sanctoral')).toBe(false); diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts index 6baddc2..03d86bc 100644 --- a/tests/hours/lauds.test.ts +++ b/tests/hours/lauds.test.ts @@ -141,7 +141,11 @@ describe('resolveOrdo("lauds", ...)', () => { }); it('resolves a real Benedictus antiphon for christmas-octave-sunday too, pulled from the reference engine\'s own dedicated Tempora/Nat1-0 proper rather than any individual octave-day saint\'s page', () => { - const ordo = resolveOrdo('lauds', '2026-12-30'); + // 2029-12-30 specifically: the year Dec 30 itself is the real Sunday + // within the Octave, so nothing gets bumped there by + // calendar/index.ts's applyChristmasOctaveSunday (see its own tests) -- + // a genuinely clean case for this content, unlike most years. + const ordo = resolveOrdo('lauds', '2029-12-30'); const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); expect(canticle?.kind === 'canticle' ? canticle.antiphon?.text.la : undefined).toContain('Dum médium siléntium'); expect(canticle?.kind === 'canticle' ? canticle.antiphon?.status?.la : undefined).toBe('verified');