diff --git a/src/calendar/feasts.ts b/src/calendar/feasts.ts index 6aec46a..ce57ce0 100644 --- a/src/calendar/feasts.ts +++ b/src/calendar/feasts.ts @@ -9,7 +9,7 @@ // practice has its own precedence/commemoration rules for that too, not // modeled here. If a day has more than one candidate, calendar/index.ts // just picks the highest-ranked as the day's sole sanctoral contender. -import type { FeastClass } from './types'; +import type { FeastClass, OctaveConfig } from './types'; import sanctoralCalendarData from '../data/calendar/sanctoral-calendar.yml'; export interface SaintRecord { @@ -18,6 +18,9 @@ export interface SaintRecord { rank: FeastClass; common: string; propers: string | null; + /** See calendar/types.ts's OctaveConfig doc comment. Absent for the vast + * majority of saints — only ones with a real octave declare it. */ + octave?: OctaveConfig; } const sanctoralCalendar = sanctoralCalendarData as { days: Record }; diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 126b883..33cd6a6 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -3,9 +3,10 @@ import { weekdayOf } from './weekday'; import { resolveSeason, resolveTemporalCategory } from './temporal'; import { resolveTemporalId } from './temporal-id'; import { getSanctoralCandidatesFor } from './feasts'; -import { decideOccurrence, type OccurrenceResult } from './commemorations'; +import { decideOccurrence, isAtLeast, type OccurrenceResult } from './commemorations'; import { resolveCollision } from './collision'; import { addDays } from './date-math'; +import { activeOctavesFor, strictestThreshold } from './octaves'; /** * A day's occurrence considered on its own — no awareness of what an @@ -99,9 +100,50 @@ export function resolveDay(isoDate: string): LiturgicalDay { winner = applyIncomingTransfer(tomorrow.result.transfer.candidate, temporalCategory, winner, commemorations); } + winner = applyOctaves(isoDate, winner, commemorations); + return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; } +/** + * Layered on top of everything above, not part of it: an octave doesn't + * change how a single day's own precedence contest is decided, it just + * (a) adds a commemoration for every octave still active on this date, and + * (b) occasionally overrides the winner when the occurring saint is too + * minor to clear the strictest active octave's threshold, in which case + * the day reverts to its own temporal identity and the saint is + * commemorated instead — same "demoted, not dropped" shape as every other + * commemoration rule in this file. + */ +function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner { + const octaves = activeOctavesFor(isoDate); + if (octaves.length === 0) { + return winner; + } + + let resolvedWinner = winner; + if (winner.kind === 'sanctoral' && !isAtLeast(winner.rank, strictestThreshold(octaves))) { + const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id); + if (!isOneOfTheseOctaves) { + commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank }); + resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) }; + } + } + + for (const octave of octaves) { + const isSelf = resolvedWinner.kind === 'sanctoral' && resolvedWinner.id === octave.id; + // An octave's own day-1, when nothing displaced it, already *is* that + // feast (via the day's own temporal identity) — commemorating it + // again alongside itself would be redundant. + const isOwnStartDay = octave.dayNumber === 1 && resolvedWinner.kind === 'temporal'; + if (!isSelf && !isOwnStartDay) { + commemorations.push({ kind: 'octave', id: octave.id, name: octave.name }); + } + } + + return resolvedWinner; +} + /** * The Sunday/feast-vs-ferial split that several hours' propers key off of * (Prime's capitulum and Preces, so far): a plain weekday with nothing else @@ -115,6 +157,8 @@ export function isSundayOrFeast(day: LiturgicalDay): boolean { export { compareFeastClass, isAtLeast, decideOccurrence } from './commemorations'; export { resolveCollision } from './collision'; export { resolveTemporalId } from './temporal-id'; +export { activeOctavesFor, strictestThreshold } from './octaves'; +export type { ActiveOctave } from './octaves'; export type { LiturgicalDay, DayWinner, @@ -124,4 +168,5 @@ export type { Weekday, FeastClass, TemporalCategory, + OctaveConfig, } from './types'; diff --git a/src/calendar/octaves.ts b/src/calendar/octaves.ts new file mode 100644 index 0000000..4f8f248 --- /dev/null +++ b/src/calendar/octaves.ts @@ -0,0 +1,91 @@ +// The "generic way to commemorate an octave" — a lookback over the past +// week (a feast's own day plus up to 7 more) collecting every octave +// still active on a given date, from *both* sanctoral saints +// (calendar/feasts.ts) and temporal feasts (calendar/temporal-feasts.ts). +// Deliberately a post-processing layer over calendar/index.ts's existing +// occurrence/transfer pipeline, not a change to calendar/commemorations.ts +// itself — an octave doesn't change *how* a single day's precedence +// contest is decided, it just adds commemorations on top of whatever that +// contest already produced, and occasionally overrides the winner when a +// too-minor saint would otherwise have taken the day from it. +import type { FeastClass, OctaveConfig } from './types'; +import { getSanctoralCandidatesFor, getSaintRecord } from './feasts'; +import { getTemporalFeastRecord, temporalFeastIdsStartingOn } from './temporal-feasts'; +import { addDays, daysBetween } from './date-math'; +import { compareFeastClass } from './commemorations'; + +export interface ActiveOctave { + id: string; + name: string; + /** Rank threshold below which the occurring saint loses the day to this octave. */ + wins: FeastClass; + /** 1 on the feast's own day, counting up from there. */ + dayNumber: number; +} + +const DEFAULT_DAYS = 8; +const DEFAULT_WINS: FeastClass = 'semiduplex'; +/** How far back to look for an octave's own start date — must cover the + * longest configured `days` a caller might use; 7 covers the standard + * 8-day octave (day 1 = the start itself, day 8 = 7 days later). */ +const LOOKBACK_DAYS = 7; + +function considerCandidate( + active: ActiveOctave[], + seen: Set, + isoDate: string, + startDate: string, + id: string, + name: string, + octave: OctaveConfig | undefined, +): void { + if (!octave?.enabled || seen.has(id)) { + return; + } + const days = octave.days ?? DEFAULT_DAYS; + const offset = daysBetween(startDate, isoDate); + if (offset < 0 || offset >= days) { + return; + } + seen.add(id); + active.push({ id, name, wins: octave.wins ?? DEFAULT_WINS, dayNumber: offset + 1 }); +} + +/** Every octave (sanctoral or temporal) whose window covers `isoDate`, + * oldest-started first (so a stack like Christmas/Stephen/John/Innocents + * reads in the order each one actually began, matching how they'd be + * listed at Matins/Lauds/Vespers). */ +export function activeOctavesFor(isoDate: string): ActiveOctave[] { + const active: ActiveOctave[] = []; + const seen = new Set(); + + for (let back = LOOKBACK_DAYS; back >= 0; back--) { + const candidateDate = addDays(isoDate, -back); + + for (const candidate of getSanctoralCandidatesFor(candidateDate)) { + const record = getSaintRecord(candidate.id); + if (record) { + considerCandidate(active, seen, isoDate, candidateDate, record.id, record.name, record.octave); + } + } + + for (const feastId of temporalFeastIdsStartingOn(candidateDate)) { + const record = getTemporalFeastRecord(feastId); + if (record) { + considerCandidate(active, seen, isoDate, candidateDate, record.id, record.name, record.octave); + } + } + } + + return active; +} + +/** The strictest (highest) `wins` threshold among a set of active octaves — + * what an occurring saint needs to clear to keep the day against all of + * them at once. */ +export function strictestThreshold(octaves: ActiveOctave[]): FeastClass { + return octaves.reduce( + (max, o) => (compareFeastClass(o.wins, max) > 0 ? o.wins : max), + octaves[0]?.wins ?? DEFAULT_WINS, + ); +} diff --git a/src/calendar/temporal-feasts.ts b/src/calendar/temporal-feasts.ts new file mode 100644 index 0000000..61043fc --- /dev/null +++ b/src/calendar/temporal-feasts.ts @@ -0,0 +1,51 @@ +// The temporal-cycle sibling of calendar/feasts.ts's SaintRecord: a small +// metadata record for a *temporal* id (Christmas, Pentecost, ...) that +// needs to carry something beyond its collect text — so far, just whether +// it has an octave (calendar/types.ts's OctaveConfig). Most temporal ids +// don't need a record at all (they're just collect-text lookups via +// propers/index.ts's getTemporalProper); this only exists for the ones +// that do. +import type { OctaveConfig } from './types'; +import { easterOffsetOf } from './temporal'; + +export interface TemporalFeastRecord { + id: string; + name: string; + octave?: OctaveConfig; +} + +const temporalFeastModules = import.meta.glob<{ default: TemporalFeastRecord }>( + '../data/calendar/temporal-feasts/*.yml', + { eager: true }, +); + +const temporalFeastsById = new Map(); +for (const mod of Object.values(temporalFeastModules)) { + temporalFeastsById.set(mod.default.id, mod.default); +} + +export function getTemporalFeastRecord(id: string): TemporalFeastRecord | undefined { + return temporalFeastsById.get(id); +} + +/** Fixed-calendar-date starts (MM-DD -> temporal feast id). Only Christmas + * so far; Epiphany/Candlemas would join here if they ever needed an + * octave modeled too. */ +const FIXED_DATE_STARTS: [string, string][] = [['12-25', 'christmas-day']]; + +/** Easter-offset starts (offset -> temporal feast id). */ +const EASTER_OFFSET_STARTS: [number, string][] = [[49, 'pentecost-sunday']]; + +/** Which temporal feast(s), if any, have their own (day-1) octave start on this date. */ +export function temporalFeastIdsStartingOn(isoDate: string): string[] { + const monthDay = isoDate.slice(5); + const ids: string[] = []; + for (const [md, id] of FIXED_DATE_STARTS) { + if (md === monthDay) ids.push(id); + } + const offset = easterOffsetOf(isoDate); + for (const [off, id] of EASTER_OFFSET_STARTS) { + if (off === offset) ids.push(id); + } + return ids; +} diff --git a/src/calendar/types.ts b/src/calendar/types.ts index 0da33dd..f9efad2 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -106,6 +106,34 @@ export interface SanctoralIdentity { rank: FeastClass; } +/** + * Attached to a saint's own record (calendar/feasts.ts's SaintRecord) or a + * temporal feast's (calendar/temporal-feasts.ts's TemporalFeastRecord) to + * declare that it carries an octave — commemorated daily for `days` days + * after its own feast, layered on top of whatever normally wins each of + * those days. `enabled: false` (or the field simply absent) means no + * octave; `{ enabled: true }` alone is valid and uses every default below, + * satisfying the "turned on but no octave data authored yet" case — + * calendar/octaves.ts is the "generic way to commemorate an octave" that + * makes that minimal declaration meaningful on its own. + */ +export interface OctaveConfig { + enabled: boolean; + /** Length of the octave in days, inclusive of the feast's own day. Default 8. */ + days?: number; + /** Rank threshold: an occurring saint at this rank or higher keeps the + * day for itself (the octave is merely commemorated back); below it, the + * octave wins the day instead and the saint is commemorated. Default + * 'semiduplex' — i.e. only a Simplex loses to the octave — matching + * every octave checked so far except Pentecost's (see + * data/calendar/temporal-feasts/pentecost-sunday.yml), which is + * stricter (`duplex`). */ + wins?: FeastClass; + /** Matins reading proper id, if one has been sourced. Absent by default + * — most octaves checked so far have none. */ + readingId?: string; +} + export type DayWinner = | { kind: 'temporal'; id: string } | ({ @@ -119,12 +147,15 @@ export type DayWinner = /** * A day can have more than one of these at once (a transferred feast can * displace a native saint who then also gets commemorated, alongside the - * Sunday whose own occurrence pushed the transfer in the first place) — - * hence a list, not a single flag. Extensible on purpose: a future - * `{ kind: 'octave'; id: string }` variant joins this union once octaves - * are modeled, without changing the shape callers already rely on. + * Sunday whose own occurrence pushed the transfer in the first place, or — + * since octaves were modeled — several overlapping octaves stacking on one + * date, e.g. Christmas + St. Stephen + St. John all commemorated together + * within the Christmas Octave) — hence a list, not a single flag. */ -export type Commemoration = { kind: 'temporal'; id: string } | ({ kind: 'sanctoral' } & SanctoralIdentity); +export type Commemoration = + | { kind: 'temporal'; id: string } + | ({ kind: 'sanctoral' } & SanctoralIdentity) + | { kind: 'octave'; id: string; name: string }; export interface LiturgicalDay { /** ISO date, e.g. "2026-08-09" */ diff --git a/src/data/calendar/saints/holy-innocents.yml b/src/data/calendar/saints/holy-innocents.yml index 81bf371..d2aa513 100644 --- a/src/data/calendar/saints/holy-innocents.yml +++ b/src/data/calendar/saints/holy-innocents.yml @@ -9,3 +9,7 @@ name: "The Holy Innocents, Martyrs" rank: duplex-2-classis common: common-of-several-martyrs propers: "holy-innocents" +# Has its own real octave, one of the three "Comites Christi" (with St. +# Stephen and St. John) -- see st-stephen-protomartyr.yml. +octave: + enabled: true diff --git a/src/data/calendar/saints/st-john-apostle.yml b/src/data/calendar/saints/st-john-apostle.yml index f8c56c3..13ad487 100644 --- a/src/data/calendar/saints/st-john-apostle.yml +++ b/src/data/calendar/saints/st-john-apostle.yml @@ -7,3 +7,7 @@ name: "St. John, Apostle and Evangelist" rank: duplex-2-classis common: common-of-an-evangelist propers: "st-john-apostle" +# Has his own real octave, one of the three "Comites Christi" (with St. +# Stephen and the Holy Innocents) -- see st-stephen-protomartyr.yml. +octave: + enabled: true diff --git a/src/data/calendar/saints/st-lawrence.yml b/src/data/calendar/saints/st-lawrence.yml index 3b6d80c..6a49d4a 100644 --- a/src/data/calendar/saints/st-lawrence.yml +++ b/src/data/calendar/saints/st-lawrence.yml @@ -9,3 +9,12 @@ name: "St. Lawrence, Martyr" rank: duplex-2-classis common: common-of-a-martyr propers: "st-lawrence" +# Has a real octave (Aug 10-17) -- see calendar/types.ts's OctaveConfig +# and calendar/octaves.ts. `wins` left at the default (semiduplex, i.e. +# only a Simplex loses to it) pending its own direct verification; this +# is also the concrete octave that repeatedly cost real saints their spot +# during the August pull (Ss. Tiburtius & Susanna, St. Clare, Ss. +# Hippolytus & Cassian) before this mechanism existed to commemorate them +# instead of just excluding them outright. +octave: + enabled: true diff --git a/src/data/calendar/saints/st-stephen-protomartyr.yml b/src/data/calendar/saints/st-stephen-protomartyr.yml index 2138709..72c3fcc 100644 --- a/src/data/calendar/saints/st-stephen-protomartyr.yml +++ b/src/data/calendar/saints/st-stephen-protomartyr.yml @@ -10,3 +10,10 @@ name: "St. Stephen, Protomartyr" rank: duplex-2-classis common: common-of-a-martyr propers: "st-stephen-protomartyr" +# Has his own real octave, stacking within Christmas's (see +# christmas-day.yml and calendar/octaves.ts) -- one of the three +# "Comites Christi" (with St. John and the Holy Innocents) that do; St. +# Thomas of Canterbury, also within the Christmas Octave, deliberately +# does not (user-confirmed, 2026-08). +octave: + enabled: true diff --git a/src/data/calendar/temporal-categories.yml b/src/data/calendar/temporal-categories.yml index 8d19fdd..295faf0 100644 --- a/src/data/calendar/temporal-categories.yml +++ b/src/data/calendar/temporal-categories.yml @@ -23,16 +23,21 @@ # major tier (Easter Octave directly verified — Holy Week and Ash # Wednesday are strong prior knowledge, not independently re-verified # here). Where this app *doesn't* have direct evidence either way — -# Pentecost's own Ember days/Vigil/Octave, the Vigil of Christmas — it -# defaults to the major (stricter) tier as the safer guess, pending -# verification. +# Pentecost's own Ember days, the Vigil of Christmas — it defaults to the +# major (stricter) tier as the safer guess, pending verification. Pentecost's +# own *Octave* (as opposed to its Ember days) is the one exception: it's +# no longer classified via this table at all — see the offsetRanges +# section below and calendar/octaves.ts. # # Known gap: Advent Ember days (Wed/Fri/Sat after Dec 13) and September # Ember days (Wed/Fri/Sat near the Exaltation of the Cross, Sept 14) are # NOT modeled here — both are fixed-calendar "nearest such-and-such a date" # rules, not a plain offset, and weren't worth guessing at without a source. -# Lent's and Pentecost's Ember days *are* modeled, since those are clean -# Easter offsets. +# Lent's Ember days *are* modeled (clean Easter offsets, directly +# verified). Pentecost's own Ember days share this same offset-based +# fixability but currently fall to the plain `ordinary-feria` default +# instead (see the offsetRanges comment below) — unverified, a real gap, +# not a guess dressed up as one. bySeason: # Advent's ordinary ferias verified `privileged-feria-minor` during the # December sanctoral pull (see calendar/types.ts's TemporalCategory doc @@ -75,10 +80,20 @@ offsets: offsetRanges: - { fromOffset: -6, toOffset: -1, category: privileged-feria-major } # ferias of Holy Week - { fromOffset: 1, toOffset: 6, category: privileged-feria-major } # Easter octave — verified: even Duplex II. classis (St. Mark) only gets commemorated, never wins - # Pentecost octave (also covers Pentecost's own Ember days) — unverified, - # defaulted to the stricter tier; Lent's Ember days are confirmed - # lesser-tier, but that hasn't been checked for Pentecost's specifically. - - { fromOffset: 50, toOffset: 55, category: privileged-feria-major } + # Pentecost's own Octave (offsets 50-55) is deliberately absent from + # this table now -- it used to be classified privileged-feria-major + # here, but user-confirmed rule (2026-08) is that a Duplex+ saint wins + # outright within it, which the generic octave layer + # (calendar/octaves.ts, data/calendar/temporal-feasts/pentecost- + # sunday.yml) now handles directly. Leaving these offsets unlisted lets + # them fall through to the `pentecost` season default (ordinary-feria, + # see bySeason above), so a real sanctoral candidate reaches the octave + # layer intact instead of being transferred away by a stricter feria + # tier first. Pentecost's own Ember days (within this same range, in + # years they land there) get the same ordinary-feria default as a + # result -- still unverified for Pentecost specifically, not the + # Lent-Ember-day privileged-feria treatment; a real gap, just an + # existing one, not introduced by this change. fixedDates: "12-24": privileged-feria-major # Vigil of Christmas — unverified, defaulted to the stricter tier diff --git a/src/data/calendar/temporal-feasts/christmas-day.yml b/src/data/calendar/temporal-feasts/christmas-day.yml new file mode 100644 index 0000000..9906e20 --- /dev/null +++ b/src/data/calendar/temporal-feasts/christmas-day.yml @@ -0,0 +1,16 @@ +# Christmas's own Octave (Dec 25 - Jan 1), the one exception this pull's +# other octaves aren't: it already has partial standing via the +# `privileged-feria-minor` temporal category (see +# data/calendar/temporal-categories.yml's christmastide entry), which +# governs *ordinary* days within it. This record adds the generic octave +# layer on top so Christmas itself keeps accumulating as a commemoration +# on the days that belong to someone else within the Octave (Stephen, +# John, Holy Innocents, Thomas of Canterbury, Silvester) — those days +# already resolve correctly without this, this is what makes "Christmas" +# itself keep showing up alongside them. `wins` left at the default +# (semiduplex) deliberately, not yet independently verified for Christmas +# specifically. +id: christmas-day +name: "Christmas" +octave: + enabled: true diff --git a/src/data/calendar/temporal-feasts/pentecost-sunday.yml b/src/data/calendar/temporal-feasts/pentecost-sunday.yml new file mode 100644 index 0000000..92ae45d --- /dev/null +++ b/src/data/calendar/temporal-feasts/pentecost-sunday.yml @@ -0,0 +1,15 @@ +# Pentecost's Octave (Pentecost Sunday + 7 days). Stricter threshold than +# the default: user-confirmed rule (2026-08) is that a saint needs to be +# Duplex or higher to keep the day for itself here -- Semiduplex and below +# are commemorated, with the Octave winning the day instead. This replaces +# the previous approximation of folding Pentecost's octave into the +# `privileged-feria-major` temporal category (which never let *any* rank +# win outright) -- see data/calendar/temporal-categories.yml, where those +# offsets were downgraded to `ordinary-feria` so a real sanctoral +# candidate reaches this octave layer instead of being transferred away +# before it gets the chance. +id: pentecost-sunday +name: "Pentecost" +octave: + enabled: true + wins: duplex diff --git a/tests/calendar/august-sanctoral.test.ts b/tests/calendar/august-sanctoral.test.ts index a1e0a71..7c695a1 100644 --- a/tests/calendar/august-sanctoral.test.ts +++ b/tests/calendar/august-sanctoral.test.ts @@ -76,10 +76,10 @@ describe('August sanctoral pull (first pass)', () => { expect(getDayCollect(day).status.en).toBe('verified'); }); - it('dates within St. Lawrence and the Assumption\'s own octaves are excluded entirely, even where the source names a real secondary saint (St. Clare, Aug 12), pending octave support', () => { + it('dates within St. Lawrence and the Assumption\'s own octaves still exclude the real secondary saint the source names (St. Clare, Aug 12) -- Clare herself isn\'t modeled yet -- but St. Lawrence\'s own octave (now modeled, see calendar/octaves.ts) correctly keeps commemorating him through it regardless. The Assumption\'s own octave isn\'t populated with octave data yet, so Aug 22 stays plain.', () => { const clareDay = resolveDay('2025-08-12'); expect(clareDay.winner.kind).toBe('temporal'); - expect(clareDay.commemorations).toEqual([]); + expect(clareDay.commemorations).toEqual([{ kind: 'octave', id: 'st-lawrence', name: 'St. Lawrence, Martyr' }]); const assumptionOctaveDay = resolveDay('2025-08-22'); expect(assumptionOctaveDay.winner.kind).toBe('temporal'); expect(assumptionOctaveDay.commemorations).toEqual([]); diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 9cc4bd3..e98f985 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -11,11 +11,13 @@ describe('getDayLabel — ordinal temporal label', () => { }); it('names the anchor day itself, not "day after itself"', () => { - // St. Felix I (May 30, Simplex) is impeded by that date's own Pentecost - // Ember Saturday and transfers forward into Trinity Sunday, where he's - // commemorated (not displacing the Sunday) — real sanctoral content - // now populates this date, so the label reflects both, feast name first. - expect(getDayLabel(resolveDay('2026-05-31'))).toBe('St. Felix I, Pope and Martyr — Trinity Sunday'); + // Trinity Sunday no longer carries a sanctoral commemoration of its + // own (St. Felix I, May 30, is now commemorated in place on his own + // day within Pentecost's octave instead of transferring here — see + // calendar/octaves.ts and tests/calendar/transfer.test.ts), so this + // is back to a plain temporal label; the "feast — temporal" combined + // form is covered separately below with synthetic data. + expect(getDayLabel(resolveDay('2026-05-31'))).toBe('Trinity Sunday'); expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter'); expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday'); }); diff --git a/tests/calendar/december-sanctoral.test.ts b/tests/calendar/december-sanctoral.test.ts index 9057cd9..f965b37 100644 --- a/tests/calendar/december-sanctoral.test.ts +++ b/tests/calendar/december-sanctoral.test.ts @@ -53,9 +53,11 @@ describe('December sanctoral pull (first pass, 12/12)', () => { }); it('the Christmas Octave "Comites Christi" days each win outright with real propers, the Octave day commemorated in return', () => { - const stephen = resolveDay('2025-12-26'); - const john = resolveDay('2025-12-27'); - const innocents = resolveDay('2026-12-28'); + // 2033 has no Sunday collision anywhere in Dec 26-31, so each of + // these resolves without a privileged-Sunday complication. + const stephen = resolveDay('2033-12-26'); + const john = resolveDay('2033-12-27'); + const innocents = resolveDay('2033-12-28'); expect(stephen.winner).toEqual({ kind: 'sanctoral', id: 'st-stephen-protomartyr', @@ -75,21 +77,48 @@ describe('December sanctoral pull (first pass, 12/12)', () => { rank: 'duplex-2-classis', }); for (const day of [stephen, john, innocents]) { - expect(day.commemorations.length).toBe(1); - expect(day.commemorations[0]?.kind).toBe('temporal'); expect(getDayCollect(day).status.en).toBe('verified'); } + // Each also carries the day's own Octave-of-the-Nativity commemoration + // plus Christmas's own octave, which keeps accumulating -- see the + // dedicated stacking test below for the full week's build-up. + expect(stephen.commemorations).toEqual([ + { kind: 'temporal', id: expect.any(String) }, + { kind: 'octave', id: 'christmas-day', name: 'Christmas' }, + ]); }); it('St. Thomas of Canterbury, only Semiduplex, still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => { - const day = resolveDay('2025-12-29'); + const day = resolveDay('2033-12-29'); expect(day.winner).toEqual({ kind: 'sanctoral', id: 'st-thomas-becket', name: 'St. Thomas of Canterbury, Bishop and Martyr', rank: 'semiduplex', }); - expect(day.commemorations.length).toBe(1); - expect(day.commemorations[0]?.kind).toBe('temporal'); + // Thomas himself has no octave (user-confirmed, 2026-08), but by his + // own day, Christmas + Stephen + John + Holy Innocents' octaves have + // all stacked up and commemorate alongside him. + expect(day.commemorations).toEqual([ + { kind: 'temporal', id: expect.any(String) }, + { kind: 'octave', id: 'christmas-day', name: 'Christmas' }, + { kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' }, + { kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' }, + { kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' }, + ]); + }); + + it('the Christmas Octave commemorations build up day by day as each Comites Christi feast starts its own octave', () => { + // Same clean year (2033) as above, walking the whole week to show the + // accumulation directly -- this is the concrete shape of "commemorations + // build up" the octave mechanism was built for. + const dec30 = resolveDay('2033-12-30'); // no saint of its own -- a plain Octave-of-the-Nativity day + expect(dec30.winner.kind).toBe('temporal'); + expect(dec30.commemorations).toEqual([ + { kind: 'octave', id: 'christmas-day', name: 'Christmas' }, + { kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' }, + { kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' }, + { kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' }, + ]); }); }); diff --git a/tests/calendar/octaves.test.ts b/tests/calendar/octaves.test.ts new file mode 100644 index 0000000..b4ac692 --- /dev/null +++ b/tests/calendar/octaves.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { activeOctavesFor, strictestThreshold } from '../../src/calendar/octaves'; + +describe('activeOctavesFor', () => { + it('finds nothing outside any octave window', () => { + expect(activeOctavesFor('2026-07-15')).toEqual([]); + }); + + it("finds a saint's own octave on its own day (day 1) and through day 8, not on day 9", () => { + const day1 = activeOctavesFor('2026-08-10'); // St. Lawrence's own day + expect(day1).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1 }]); + + const day8 = activeOctavesFor('2026-08-17'); + expect(day8).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 8 }]); + + expect(activeOctavesFor('2026-08-18')).toEqual([]); + }); + + it("Pentecost's octave uses its own configured threshold (duplex), not the default (semiduplex)", () => { + const octaves = activeOctavesFor('2026-05-29'); // within Pentecost's octave, 2026 + expect(octaves).toEqual([{ id: 'pentecost-sunday', name: 'Pentecost', wins: 'duplex', dayNumber: 6 }]); + expect(strictestThreshold(octaves)).toBe('duplex'); + }); + + it('stacks multiple active octaves oldest-started first', () => { + const octaves = activeOctavesFor('2033-12-29'); // St. Thomas of Canterbury's day + expect(octaves.map((o) => o.id)).toEqual([ + 'christmas-day', + 'st-stephen-protomartyr', + 'st-john-apostle', + 'holy-innocents', + ]); + expect(octaves.map((o) => o.dayNumber)).toEqual([5, 4, 3, 2]); + }); +}); diff --git a/tests/calendar/transfer.test.ts b/tests/calendar/transfer.test.ts index 5c901e7..6068ca7 100644 --- a/tests/calendar/transfer.test.ts +++ b/tests/calendar/transfer.test.ts @@ -50,25 +50,38 @@ describe('transfer mechanism (resolveDay integration)', () => { }); }); - // Found via real data (the May sanctoral pull): a transferred-in - // candidate was blindly winning outright whenever the receiving day's - // own native winner was temporal, regardless of that day's own - // precedence category — so a transferred-in Simplex was overwriting - // Trinity Sunday itself instead of going through the same ordinary- - // Sunday rule a native candidate would have. - it('a simplex saint impeded by a privileged feria transfers forward and is merely commemorated, not made to win, on the ordinary Sunday it lands on', () => { - // St. Felix I (May 30, Simplex) is impeded by that date's own - // Pentecost Ember Saturday (privileged-feria-major) and transfers - // forward into Trinity Sunday 2026 (May 31, an ordinary Sunday). + // Originally found via real data (the May sanctoral pull): a + // transferred-in candidate was blindly winning outright whenever the + // receiving day's own native winner was temporal, regardless of that + // day's own precedence category — so a transferred-in Simplex was + // overwriting Trinity Sunday itself instead of going through the same + // ordinary-Sunday rule a native candidate would have. That fix (in + // applyIncomingTransfer) is unchanged and still covered here, but the + // concrete St. Felix I / Trinity Sunday scenario that first surfaced it + // no longer demonstrates a *transfer* at all: since the octave + // mechanism (calendar/octaves.ts) was added, Pentecost's own Ember + // Saturday is no longer classified `privileged-feria-major` (see + // data/calendar/temporal-categories.yml), so Felix now wins his own day + // outright under the permissive `ordinary-feria` default — then gets + // caught and commemorated in place by Pentecost's octave itself + // (threshold: duplex), never even reaching a transfer. He simply + // doesn't appear on Trinity Sunday at all anymore. Kept here anyway as + // a still-useful demonstration of the octave layer's own override path. + it("St. Felix I, below Pentecost's octave threshold, is commemorated in place on his own day rather than transferring anywhere", () => { const saturday = resolveDay('2026-05-30'); const sunday = resolveDay('2026-05-31'); expect(saturday.winner).toEqual({ kind: 'temporal', id: 'pentecost-sunday' }); - expect(saturday.commemorations).toEqual([]); - - expect(sunday.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-01' }); - expect(sunday.commemorations).toEqual([ + expect(saturday.commemorations).toEqual([ { kind: 'sanctoral', id: 'st-felix-i', name: 'St. Felix I, Pope and Martyr', rank: 'simplex' }, + { kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost' }, ]); + + // Felix doesn't reach Trinity Sunday at all now -- but Trinity Sunday + // is day 8 of Pentecost's own 8-day octave (the default length, not + // yet independently confirmed to be right for Pentecost specifically), + // so Pentecost's own octave commemoration still persists here. + expect(sunday.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-01' }); + expect(sunday.commemorations).toEqual([{ kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost' }]); }); });