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
+4 -1
View File
@@ -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<string, string[]> };
+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';
+91
View File
@@ -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<string>,
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<string>();
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<FeastClass>(
(max, o) => (compareFeastClass(o.wins, max) > 0 ? o.wins : max),
octaves[0]?.wins ?? DEFAULT_WINS,
);
}
+51
View File
@@ -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<string, TemporalFeastRecord>();
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;
}
+36 -5
View File
@@ -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" */
@@ -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
@@ -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
+9
View File
@@ -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
@@ -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
+24 -9
View File
@@ -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
@@ -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
@@ -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