Fix octave-tie precedence, day-label commemorations, and rank display

A tie between an occurring saint's rank and an active octave now favors
the octave only on its own elevated closing day (live-verified: St.
Hyacinth vs. St. Lawrence's own Aug 17 closing day), not on an ordinary
octave day, where a tied saint still wins as before -- confirmed against
two already-tested counterexamples (St. Thomas of Canterbury, St.
Nicholas of Tolentino) that a blanket tie-flip would have broken.

getDayLabel previously dropped every commemoration whenever the day's
winner was a plain saint, and dropped every non-headline active octave
even when an octave itself won -- both fixed. Rank is now shown after
the day's own winner's name (previously not shown anywhere in the UI).

Also authored assumption-octave-day-3.yml, a real content gap (Aug 17,
day 3 of her octave) surfaced while fixing the above.
This commit is contained in:
2026-08-18 06:11:37 -04:00
parent ee3e155bfa
commit 1fc4bd7160
7 changed files with 224 additions and 64 deletions
+60 -6
View File
@@ -9,7 +9,7 @@
// become a configurable choice later (the same day->id indirection
// philosophy already used for the sanctoral calendar), not hardcoded here
// forever — just not built yet.
import type { LiturgicalDay } from './types';
import type { Commemoration, FeastClass, LiturgicalDay } from './types';
import { easterSunday } from './easter';
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math';
@@ -212,6 +212,43 @@ function octaveLabel(octave: ActiveOctave): string {
return `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`;
}
/** Every `kind: 'octave'` commemoration on `day` other than `excludeId` —
* an octave already serving as the day's own headline (a sanctoral
* winner sharing an octave's id, or the octave `resolveActiveOctave`
* itself picked as primary below) would be redundant to list again.
* Plain names, not `octaveLabel`'s "Nth Day within the Octave of ..."
* phrasing — that fuller phrasing is reserved for an octave that's
* actually the day's own primary identity, not a secondary mention
* alongside it (same plain-name convention `commemoratedSaint` already
* uses below). Real gap this closes: a *second*, non-winning active
* octave (e.g. the Assumption's own day 3, alongside St. Lawrence's
* winning closing day) was previously dropped from the label entirely,
* regardless of which branch below actually renders the primary name. */
function otherActiveOctaveNames(day: LiturgicalDay, excludeId: string | undefined): string[] {
return day.commemorations
.filter((c): c is Extract<Commemoration, { kind: 'octave' }> => c.kind === 'octave' && c.id !== excludeId)
.map((c) => c.name);
}
const RANK_LABELS: Record<FeastClass, string> = {
simplex: 'Simplex',
vigil: 'Vigil',
semiduplex: 'Semiduplex',
duplex: 'Duplex',
'duplex-majus': 'Duplex Majus',
'duplex-2-classis': 'Duplex II Class',
'duplex-1-classis': 'Duplex I Class',
};
/** The day's own winning saint's rank, parenthesized after their name —
* rank was previously shown nowhere in this app's UI at all, for any
* saint. Only the day's own *winner* gets this treatment, not every
* commemoration, matching the "winner is primary, commemorations are
* secondary" distinction this file already draws throughout. */
function formatRank(rank: FeastClass): string {
return RANK_LABELS[rank];
}
/**
* The full "day being celebrated" label: a feast name when
* calendar/commemorations.ts says the day has one, combined with (or
@@ -221,7 +258,19 @@ function octaveLabel(octave: ActiveOctave): string {
*/
export function getDayLabel(day: LiturgicalDay): string {
if (day.winner.kind === 'sanctoral') {
return day.winner.name;
// A sanctoral winner can still share the day with an active octave
// it didn't come from (e.g. winning a tie-break against one octave
// while a second, unrelated octave is also active) — append those,
// same "winner is primary, commemorations ride along" shape every
// other branch below already uses. Gated on `ordinary-feria`, same
// as the octave-headline branch further down: a day with real
// standing of its own (e.g. Trinity Sunday, which is incidentally
// 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 winnerName = `${day.winner.name} (${formatRank(day.winner.rank)})`;
return [winnerName, ...otherOctaves].join(' — ');
}
// A named temporal feast (Christmas, Pentecost, Marian Saturday, ...)
@@ -260,13 +309,18 @@ export function getDayLabel(day: LiturgicalDay): string {
// "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.
// (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;
if (activeOctave) {
const primary = octaveLabel(activeOctave);
return commemoratedSaint ? `${primary}${commemoratedSaint.name}` : primary;
const rest = [...otherActiveOctaveNames(day, activeOctave.id), ...(commemoratedSaint ? [commemoratedSaint.name] : [])];
return [primary, ...rest].join(' — ');
}
const temporal = temporalLabel(day);
+40 -10
View File
@@ -3,7 +3,7 @@ import { weekdayOf } from './weekday';
import { resolveSeason, resolveTemporalCategory, sundayOnOrBefore } from './temporal';
import { resolveTemporalId } from './temporal-id';
import { getSanctoralCandidatesFor } from './feasts';
import { decideOccurrence, isAtLeast, type OccurrenceResult } from './commemorations';
import { decideOccurrence, compareFeastClass, type OccurrenceResult } from './commemorations';
import { resolveCollision } from './collision';
import { addDays, toIsoDate } from './date-math';
import { easterSunday } from './easter';
@@ -281,11 +281,36 @@ function applyMarianSaturday(
* Layered on top of everything above, not part of it: an octave doesn't
* change how a single day's own precedence contest is decided, it just
* (a) adds a commemoration for every octave still active on this date, and
* (b) occasionally overrides the winner when the occurring saint is too
* minor to clear the strictest active octave's threshold, in which case
* the day reverts to its own temporal identity and the saint is
* commemorated instead — same "demoted, not dropped" shape as every other
* (b) occasionally overrides the winner when the occurring saint doesn't
* outrank the strictest active octave's threshold, in which case the day
* reverts to its own temporal identity and the saint is commemorated
* instead — same "demoted, not dropped" shape as every other
* commemoration rule in this file.
*
* A *tie against an octave's own elevated closing day* goes to the
* octave, not the occurring saint — live-verified counterexample: St.
* Hyacinth (plain Duplex, Aug 17) against St. Lawrence's own octave
* closing day that same date (also Duplex, via `closingDayRank`'s
* default) — the reference engine's own alternate block for that date is
* titled "Commemoratio S. Hyacinthi Confessoris", i.e. Hyacinth is the
* one merely commemorated there, Lawrence's own elevated closing day
* keeps the office. Matches this file's own `collision.ts` precedent for
* the analogous sanctoral-vs-sanctoral tie ("Ties favor `native` — the
* incoming feast is the guest here"): the closing day is the
* already-running incumbent's own elevated day, an occurring saint is
* the guest, and a guest needs to actually outrank it to displace it,
* not just match it.
*
* A tie against an *ordinary* (non-closing) octave day's threshold still
* favors the occurring saint, unchanged from the original behavior —
* confirmed by two already-verified, live-sourced counterexamples this
* file's own tests carry: St. Thomas of Canterbury (plain Semiduplex,
* Dec 29) wins outright against the Christmas Octave's own ordinary
* `wins: semiduplex` default that day (not its closing day, Jan 1), and
* St. Nicholas of Tolentino (plain Semiduplex, Sep 10) likewise against
* the Nativity of the BVM's octave (day 3 of 8, not closing). Only a
* closing day's own elevated rank carries the "already the incumbent"
* weight that breaks a tie in the octave's favor.
*/
function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner {
const octaves = activeOctavesFor(isoDate);
@@ -294,11 +319,16 @@ function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commem
}
let resolvedWinner = winner;
if (winner.kind === 'sanctoral' && !isAtLeast(winner.rank, strictestThreshold(octaves))) {
const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
if (!isOneOfTheseOctaves) {
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
if (winner.kind === 'sanctoral') {
const threshold = strictestThreshold(octaves);
const cmp = compareFeastClass(winner.rank, threshold);
const tiedAgainstClosingDay = cmp === 0 && octaves.some((o) => o.isClosingDay && compareFeastClass(o.wins, threshold) === 0);
if (cmp < 0 || tiedAgainstClosingDay) {
const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
if (!isOneOfTheseOctaves) {
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
}
}
}
+6 -1
View File
@@ -27,6 +27,11 @@ export interface ActiveOctave {
wins: FeastClass;
/** 1 on the feast's own day, counting up from there. */
dayNumber: number;
/** Whether `dayNumber` is this octave's own final ("in Octava") day —
* i.e. whether `wins` above came from `closingDayRank` rather than the
* ordinary `wins` config. Used by calendar/index.ts's applyOctaves to
* decide which side a *tied* rank favors — see its own doc comment. */
isClosingDay: boolean;
}
const DEFAULT_DAYS = 8;
@@ -58,7 +63,7 @@ function considerCandidate(
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 });
active.push({ id, name, wins, dayNumber, isClosingDay });
}
/** Every octave (sanctoral or temporal) whose window covers `isoDate`,
@@ -0,0 +1,31 @@
# Verified against Divinum Officium (Monastic Tridentinum 1617) --
# read directly from the reference engine's own SanctiM/08-17bmv.txt
# ("Tertia die infra Octavam S. Assumptionis Beatæ Mariæ Virginis," per
# Sancti/08-17bmv.txt's own [Rank] line). Same recurring "Sermo sancti
# Joannis Damasceni" Nocturn 2 homily excerpt already used on day 2
# (assumption-octave-day-2.yml) and days 4-5 -- confirmed a standing,
# day-independent reading reused across the octave, not a copy-paste
# mistake -- collapsed into one continuous reading per project
# convention. No English translation exists in the source at all for
# this reading (English/Sancti/08-17bmv.txt has only the day's own
# [Rank] line, no lesson text), so en is left honestly missing rather
# than duplicated or translated here, same as day 2. Responsory is the
# same recurring "Vidi speciosam" already used on days 2, 4, and 5 --
# a standing octave-wide responsory, not day-specific.
id: assumption-octave-day-3
source: "Sermo sancti Joannis Damasceni"
text:
la: |
Sermo sancti Joánnis Damascéni Orat. 2 de Dormit. Deíparæ Jubilémus in arca Dómini Dei toto ánimo, et muri cadent Jerichuntíni, contrariárum, inquam, potestátum inféstæ munitiónes. Cum David exsultémus spíritu: Arca enim Dómini hódie requiévit. Clamémus cum Gabriéle, qui primum locum óbtinet inter Angelos: Ave, grátia plena; Dóminus tecum. Ave, gáudii pélagus inexháustum: ave, únicum molestiárum levámen: ave, ómnium cordis dolórum medicaméntum. Ave, sancta Virgo, per quam mors quidem fuit expúlsa, vita autem introdúcta.
Tu vero, o sacrórum sepulcrórum sacratíssimum post Dómini quidem sepúlcrum, quod vitæ dedit princípium, quod fuit fons resurrectiónis (tecum enim loquar, tamquam cum animáto): úbinam est aurum illud purum, quod manus Apostolórum in te condidérunt? Ubi divítiæ, quæ consúmi néqueunt? Ubi pretiósus ille thesáurus, qui vitam suscépit? Ubi novum volúmen, in quo ineffabíliter Dei Verbum sine manu inscríptum fuit? Ubi abýssus grátiæ, ubi pélagus curatiónum? Ubi est desiderábile illud Deíparæ Vírginis corpus?
Quid quǽritis in sepúlcro eam, quæ ad cæléstia transláta est tabernácula? Cur a me custódiæ ratiónem expóscitis? Non possum ego divínis jussis resístere. Sacrosánctum illud corpus, quod mihi quoque sanctitátem impertívit, ac pretiosíssimi unguénti fragrántia me replévit, et divínum templum effécit, relíctis sindónibus, ábiit raptum sublíme, comitántibus Angelis, Archángelis et ómnibus cæléstibus poténtiis. Nunc me circúmdant Angeli, nunc divína in me hábitat grátia. Ego evási officína medicínæ ægrotántibus: ego fons perénnis curatiónum: ego remédium advérsus dæmones: ego cívitas refúgii ómnibus ad me confugiéntibus.
responsory:
la: |
℟. Vidi speciósam sicut colúmbam, ascendéntem désuper rivos aquárum, cujus inæstimábilis odor erat nimis in vestiméntis ejus; * Et sicut dies verni circúmdabant eam flores rosárum et lília convállium. ℣. Quæ est ista quæ ascéndit per desértum sicut vírgula fumi ex aromátibus myrrhæ et thuris? ℟. Et sicut dies verni circúmdabant eam flores rosárum et lília convállium.
en: |
℟. I saw her, when, fair like a dove, she winged her flight above the rivers of waters. The priceless savour of her perfumes hung heavy in her garments. * And about her it was as the flower of roses in the spring of the year, and lilies of the valleys. ℣. Who is this that cometh out of the wilderness like a pillar of smoke, perfumed with myrrh and frankincense? ℟. And about her it was as the flower of roses in the spring of the year, and lilies of the valleys.
status:
la: verified
en: missing
+20 -15
View File
@@ -493,29 +493,34 @@ function octaveCommemorationPart(commemoration: Extract<Commemoration, { kind: '
* text surgery this doesn't attempt — so on a commemorated day, each
* collect here renders as its own complete, separate block instead.
*
* An octave commemoration only renders its own block here when
* `day.temporalCategory !== 'ordinary-feria'` — the same gate
* resolveOfficeWinner uses (see its own doc comment). On an *ordinary*
* octave day (e.g. an in-between day of St. Lawrence's own octave),
* resolveOfficeWinner has already substituted that octave's content as
* the primary collect above, so rendering it again here would be a
* redundant repeat (or worse, an honestly-"missing" duplicate for the
* many octaves without their own `-octave-commemoration.yml` authored).
* On a day the temporal identity itself keeps real standing (the
* Christmas Octave's own stacking days, e.g. Dec 26-31, where Christmas
* + Stephen + John + Holy Innocents are each merely commemorated
* alongside the day's own temporal office), resolveOfficeWinner does
* *not* touch the primary collect at all, so this is the only place
* these four octaves' real "Commemoratio Octavæ ..." content surfaces.
* An octave commemoration skips rendering its own block here only when
* it's the *specific* octave whose content resolveOfficeWinner already
* substituted as the primary collect above (same
* `day.temporalCategory === 'ordinary-feria'` gate, same
* `resolveActiveOctave` pick — see resolveOfficeWinner's own doc
* comment) — rendering it again would be a redundant repeat. Every
* *other* simultaneously-active octave still gets its own block: e.g.
* Aug 17, St. Lawrence's own elevated closing day and the Assumption's
* ordinary day 3 genuinely overlap — Lawrence's own content governs the
* primary collect (the higher-ranked of the two, resolveActiveOctave's
* own pick), but the Assumption's octave is still real and distinct, not
* a duplicate of Lawrence's, so it still renders its own commemoration
* block here (once authored). On a day the temporal identity itself
* keeps real standing (the Christmas Octave's own stacking days, e.g.
* Dec 26-31), resolveOfficeWinner never substitutes anything, so no
* octave is skipped and every active one renders here — the only place
* those four octaves' real "Commemoratio Octavæ ..." content surfaces.
*/
export function getDayCollects(day: LiturgicalDay): ResolvedPart[] {
const parts: ResolvedPart[] = [{ kind: 'prayer', text: getDayCollect(day) }];
const substitutedOctaveId =
day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date)?.id : undefined;
for (const commemoration of day.commemorations) {
if (commemoration.kind === 'temporal') {
parts.push({ kind: 'prayer', text: toResolvedText(getTemporalProper(`${commemoration.id}-collect`)) });
} else if (commemoration.kind === 'sanctoral') {
parts.push(sanctoralCommemorationPart(commemoration));
} else if (commemoration.kind === 'octave' && day.temporalCategory !== 'ordinary-feria') {
} else if (commemoration.kind === 'octave' && commemoration.id !== substitutedOctaveId) {
parts.push(octaveCommemorationPart(commemoration));
}
}