calendar: model octaves as a generic, data-driven mechanism
Deploy / deploy (push) Successful in 46s

Adds calendar/octaves.ts: a lookback over the past week collecting every
octave (sanctoral or temporal) still active on a date, stacking multiple
at once (Christmas + St. Stephen + St. John + Holy Innocents all
commemorated together within the Christmas Octave). Declared via an
optional `octave` field on a saint's own record or a new small
TemporalFeastRecord (calendar/temporal-feasts.ts) for temporal ids like
Christmas/Pentecost that didn't have a metadata record before -- data-
driven per user design discussion, with `{ enabled: true }` alone using
sensible defaults (8 days, semiduplex threshold) so a minimal declaration
works without authored content.

Wired into resolveDay as a post-processing layer: doesn't change how a
single day's own precedence contest is decided, just adds commemorations
for active octaves and occasionally overrides the winner when the
occurring saint doesn't clear the strictest active octave's threshold.

Populated so far: St. Lawrence's own octave (the one that repeatedly cost
real saints their spot in August), the three Comites Christi octaves
(Stephen/John/Innocents -- Thomas of Canterbury deliberately excluded,
per discussion), and Pentecost's (duplex threshold, user-specified).
Pentecost's octave offsets are also removed from temporal-categories.yml's
privileged-feria-major classification, letting a real candidate reach the
new octave layer instead of being transferred away first -- with the
side effect that Pentecost's own Ember Saturday no longer forces a
transfer (a sub-threshold saint is now commemorated in place instead, see
tests/calendar/transfer.test.ts's updated case). Assumption, Nativity
BVM, Immaculate Conception, and All Saints' own octaves are not yet
populated with octave data -- deliberately deferred to a follow-up pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 07:23:43 -04:00
parent c0413a44dc
commit da3b6e893e
17 changed files with 415 additions and 45 deletions
+46 -1
View File
@@ -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';