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