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 // become a configurable choice later (the same day->id indirection
// philosophy already used for the sanctoral calendar), not hardcoded here // philosophy already used for the sanctoral calendar), not hardcoded here
// forever — just not built yet. // forever — just not built yet.
import type { LiturgicalDay } from './types'; import type { Commemoration, FeastClass, LiturgicalDay } from './types';
import { easterSunday } from './easter'; import { easterSunday } from './easter';
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math'; 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}`; 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 * The full "day being celebrated" label: a feast name when
* calendar/commemorations.ts says the day has one, combined with (or * 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 { export function getDayLabel(day: LiturgicalDay): string {
if (day.winner.kind === 'sanctoral') { 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, ...) // 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 // "the label says Lawrence" consistent instead of two independent
// guesses that can disagree. When more than one octave is active at // guesses that can disagree. When more than one octave is active at
// once (resolveActiveOctave), the highest-ranked wins the headline // once (resolveActiveOctave), the highest-ranked wins the headline
// (ties broken by whichever started more recently) — the others still // (ties broken by whichever started more recently) — every other
// ride along as ordinary octave commemorations, just not separately // active octave still gets named too (otherActiveOctaveNames), not
// named here. // 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; const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined;
if (activeOctave) { if (activeOctave) {
const primary = octaveLabel(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); const temporal = temporalLabel(day);
+36 -6
View File
@@ -3,7 +3,7 @@ import { weekdayOf } from './weekday';
import { resolveSeason, resolveTemporalCategory, sundayOnOrBefore } from './temporal'; import { resolveSeason, resolveTemporalCategory, sundayOnOrBefore } from './temporal';
import { resolveTemporalId } from './temporal-id'; import { resolveTemporalId } from './temporal-id';
import { getSanctoralCandidatesFor } from './feasts'; import { getSanctoralCandidatesFor } from './feasts';
import { decideOccurrence, isAtLeast, type OccurrenceResult } from './commemorations'; import { decideOccurrence, compareFeastClass, type OccurrenceResult } from './commemorations';
import { resolveCollision } from './collision'; import { resolveCollision } from './collision';
import { addDays, toIsoDate } from './date-math'; import { addDays, toIsoDate } from './date-math';
import { easterSunday } from './easter'; import { easterSunday } from './easter';
@@ -281,11 +281,36 @@ function applyMarianSaturday(
* Layered on top of everything above, not part of it: an octave doesn't * 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 * 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 * (a) adds a commemoration for every octave still active on this date, and
* (b) occasionally overrides the winner when the occurring saint is too * (b) occasionally overrides the winner when the occurring saint doesn't
* minor to clear the strictest active octave's threshold, in which case * outrank the strictest active octave's threshold, in which case the day
* the day reverts to its own temporal identity and the saint is * reverts to its own temporal identity and the saint is commemorated
* commemorated instead — same "demoted, not dropped" shape as every other * instead — same "demoted, not dropped" shape as every other
* commemoration rule in this file. * 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 { function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner {
const octaves = activeOctavesFor(isoDate); const octaves = activeOctavesFor(isoDate);
@@ -294,13 +319,18 @@ function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commem
} }
let resolvedWinner = winner; let resolvedWinner = winner;
if (winner.kind === 'sanctoral' && !isAtLeast(winner.rank, strictestThreshold(octaves))) { 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); const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
if (!isOneOfTheseOctaves) { if (!isOneOfTheseOctaves) {
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank }); commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) }; resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
} }
} }
}
for (const octave of octaves) { for (const octave of octaves) {
const isSelf = resolvedWinner.kind === 'sanctoral' && resolvedWinner.id === octave.id; const isSelf = resolvedWinner.kind === 'sanctoral' && resolvedWinner.id === octave.id;
+6 -1
View File
@@ -27,6 +27,11 @@ export interface ActiveOctave {
wins: FeastClass; wins: FeastClass;
/** 1 on the feast's own day, counting up from there. */ /** 1 on the feast's own day, counting up from there. */
dayNumber: number; 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; const DEFAULT_DAYS = 8;
@@ -58,7 +63,7 @@ function considerCandidate(
const dayNumber = offset + 1; const dayNumber = offset + 1;
const isClosingDay = dayNumber === days; const isClosingDay = dayNumber === days;
const wins = isClosingDay ? (octave.closingDayRank ?? DEFAULT_CLOSING_DAY_RANK) : (octave.wins ?? DEFAULT_WINS); 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`, /** 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 * text surgery this doesn't attempt — so on a commemorated day, each
* collect here renders as its own complete, separate block instead. * collect here renders as its own complete, separate block instead.
* *
* An octave commemoration only renders its own block here when * An octave commemoration skips rendering its own block here only when
* `day.temporalCategory !== 'ordinary-feria'` — the same gate * it's the *specific* octave whose content resolveOfficeWinner already
* resolveOfficeWinner uses (see its own doc comment). On an *ordinary* * substituted as the primary collect above (same
* octave day (e.g. an in-between day of St. Lawrence's own octave), * `day.temporalCategory === 'ordinary-feria'` gate, same
* resolveOfficeWinner has already substituted that octave's content as * `resolveActiveOctave` pick — see resolveOfficeWinner's own doc
* the primary collect above, so rendering it again here would be a * comment) — rendering it again would be a redundant repeat. Every
* redundant repeat (or worse, an honestly-"missing" duplicate for the * *other* simultaneously-active octave still gets its own block: e.g.
* many octaves without their own `-octave-commemoration.yml` authored). * Aug 17, St. Lawrence's own elevated closing day and the Assumption's
* On a day the temporal identity itself keeps real standing (the * ordinary day 3 genuinely overlap — Lawrence's own content governs the
* Christmas Octave's own stacking days, e.g. Dec 26-31, where Christmas * primary collect (the higher-ranked of the two, resolveActiveOctave's
* + Stephen + John + Holy Innocents are each merely commemorated * own pick), but the Assumption's octave is still real and distinct, not
* alongside the day's own temporal office), resolveOfficeWinner does * a duplicate of Lawrence's, so it still renders its own commemoration
* *not* touch the primary collect at all, so this is the only place * block here (once authored). On a day the temporal identity itself
* these four octaves' real "Commemoratio Octavæ ..." content surfaces. * 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[] { export function getDayCollects(day: LiturgicalDay): ResolvedPart[] {
const parts: ResolvedPart[] = [{ kind: 'prayer', text: getDayCollect(day) }]; 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) { for (const commemoration of day.commemorations) {
if (commemoration.kind === 'temporal') { if (commemoration.kind === 'temporal') {
parts.push({ kind: 'prayer', text: toResolvedText(getTemporalProper(`${commemoration.id}-collect`)) }); parts.push({ kind: 'prayer', text: toResolvedText(getTemporalProper(`${commemoration.id}-collect`)) });
} else if (commemoration.kind === 'sanctoral') { } else if (commemoration.kind === 'sanctoral') {
parts.push(sanctoralCommemorationPart(commemoration)); 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)); parts.push(octaveCommemorationPart(commemoration));
} }
} }
+25 -24
View File
@@ -24,7 +24,7 @@ describe('getDayLabel — ordinal temporal label', () => {
// tests/calendar/transfer.test.ts's own May 31 case for the full // tests/calendar/transfer.test.ts's own May 31 case for the full
// winner/commemorations breakdown), so the label now reflects her, // winner/commemorations breakdown), so the label now reflects her,
// not Trinity Sunday itself. // not Trinity Sunday itself.
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('The Queenship of the Blessed Virgin Mary'); expect(getDayLabel(resolveDay('2026-05-31'))).toBe('The Queenship of the Blessed Virgin Mary (Duplex II Class)');
expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter'); expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter');
expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday'); expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday');
}); });
@@ -82,12 +82,12 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
// genuinely saint-free overflow-week Monday instead (no single // genuinely saint-free overflow-week Monday instead (no single
// Nov-dated Monday near this Sunday pair is clean any more, now that // Nov-dated Monday near this Sunday pair is clean any more, now that
// Nov 4/8/15/16/20 are all fixed real content). // Nov 4/8/15/16/20 are all fixed real content).
expect(getDayLabel(resolveDay('2024-11-11'))).toBe('St. Martin of Tours, Bishop and Confessor'); expect(getDayLabel(resolveDay('2024-11-11'))).toBe('St. Martin of Tours, Bishop and Confessor (Duplex Majus)');
expect(getDayLabel(resolveDay('2024-11-17'))).toBe('The 6th Sunday after Epiphany'); expect(getDayLabel(resolveDay('2024-11-17'))).toBe('The 6th Sunday after Epiphany');
// 2024-11-18 is the Dedication of the Basilicas of Ss. Peter and Paul // 2024-11-18 is the Dedication of the Basilicas of Ss. Peter and Paul
// (pre-existing fixed content) -- again a real winning saint, not the // (pre-existing fixed content) -- again a real winning saint, not the
// ferial fallback. // ferial fallback.
expect(getDayLabel(resolveDay('2024-11-18'))).toBe('Dedication of the Basilicas of Ss. Peter and Paul'); expect(getDayLabel(resolveDay('2024-11-18'))).toBe('Dedication of the Basilicas of Ss. Peter and Paul (Duplex)');
// 2035-10-29, a Monday in the 3rd week of a different overflow // 2035-10-29, a Monday in the 3rd week of a different overflow
// stretch, is genuinely clean (no sanctoral entry, no commemoration) // stretch, is genuinely clean (no sanctoral entry, no commemoration)
// -- covers the ferial "Monday in the Nth week after Epiphany" format // -- covers the ferial "Monday in the Nth week after Epiphany" format
@@ -110,7 +110,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
// outright rather than being commemorated — a pre-existing rule, not // outright rather than being commemorated — a pre-existing rule, not
// new behavior from adding her. 2026-11-22 above (no saint collision) // new behavior from adding her. 2026-11-22 above (no saint collision)
// still covers the fixed-ordinal label itself. // still covers the fixed-ordinal label itself.
expect(getDayLabel(resolveDay('1943-11-21'))).toBe('The Presentation of the Blessed Virgin Mary'); expect(getDayLabel(resolveDay('1943-11-21'))).toBe('The Presentation of the Blessed Virgin Mary (Duplex Majus)');
}); });
}); });
@@ -125,8 +125,10 @@ describe('getDayLabel — active octave', () => {
it('shows the octave day alone when nothing is separately commemorated that day', () => { it('shows the octave day alone when nothing is separately commemorated that day', () => {
// Not Aug 17 (day 8, the last day, of St. Lawrence's octave): that // Not Aug 17 (day 8, the last day, of St. Lawrence's octave): that
// date is now St. Hyacinth's own fixed day (Duplex, added per the // date is also within the Assumption's own overlapping octave (day 3)
// full-year sanctoral import), which wins outright. Not Nov 8 either // and St. Hyacinth's own fixed day (Duplex) is commemorated there too
// — see the "picks the higher-ranked octave" case below for that
// three-way combination. Not Nov 8 either
// (day 8, the last day, of All Saints' own octave): the November // (day 8, the last day, of All Saints' own octave): the November
// sanctoral pull gave that day its own real feast, the Octave Day of // sanctoral pull gave that day its own real feast, the Octave Day of
// All Saints itself (Duplex majus, own proper Nocturn 2-3 content -- // All Saints itself (Duplex majus, own proper Nocturn 2-3 content --
@@ -155,23 +157,22 @@ describe('getDayLabel — active octave', () => {
expect(getDayLabel(day)).toBe('Friday in Christmastide'); expect(getDayLabel(day)).toBe('Friday in Christmastide');
}); });
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall", () => { it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => {
// Aug 17 itself is no longer available to demonstrate the // Live-verified against the reference engine directly (Monastic
// octave-vs-octave priority pick through a real date: it's now St. // Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S.
// Hyacinth's own fixed day (Duplex, added per the full-year // Laurentii Martyris ~ Duplex" -- Lawrence's own elevated closing day
// sanctoral import), which wins outright over both octaves. In // wins outright, with Hyacinth's own alternate-rubric block titled
// fact every day where St. Lawrence's (Aug 10-17) and the // "Commemoratio S. Hyacinthi Confessoris" confirming he's the one
// Assumption's (Aug 15-22) octaves genuinely overlap (Aug 15-17) is // merely commemorated there. A tie against an octave's own elevated
// now occupied by real sanctoral content (the Assumption herself, // closing day favors the octave (calendar/index.ts's applyOctaves),
// St. Joachim, St. Hyacinth) -- there's no remaining real calendar // unlike an ordinary mid-octave day's tie, which still favors an
// date left in this app where that specific priority comparison is // occurring saint (see octaves.test.ts and the December/September
// observable. The underlying mechanism itself (`pickWinningOctave`) // sanctoral tests for those counterexamples). The Assumption's own
// is still directly unit-tested with synthetic data in // day 3 (semiduplex, genuinely active but outranked by Lawrence's
// tests/calendar/octaves.test.ts's own "picks the higher-ranked // elevated duplex) still gets named, not dropped, alongside it.
// octave regardless of order or start date" case. Aug 18 (day 4 of expect(getDayLabel(resolveDay('2026-08-17'))).toBe(
// the Assumption's octave alone, Lawrence's already over) is still '8th Day within the Octave of St. Lawrence, Martyr — The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor',
// a valid, uncontested check. );
expect(getDayLabel(resolveDay('2026-08-17'))).toBe('St. Hyacinth, Confessor');
expect(getDayLabel(resolveDay('2026-08-18'))).toBe('4th Day within the Octave of The Assumption of the Blessed Virgin Mary'); expect(getDayLabel(resolveDay('2026-08-18'))).toBe('4th Day within the Octave of The Assumption of the Blessed Virgin Mary');
}); });
@@ -199,7 +200,7 @@ describe('getDayLabel — feast name combination', () => {
...base, ...base,
winner: { kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' }, winner: { kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' },
}; };
expect(getDayLabel(day)).toBe('St. Ereden'); expect(getDayLabel(day)).toBe('St. Ereden (Duplex)');
}); });
it('shows both, feast first, when the feast is merely commemorated', () => { it('shows both, feast first, when the feast is merely commemorated', () => {
+42 -8
View File
@@ -9,7 +9,9 @@ describe('activeOctavesFor', () => {
it("finds a saint's own octave on its own day (day 1) and through day 8, not on day 9", () => { it("finds a saint's own octave on its own day (day 1) and through day 8, not on day 9", () => {
const day1 = activeOctavesFor('2026-08-10'); // St. Lawrence's own day const day1 = activeOctavesFor('2026-08-10'); // St. Lawrence's own day
expect(day1).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1 }]); expect(day1).toEqual([
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1, isClosingDay: false },
]);
// Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) -- // Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) --
// the two run concurrently this time of year. Day 8 is Lawrence's own // the two run concurrently this time of year. Day 8 is Lawrence's own
@@ -17,8 +19,14 @@ describe('activeOctavesFor', () => {
// pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig). // pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig).
const day8 = activeOctavesFor('2026-08-17'); const day8 = activeOctavesFor('2026-08-17');
expect(day8).toEqual([ expect(day8).toEqual([
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8 }, { id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8, isClosingDay: true },
{ id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary', wins: 'semiduplex', dayNumber: 3 }, {
id: 'assumption',
name: 'The Assumption of the Blessed Virgin Mary',
wins: 'semiduplex',
dayNumber: 3,
isClosingDay: false,
},
]); ]);
// Aug 23: past both Lawrence's (ended Aug 17) and the Assumption's // Aug 23: past both Lawrence's (ended Aug 17) and the Assumption's
@@ -28,7 +36,9 @@ describe('activeOctavesFor', () => {
it("Pentecost's octave uses its own configured threshold (duplex), not the default (semiduplex)", () => { it("Pentecost's octave uses its own configured threshold (duplex), not the default (semiduplex)", () => {
const octaves = activeOctavesFor('2026-05-29'); // within Pentecost's octave, 2026 const octaves = activeOctavesFor('2026-05-29'); // within Pentecost's octave, 2026
expect(octaves).toEqual([{ id: 'pentecost-sunday', name: 'Pentecost', wins: 'duplex', dayNumber: 6 }]); expect(octaves).toEqual([
{ id: 'pentecost-sunday', name: 'Pentecost', wins: 'duplex', dayNumber: 6, isClosingDay: false },
]);
expect(strictestThreshold(octaves)).toBe('duplex'); expect(strictestThreshold(octaves)).toBe('duplex');
}); });
@@ -62,10 +72,34 @@ describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave preceden
// pickWinningOctave directly, since no real equal-rank overlap exists yet // pickWinningOctave directly, since no real equal-rank overlap exists yet
// in this app's own calendar to exercise the tie-break against live data. // in this app's own calendar to exercise the tie-break against live data.
const higher = (dayNumber: number): ActiveOctave => ({ id: 'higher', name: 'Higher', wins: 'duplex', dayNumber }); const higher = (dayNumber: number): ActiveOctave => ({
const lower = (dayNumber: number): ActiveOctave => ({ id: 'lower', name: 'Lower', wins: 'semiduplex', dayNumber }); id: 'higher',
const equalA = (dayNumber: number): ActiveOctave => ({ id: 'equal-a', name: 'Equal A', wins: 'duplex', dayNumber }); name: 'Higher',
const equalB = (dayNumber: number): ActiveOctave => ({ id: 'equal-b', name: 'Equal B', wins: 'duplex', dayNumber }); wins: 'duplex',
dayNumber,
isClosingDay: false,
});
const lower = (dayNumber: number): ActiveOctave => ({
id: 'lower',
name: 'Lower',
wins: 'semiduplex',
dayNumber,
isClosingDay: false,
});
const equalA = (dayNumber: number): ActiveOctave => ({
id: 'equal-a',
name: 'Equal A',
wins: 'duplex',
dayNumber,
isClosingDay: false,
});
const equalB = (dayNumber: number): ActiveOctave => ({
id: 'equal-b',
name: 'Equal B',
wins: 'duplex',
dayNumber,
isClosingDay: false,
});
it('picks the higher-ranked octave regardless of order or start date', () => { it('picks the higher-ranked octave regardless of order or start date', () => {
expect(pickWinningOctave([lower(2), higher(6)])?.id).toBe('higher'); expect(pickWinningOctave([lower(2), higher(6)])?.id).toBe('higher');