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
+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) {