Fix octave day labels: correct commemoration phrasing, closing-day title, and privileged-season precedence
Deploy / deploy (push) Successful in 1m4s

Three related day-label bugs, all found via Aug 19's mislabeled Assumption
octave commemoration:

- A sanctoral winner that displaces the only active octave now shows the
  octave's real "Nth Day within the Octave of X" phrasing (no rank, since
  it's riding along under the winning feast), not a bare feast name.
- An octave's own closing day now titles itself "Octave of X", matching
  the real DO's "in octava" vs. "infra octavam" distinction, instead of
  "8th Day within the Octave of X".
- A sufficiently-ranked octave can now outrank a privileged temporal
  season (e.g. the Immaculate Conception's octave outright winning several
  of its days against Advent, live-verified against Divino Afflatu 1954),
  via a new shared octaveGoverningPrivilegedDay helper used by both the
  day label and the actual office-content resolver. Deliberately excludes
  Christmastide, whose own stacked octaves are structurally already that
  season's temporal content rather than a foreign add-on.

Updates a Matins test fixture that had unknowingly relied on the
Dec 15 bug (Advent ferial + a lone commemorated saint) and splits it into
a clean ferial case plus a dedicated commemorated-saint case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:49:51 -04:00
parent 427ba6756a
commit 3f07d6e8d3
7 changed files with 251 additions and 54 deletions
+28
View File
@@ -38,6 +38,34 @@ export function isAtLeast(rank: FeastClass, threshold: FeastClass): boolean {
return compareFeastClass(rank, threshold) >= 0;
}
/**
* The minimum rank a real sanctoral candidate needs to win each temporal
* category outright, per `decideOccurrence`'s own branches above —
* factored out so calendar/octaves.ts can ask the identical question of an
* *octave*'s own effective standing (not a real occurring saint) on a day
* where nothing else won. `undefined` where nothing ever wins outright no
* matter how high-ranked (`privileged-feria-major`/`privileged-sunday` —
* Ash Wednesday, Holy Week, a privileged Sunday — only ever get a
* commemoration at best, per those branches above). `ordinary-feria` has
* no real threshold at all (any real content wins), included here as
* `'simplex'` (the weakest real rank) only for completeness — callers
* needing that case already have their own unconditional path and don't
* need to call this. */
export function minimumOutrightWinningRank(category: TemporalCategory): FeastClass | undefined {
switch (category) {
case 'ordinary-feria':
return 'simplex';
case 'privileged-feria-minor':
return 'semiduplex';
case 'privileged-feria':
case 'ordinary-sunday':
return 'duplex';
case 'privileged-feria-major':
case 'privileged-sunday':
return undefined;
}
}
/** Vigils belong to the day *before* their feast, so an impeded vigil is
* shifted backward rather than forward like everything else — see
* calendar/transfer.ts. */
+72 -21
View File
@@ -14,7 +14,7 @@ import { easterSunday } from './easter';
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math';
import { getTemporalFeastRecord } from './temporal-feasts';
import { resolveActiveOctave, type ActiveOctave } from './octaves';
import { activeOctavesFor, octaveGoverningPrivilegedDay, resolveActiveOctave, type ActiveOctave } from './octaves';
import { resolveTemporalId } from './temporal-id';
function capitalize(text: string): string {
@@ -210,15 +210,36 @@ function temporalLabel(day: LiturgicalDay): string {
return `${weekdayName} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName}`;
}
/** "Third Day within the Octave of St. Lawrence" — the real DO title an
* octave day carries on its own (e.g. "Tertia die infra Octavam S.
* Laurentii Martyris") when nothing else has displaced it. Day 1 shouldn't
* normally reach this (that day's own winner is the feast itself, handled
/** "Third Day within the Octave of St. Lawrence" for an ordinary mid-octave
* day (e.g. "Tertia die infra Octavam S. Laurentii Martyris"), or plain
* "Octave of St. Lawrence" for the octave's own closing day — real DO
* Latin distinguishes "infra octavam" (within the octave, days 2-7) from
* "in octava" (on the octave day itself, day 8), live-verified: Aug 17
* (St. Lawrence's closing day) titles itself "In Octava S. Laurentii
* Martyris", not "Octava die infra Octavam...". Day 1 shouldn't normally
* reach either branch (that day's own winner is the feast itself, handled
* above before this is ever called) — kept simple rather than
* special-cased for that rare edge case (see applyOctaves's own
* `isOwnStartDay` comment in calendar/index.ts for when it can happen). */
function octaveCoreName(octave: ActiveOctave): string {
return octave.isClosingDay ? `Octave of ${octave.name}` : `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`;
}
function octaveLabel(octave: ActiveOctave): string {
return `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name} (${formatRank(octave.wins)})`;
return `${octaveCoreName(octave)} (${formatRank(octave.wins)})`;
}
/** Same phrasing as `octaveLabel`, but without the parenthesized rank —
* the real DO commemoration line for a displaced octave day carries no
* rank at all (e.g. Aug 19's real Divino Afflatu 1954 commemoration reads
* plain "Quinta die infra Octavam S. Assumptionis Beatæ Mariæ Virginis",
* no "~ Semiduplex"). `octaveLabel`'s own rank parenthetical only belongs
* to an octave day when it's the day's own primary winner (rank is what
* it *won as*, not a fact worth restating once a duplex-or-higher saint
* has displaced it and it's just riding along as a commemoration
* instead). */
function octaveCommemorationLabel(octave: ActiveOctave): string {
return octaveCoreName(octave);
}
/** Every `kind: 'octave'` commemoration on `day` other than `excludeId` —
@@ -239,6 +260,27 @@ function otherActiveOctaveNames(day: LiturgicalDay, excludeId: string | undefine
.map((c) => c.name);
}
/** Same commemoration-filtering as `otherActiveOctaveNames`, but rendered
* with `octaveCommemorationLabel`'s fuller "Nth Day within the Octave of X"
* phrasing rather than a plain name — the real DO title an octave day
* carries even when it *lost* outright to an occurring saint (e.g. Aug 19's
* real Divino Afflatu 1954 title is "S. Joannis Eudes Confessoris ~ Duplex"
* with the commemoration read as "Quinta die infra Octavam S. Assumptionis
* Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed Virgin
* Mary"). Only used from the sanctoral-winner branch below — the
* octave-vs-octave secondary mention (`otherActiveOctaveNames` itself,
* still used in the octave-headline branch further down) keeps its plain
* name on purpose, per that function's own doc comment. */
function commemoratedOctaveDayLabels(day: LiturgicalDay, excludeId: string | undefined): string[] {
const active = activeOctavesFor(day.date);
return day.commemorations
.filter((c): c is Extract<Commemoration, { kind: 'octave' }> => c.kind === 'octave' && c.id !== excludeId)
.map((c) => {
const octave = active.find((a) => a.id === c.id);
return octave ? octaveCommemorationLabel(octave) : c.name;
});
}
const RANK_LABELS: Record<FeastClass, string> = {
simplex: 'Simplex',
vigil: 'Vigil',
@@ -295,7 +337,7 @@ export function getDayLabel(day: LiturgicalDay): string {
// also day 8 of Pentecost's own octave) never mentions an octave —
// same "nobody calls it that" convention anchorDayName's own doc
// comment already established for the anchor-day case.
const otherOctaves = day.temporalCategory === 'ordinary-feria' ? otherActiveOctaveNames(day, undefined) : [];
const otherOctaves = day.temporalCategory === 'ordinary-feria' ? commemoratedOctaveDayLabels(day, undefined) : [];
const winnerName = `${day.winner.name} (${formatRank(day.winner.rank)})`;
return [winnerName, ...otherOctaves].join(' — ');
}
@@ -326,24 +368,33 @@ export function getDayLabel(day: LiturgicalDay): string {
// identity in the live engine, not a footnote — e.g. "Tertia die infra
// Octavam S. Laurentii Martyris", not "Wednesday in the 11th week after
// Trinity" — but *only* when the temporal day itself has no standing of
// its own (`ordinary-feria`), same gate as hours/resolve-common.ts's
// its own (`ordinary-feria`), or a foreign octave's own effective rank
// is strong enough to clear a privileged day's real threshold anyway
// (`octaveGoverningPrivilegedDay` — the Immaculate Conception's own
// octave outright winning several of its days against Advent's
// privileged-feria-minor ferias, live-verified: Dec 9/10/12/14 at the
// octave's ordinary Semiduplex, Dec 15 at its own elevated Duplex majus
// closing day). Same gate as hours/resolve-common.ts's
// resolveOfficeWinner and for the same reason: live-verified
// counterexample is the Christmas Octave's own stack (Dec 30, e.g.,
// `privileged-feria-minor`), where the real title stays the temporal
// Sunday's own ("De Dominica Infra Octavam Nativitatis") with no octave
// name in it at all — this label agreeing with resolveOfficeWinner
// about which one wins is what makes "the office is Lawrence's" and
// "the label says Lawrence" consistent instead of two independent
// guesses that can disagree. When more than one octave is active at
// once (resolveActiveOctave), the highest-ranked wins the headline
// (ties broken by whichever started more recently) — every other
// active octave still gets named too (otherActiveOctaveNames), not
// dropped: live-verified real case, Aug 17 -- St. Lawrence's own
// elevated closing day wins the headline, but the Assumption's own
// day 3 (a real, distinct, simultaneously-active octave, not a
// duplicate of Lawrence's) still belongs in the label alongside St.
// Hyacinth's commemoration.
const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined;
// name in it at all, regardless of rank — `octaveGoverningPrivilegedDay`
// excludes `christmastide` for exactly this reason (see its own doc
// comment) — this label agreeing with resolveOfficeWinner about which
// one wins is what makes "the office is Lawrence's" and "the label says
// Lawrence" consistent instead of two independent guesses that can
// disagree. When more than one octave is active at once
// (resolveActiveOctave), the highest-ranked wins the headline (ties
// broken by whichever started more recently) — every other active
// octave still gets named too (otherActiveOctaveNames), not dropped:
// live-verified real case, Aug 17 -- St. Lawrence's own elevated
// closing day wins the headline, but the Assumption's own day 3 (a
// real, distinct, simultaneously-active octave, not a duplicate of
// Lawrence's) still belongs in the label alongside St. Hyacinth's
// commemoration.
const activeOctave =
day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : octaveGoverningPrivilegedDay(day);
if (activeOctave) {
const primary = octaveLabel(activeOctave);
const rest = [...otherActiveOctaveNames(day, activeOctave.id), ...(commemoratedSaint ? [commemoratedSaint.name] : [])];
+2 -2
View File
@@ -356,10 +356,10 @@ export function isSundayOrFeast(day: LiturgicalDay): boolean {
return day.weekday === 'sunday' || day.winner.kind === 'sanctoral';
}
export { compareFeastClass, isAtLeast, decideOccurrence } from './commemorations';
export { compareFeastClass, isAtLeast, decideOccurrence, minimumOutrightWinningRank } from './commemorations';
export { resolveCollision } from './collision';
export { resolveTemporalId } from './temporal-id';
export { activeOctavesFor, strictestThreshold, resolveActiveOctave } from './octaves';
export { activeOctavesFor, strictestThreshold, resolveActiveOctave, octaveGoverningPrivilegedDay } from './octaves';
export type { ActiveOctave } from './octaves';
export type {
LiturgicalDay,
+42 -2
View File
@@ -8,11 +8,11 @@
// 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 type { FeastClass, LiturgicalDay, OctaveConfig } from './types';
import { getSanctoralCandidatesFor, getSaintRecord } from './feasts';
import { getTemporalFeastRecord, temporalFeastIdsStartingOn } from './temporal-feasts';
import { addDays, daysBetween } from './date-math';
import { compareFeastClass } from './commemorations';
import { compareFeastClass, minimumOutrightWinningRank } from './commemorations';
export interface ActiveOctave {
id: string;
@@ -131,6 +131,46 @@ export function resolveActiveOctave(isoDate: string): ActiveOctave | undefined {
return pickWinningOctave(activeOctavesFor(isoDate));
}
/**
* Whether an active octave's own effective rank clears the bar a real
* sanctoral candidate would need to win `day`'s temporal category outright
* — i.e. whether the octave governs the day's content/label even though
* no *saint* occurring there was strong enough to (a "foreign" octave
* superimposed on a season it doesn't belong to, e.g. the Immaculate
* Conception's own octave running Dec 8-15, squarely inside Advent).
* Live-verified against Divino Afflatu 1954: Dec 9/10/12/14 (ordinary
* octave days, Semiduplex) and Dec 15 (the octave's own elevated closing
* day, Duplex majus) all win outright over Advent's own privileged-feria-
* minor ferias, exactly the same threshold `decideOccurrence` already
* uses for a real Semiduplex+ saint there (St. Nicholas, Dec 6).
*
* Deliberately excludes `ordinary-feria` (unconditional there already —
* any active octave governs regardless of rank, handled separately by
* each caller via `resolveActiveOctave` directly) and `christmastide`
* (Christmas's own stacked octaves — Christmas, St. Stephen, St. John,
* Holy Innocents — are structurally already that season's own temporal
* content, not a foreign add-on contesting it the way the Immaculate
* Conception's octave contests Advent: live-verified, Dec 30 keeps
* "Dominica Infra Octavam Nativitatis" as the real DO title regardless of
* actual weekday or which of the four octaves is active, never an octave
* name — see calendar/day-label.ts's and hours/resolve-common.ts's own
* Dec-30 doc comments for the fuller reasoning already established
* there). */
export function octaveGoverningPrivilegedDay(day: LiturgicalDay): ActiveOctave | undefined {
if (day.temporalCategory === 'ordinary-feria' || day.season === 'christmastide') {
return undefined;
}
const minRank = minimumOutrightWinningRank(day.temporalCategory);
if (!minRank) {
return undefined;
}
const octave = resolveActiveOctave(day.date);
if (octave && compareFeastClass(octave.wins, minRank) >= 0) {
return octave;
}
return undefined;
}
/** The comparison itself, factored out from resolveActiveOctave so the
* precedence rule (rank, then recency) is directly unit-testable against
* synthetic ActiveOctave data — no real equal-rank overlap exists yet in
+27 -19
View File
@@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calend
import type { ProperText } from '../propers';
import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave, activeOctavesFor, isAtLeast } from '../calendar';
import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { splitAntiphon } from './antiphon';
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
@@ -40,25 +40,35 @@ export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ-
* Which identity's own propers actually supply the office's content
* (collect, Benedictus antiphon, psalmody override) — usually just
* `day.winner`, but *not* on a day within an active octave where the
* temporal day itself has no real standing of its own (`ordinary-feria`)
* and the winner stayed temporal — an ordinary day within St. Lawrence's
* own octave, e.g.: live-verified (Tridentine 1910, 2026-08-12) that the
* temporal day itself has no real standing of its own (`ordinary-feria`),
* or where a *foreign* octave's own effective rank is strong enough to
* clear even a privileged day's real threshold
* (`octaveGoverningPrivilegedDay` — the Immaculate Conception's own
* octave outright winning several of its days against Advent's
* privileged-feria-minor ferias, live-verified: Dec 9/10/12/14 at the
* octave's ordinary Semiduplex, Dec 15 at its own elevated Duplex majus
* closing day) — an ordinary day within St. Lawrence's own octave, e.g.:
* live-verified (Tridentine 1910, 2026-08-12) that the
* chapter/responsory/hymn/versicle, psalms' antiphons, Benedictus
* antiphon, and day collect all come from the octave's own feast
* ("{ex Commune aut Festo}" / "{ex Proprio Sanctorum}"), not from the
* plain temporal day underneath it.
*
* Deliberately gated on `ordinary-feria` specifically, not just "an
* octave is active": the Christmas Octave's own stacked octaves
* (Christmas + Stephen + John + Holy Innocents, e.g. on Dec 30) are the
* live-verified counterexample — that day's own temporal identity
* ("Dominica Infra Octavam Nativitatis", `privileged-feria-minor`) is
* itself a real, named standing, and *keeps* the office
* ("{ex Proprio de Tempore}"); the four octaves there each become their
* own separate "Commemoratio Octavæ ..." block instead (not yet modeled
* — see TODO.md), rather than any one of them taking over content the
* way Lawrence's octave does. The dividing line is real standing, not
* merely "is an octave active."
* Deliberately excludes `christmastide` even though it's otherwise a
* `privileged-feria-minor` season same as Advent: the Christmas Octave's
* own stacked octaves (Christmas + Stephen + John + Holy Innocents, e.g.
* on Dec 30) are the live-verified counterexample — that day's own
* temporal identity ("Dominica Infra Octavam Nativitatis",
* `privileged-feria-minor`) is itself a real, named standing, and
* *keeps* the office ("{ex Proprio de Tempore}") regardless of any of
* those octaves' own rank; the four octaves there each become their own
* separate "Commemoratio Octavæ ..." block instead (not yet modeled —
* see TODO.md), rather than any one of them taking over content the way
* Lawrence's or the Immaculate Conception's octave does. The dividing
* line is real standing (own temporal identity vs. a foreign octave
* merely overlapping a season's ordinary ferias), not merely "is an
* octave active" — see `octaveGoverningPrivilegedDay`'s own doc comment
* for the fuller reasoning.
*
* `day.winner`/`day.commemorations` themselves stay exactly as
* calendar/index.ts computed them either way — this is purely a
@@ -71,9 +81,6 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner {
if (day.winner.kind === 'sanctoral' || ALWAYS_OVERRIDE_TEMPORAL_IDS.has(day.winner.id)) {
return day.winner;
}
if (day.temporalCategory !== 'ordinary-feria') {
return day.winner;
}
// When more than one octave is active at once (St. Lawrence's and the
// Assumption's genuinely overlap every Aug 16-17), resolveActiveOctave
// picks the one that actually governs the day — see its own doc
@@ -81,7 +88,8 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner {
// default if that octave turns out to be a temporal-only one with no
// saint record (e.g. Christmas's or Pentecost's own octave id) — no
// sanctoral content to synthesize from those.
const activeOctave = resolveActiveOctave(day.date);
const activeOctave =
day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : octaveGoverningPrivilegedDay(day);
if (activeOctave) {
const saint = getSaintRecord(activeOctave.id);
if (saint) {