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
+21 -11
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 { activeOctavesFor, type ActiveOctave } from './octaves';
import { resolveActiveOctave, type ActiveOctave } from './octaves';
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
@@ -186,16 +186,26 @@ export function getDayLabel(day: LiturgicalDay): string {
return commemoratedSaint ? `${commemoratedSaint.name}${anchorName}` : anchorName;
}
// An active octave (St. Lawrence's, Christmas's own stack, ...) is this
// day's real primary 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". Picks the oldest-started octave when
// several are stacked (activeOctavesFor's own ordering) as the headline;
// the others still ride along as ordinary octave commemorations, just
// not separately named here.
const octaves = activeOctavesFor(day.date);
if (octaves.length > 0) {
const primary = octaveLabel(octaves[0]!);
// An active octave (St. Lawrence's, ...) is this day's real primary
// 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
// 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) — the others still
// ride along as ordinary octave commemorations, just not separately
// named here.
const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined;
if (activeOctave) {
const primary = octaveLabel(activeOctave);
return commemoratedSaint ? `${primary}${commemoratedSaint.name}` : primary;
}
+1 -1
View File
@@ -239,7 +239,7 @@ 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 { activeOctavesFor, strictestThreshold, resolveActiveOctave } from './octaves';
export type { ActiveOctave } from './octaves';
export type {
LiturgicalDay,
+58 -2
View File
@@ -17,7 +17,13 @@ import { compareFeastClass } from './commemorations';
export interface ActiveOctave {
id: string;
name: string;
/** Rank threshold below which the occurring saint loses the day to this octave. */
/** This octave's own effective rank *today* — the ordinary `wins`
* threshold on every day except its own closing day, where it's
* `closingDayRank` instead (see calendar/types.ts's OctaveConfig).
* Doubles as both "how strong a rival saint must be to displace this
* octave" and "this octave's own strength when compared against
* another simultaneously-active octave" (resolveActiveOctave) — same
* underlying question, two different comparison partners. */
wins: FeastClass;
/** 1 on the feast's own day, counting up from there. */
dayNumber: number;
@@ -25,6 +31,7 @@ export interface ActiveOctave {
const DEFAULT_DAYS = 8;
const DEFAULT_WINS: FeastClass = 'semiduplex';
const DEFAULT_CLOSING_DAY_RANK: FeastClass = 'duplex';
/** 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). */
@@ -48,7 +55,10 @@ function considerCandidate(
return;
}
seen.add(id);
active.push({ id, name, wins: octave.wins ?? DEFAULT_WINS, dayNumber: offset + 1 });
const dayNumber = offset + 1;
const isClosingDay = dayNumber === days;
const wins = isClosingDay ? (octave.closingDayRank ?? DEFAULT_CLOSING_DAY_RANK) : (octave.wins ?? DEFAULT_WINS);
active.push({ id, name, wins, dayNumber });
}
/** Every octave (sanctoral or temporal) whose window covers `isoDate`,
@@ -89,3 +99,49 @@ export function strictestThreshold(octaves: ActiveOctave[]): FeastClass {
octaves[0]?.wins ?? DEFAULT_WINS,
);
}
/**
* Which single active octave actually governs a day's own content/label
* when more than one is active at once (this app's first real case: St.
* Lawrence's, Aug 10-17, and the Assumption's, Aug 15-22, genuinely
* overlap every year) and no rival saint has already displaced all of
* them outright (that's calendar/index.ts's applyOctaves — this only
* runs octave-vs-octave). Per direct instruction:
*
* - Highest effective rank (`wins`, already elevated on either octave's
* own closing day) wins outright.
* - Tied rank: the more recently *started* octave wins (smaller
* `dayNumber` today) — the reasoning given was that day 1 of a newly
* started octave needs to be fully present, which is the whole point
* of it starting; the older octave that's already been running is
* commemorated instead, same as any octave that loses this comparison.
*
* Undefined when no octave is active at all. Every other active octave
* still gets commemorated regardless of which one wins here — this
* function only decides whose *content* (and day-label name) governs,
* not who gets left out of the commemoration list entirely (see
* calendar/index.ts's applyOctaves, unchanged by this).
*/
export function resolveActiveOctave(isoDate: string): ActiveOctave | undefined {
return pickWinningOctave(activeOctavesFor(isoDate));
}
/** 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
* this app's own calendar to exercise the tie-break against. */
export function pickWinningOctave(active: ActiveOctave[]): ActiveOctave | undefined {
return active.reduce<ActiveOctave | undefined>((best, candidate) => {
if (!best) {
return candidate;
}
const rankCmp = compareFeastClass(candidate.wins, best.wins);
if (rankCmp > 0) {
return candidate;
}
if (rankCmp < 0) {
return best;
}
return candidate.dayNumber < best.dayNumber ? candidate : best;
}, undefined);
}
+9
View File
@@ -137,6 +137,15 @@ export interface OctaveConfig {
* data/calendar/temporal-feasts/pentecost-sunday.yml), which is
* stricter (`duplex`). */
wins?: FeastClass;
/** The octave's own effective rank on its *last* day specifically (the
* "in Octava" closing day) instead of `wins` — live-verified as a real,
* general pattern rather than a per-saint quirk: both St. Lawrence's
* own Aug 17 and the Assumption's own Aug 22 show as Duplex, elevated
* above their otherwise-Semiduplex ordinary in-between days. Default
* 'duplex'. Feeds both the rival-saint threshold (calendar/octaves.ts's
* `strictestThreshold`) and octave-vs-octave precedence
* (`resolveActiveOctave`) on that one day. */
closingDayRank?: FeastClass;
}
export type DayWinner =