Implement octave-vs-octave precedence, and fix a real day-label bug it surfaced
Deploy / deploy (push) Successful in 52s

When more than one octave is active on a day with no temporal standing of
its own (St. Lawrence's and the Assumption's genuinely overlap every Aug
16-17), which one governs the day's content/label is now decided by rank
comparison instead of "whichever started first" (an accident of
insertion order). Per direct instruction:

- Highest effective rank wins outright; every other active octave still
  gets commemorated, same as a single octave already outranking a weak
  rival saint.
- A rank tie goes to whichever octave started more recently -- day 1 of
  a new octave needs to be fully present, the whole point of it
  starting. No real tied-rank case exists yet to verify this
  empirically, unlike everything else here -- documented in TODO.md as
  a stated decision, not a live finding.

New OctaveConfig.closingDayRank (default duplex): an octave's own final
day ("in Octava") is elevated above its ordinary in-between rank --
live-verified as a real, general pattern (both St. Lawrence's Aug 17 and
the Assumption's Aug 22 show as Duplex, above their otherwise-Semiduplex
ordinary days), not a one-off. This elevation is *why* Lawrence's octave
beats the Assumption's on their one real overlap day despite the
Assumption being the far higher-ranked feast overall. Feeds both the
existing rival-saint threshold and the new octave-vs-octave comparison.

calendar/octaves.ts gains resolveActiveOctave (+ pickWinningOctave, the
comparison itself factored out for direct unit testing against synthetic
data, since no real tied-rank overlap exists to test against yet).
hours/resolve-common.ts's resolveOfficeWinner and calendar/day-label.ts
both now call it instead of each keeping their own "activeOctavesFor(...)
[0]" logic.

That consolidation surfaced a real, independent bug: getDayLabel never
checked temporalCategory at all before choosing an octave name, unlike
resolveOfficeWinner -- found while testing the real Aug 16 overlap (a
Sunday that year, where the temporal Sunday has standing and should win
outright). Live-verified counterexample: the Christmas Octave's own
stack (Dec 30) was wrongly labeled "3rd Day within the Octave of The Holy
Innocents" instead of the correct plain temporal label -- the real title
never names any of the four stacked octaves there. Fixed by sharing the
exact same ordinary-feria gate resolveOfficeWinner already had, so the
two can no longer disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 09:38:06 -04:00
parent 5f4cf33776
commit 397f31f4fc
8 changed files with 207 additions and 51 deletions
+11 -3
View File
@@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types'
import type { ProperText } from '../propers';
import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts';
import { activeOctavesFor, isAtLeast } from '../calendar';
import { resolveActiveOctave, isAtLeast } from '../calendar';
import { splitAntiphon } from './antiphon';
function toResolvedText(proper: ProperText): ResolvedText {
@@ -60,8 +60,16 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner {
if (day.temporalCategory !== 'ordinary-feria') {
return day.winner;
}
for (const octave of activeOctavesFor(day.date)) {
const saint = getSaintRecord(octave.id);
// 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
// comment for the precedence rule. Falls through to the plain temporal
// 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);
if (activeOctave) {
const saint = getSaintRecord(activeOctave.id);
if (saint) {
return { kind: 'sanctoral', id: saint.id, name: saint.name, rank: saint.rank };
}