diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 9bf5faf..15f8c05 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -443,11 +443,18 @@ function collectCommemorations( for (const c of day.commemorations) { if (c.kind === 'sanctoral') { const name = biName(c.name, c.nameLa); - sanctoral.push( - c.vespersNote - ? bi(`${name.en} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].en})`, `${name.la} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].la})`) - : name, - ); + if (c.vespersNote) { + sanctoral.push(bi(`${name.en} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].en})`, `${name.la} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].la})`)); + } else if (c.transferredFrom) { + // Same "arrived via transfer, not natively due here" signal as a + // transferred-in *winner* gets — a reader shouldn't need to already + // know this saint's real date to realize a mere commemoration here + // is standing in for a transfer, not this date's own natural + // sanctoral standing. + sanctoral.push(bi(`${name.en} (${TRANSFERRED_LABEL.en})`, `${name.la} (${TRANSFERRED_LABEL.la})`)); + } else { + sanctoral.push(name); + } } else if (c.kind === 'temporal') { if (c.id === temporalId) { temporal.push(anchorDayName(day, false) ?? temporalLabel(day)); @@ -520,6 +527,44 @@ export function getVespersStatusLabel(day: LiturgicalDay, requestedDate: string) return bi('Second Vespers (of today)', 'Vesperæ de hodierno'); } +/** Appended after a transferred-in winner's name/rank, before any + * commemorations — same shape as a rank parenthetical. Distinct wording + * from `TRANSFERRED_AWAY_LABEL` below on purpose: this winner's own rank + * parenthetical sits right next to it ("St. X (Duplex, transferred)"), so + * plain "transferred" already reads as "arrived via transfer" in context — + * but on the origin date there's no rank alongside it to anchor that + * reading, so the away-note spells out the direction explicitly instead of + * relying on that same subtlety a first-time reader wouldn't have. */ +const TRANSFERRED_LABEL = bi('transferred', 'translata'); + +/** See `TRANSFERRED_LABEL`'s doc comment for why this is worded differently. */ +const TRANSFERRED_AWAY_LABEL = bi('transferred away', 'translata alio'); + +/** "St. X (transferred away)" note for a date whose own native sanctoral + * candidate (`day.transferredAway`) couldn't be kept here — appended after + * everything else `getDayLabel` already found for this date's real + * winner/commemorations, since the departure is a footnote about a + * *different* saint, not part of this date's own identity. Deliberately + * names no landing date (see `transferredAway`'s own doc comment) — the + * point is just to tell a reader "not an omission, this office moved + * elsewhere," not to claim a specific destination that a later, unmodeled + * transfer chain could make wrong. */ +function transferredAwayNote(day: LiturgicalDay): Bi | undefined { + if (!day.transferredAway) { + return undefined; + } + const name = biName(day.transferredAway.candidate.name, day.transferredAway.candidate.nameLa); + return bi(`${name.en} (${TRANSFERRED_AWAY_LABEL.en})`, `${name.la} (${TRANSFERRED_AWAY_LABEL.la})`); +} + +function withTransferNote(day: LiturgicalDay, label: Partial>): Partial> { + const note = transferredAwayNote(day); + if (!note) { + return label; + } + return { en: `${label.en} — ${note.en}`, la: `${label.la} — ${note.la}` }; +} + /** * The full "day being celebrated" label: the day's own primary identity * (a sanctoral winner, a named temporal feast, a season's own anchor day, @@ -547,8 +592,10 @@ export function getDayLabel(day: LiturgicalDay): Partial> }); const rank = formatRank(day.winner.rank); const winnerName = biName(day.winner.name, day.winner.nameLa); - const winner = bi(`${winnerName.en} (${rank.en})`, `${winnerName.la} (${rank.la})`); - return joinBi([winner, ...temporal, ...sanctoral, ...octaves]); + const winner = day.winner.transferredFrom + ? bi(`${winnerName.en} (${rank.en}, ${TRANSFERRED_LABEL.en})`, `${winnerName.la} (${rank.la}, ${TRANSFERRED_LABEL.la})`) + : bi(`${winnerName.en} (${rank.en})`, `${winnerName.la} (${rank.la})`); + return withTransferNote(day, joinBi([winner, ...temporal, ...sanctoral, ...octaves])); } // A named temporal feast (Christmas, Pentecost, Marian Saturday, ...) @@ -563,7 +610,7 @@ export function getDayLabel(day: LiturgicalDay): Partial> ? bi(`${name.en} (${formatRank(namedFeast.rank).en})`, `${name.la} (${formatRank(namedFeast.rank).la})`) : name; const { sanctoral } = collectCommemorations(day, { showOctaves: false }); - return joinBi([primary, ...sanctoral]); + return withTransferNote(day, joinBi([primary, ...sanctoral])); } // A season's own named anchor day (Trinity Sunday, Easter, Ash @@ -576,7 +623,7 @@ export function getDayLabel(day: LiturgicalDay): Partial> const anchorName = anchorDayName(day); if (anchorName) { const { sanctoral } = collectCommemorations(day, { showOctaves: false }); - return joinBi([anchorName, ...sanctoral]); + return withTransferNote(day, joinBi([anchorName, ...sanctoral])); } // An active octave (St. Lawrence's, ...) is this day's real primary @@ -612,12 +659,12 @@ export function getDayLabel(day: LiturgicalDay): Partial> if (activeOctave) { const { sanctoral, octaves } = collectCommemorations(day, { showOctaves: true, excludeOctaveId: activeOctave.id }); const primary = octaveLabel(activeOctave); - return joinBi([primary, ...octaves, ...sanctoral]); + return withTransferNote(day, joinBi([primary, ...octaves, ...sanctoral])); } const temporalRank = TEMPORAL_CATEGORY_RANK_LABELS[day.temporalCategory]; const label = temporalLabel(day); const temporal = bi(`${label.en} (${temporalRank.en})`, `${label.la} (${temporalRank.la})`); const { sanctoral } = collectCommemorations(day, { showOctaves: false }); - return joinBi([temporal, ...sanctoral]); + return withTransferNote(day, joinBi([temporal, ...sanctoral])); } diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 5f87bc7..c8c97eb 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -34,8 +34,12 @@ describe('getDayLabel — ordinal temporal label', () => { expect(getDayLabel(resolveDay('2026-05-31')).en).toBe( 'The Queenship of the Blessed Virgin Mary (Duplex II Class) — Trinity Sunday', ); - expect(getDayLabel(resolveDay('2026-04-05')).en).toBe('Easter (Duplex I Class)'); - expect(getDayLabel(resolveDay('2026-02-18')).en).toBe('Ash Wednesday'); + expect(getDayLabel(resolveDay('2026-04-05')).en).toBe( + "Easter (Duplex I Class) — St. Vincent Ferrer, Confessor (transferred away)", + ); + expect(getDayLabel(resolveDay('2026-02-18')).en).toBe( + "Ash Wednesday — St. Simeon, Bishop and Martyr (transferred away)", + ); // Latin: the winner now has a real authored nameLa (see // calendar/data/calendar/saints/queenship-of-the-bvm.yml), so this is // real Latin throughout — the rank label ("Duplex II. Classis") and @@ -44,8 +48,12 @@ describe('getDayLabel — ordinal temporal label', () => { expect(getDayLabel(resolveDay('2026-05-31')).la).toBe( 'Beata Maria Virgo Regina (Duplex II. Classis) — Dominica Sanctissimae Trinitatis', ); - expect(getDayLabel(resolveDay('2026-04-05')).la).toBe('Pascha (Duplex I. Classis)'); - expect(getDayLabel(resolveDay('2026-02-18')).la).toBe('Feria Quarta Cinerum'); + expect(getDayLabel(resolveDay('2026-04-05')).la).toBe( + 'Pascha (Duplex I. Classis) — Sanctus Vincentius Ferrerius, Confessor (translata alio)', + ); + expect(getDayLabel(resolveDay('2026-02-18')).la).toBe( + 'Feria Quarta Cinerum — Sanctus Simeon, Episcopus et Martyr (translata alio)', + ); }); it('gives the correct week-after-Trinity ordinal', () => { @@ -117,7 +125,9 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => expect(getDayLabel(resolveDay('2024-11-11')).en).toBe( 'St. Martin of Tours, Bishop and Confessor (Duplex Majus) — St. Menna, Martyr', ); - expect(getDayLabel(resolveDay('2024-11-17')).en).toBe('The 6th Sunday after Epiphany (Semiduplex)'); + expect(getDayLabel(resolveDay('2024-11-17')).en).toBe( + "The 6th Sunday after Epiphany (Semiduplex) — St. Gregory Thaumaturgus, Bishop and Confessor (transferred away)", + ); // 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 // ferial fallback -- with St. Gregory Thaumaturgus (Semiduplex, @@ -126,7 +136,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // transfers to the next open day rather than being commemorated in // place, and Nov 18 is that day. expect(getDayLabel(resolveDay('2024-11-18')).en).toBe( - 'Dedication of the Basilicas of Ss. Peter and Paul (Duplex) — St. Gregory Thaumaturgus, Bishop and Confessor', + 'Dedication of the Basilicas of Ss. Peter and Paul (Duplex) — St. Gregory Thaumaturgus, Bishop and Confessor (transferred)', ); // 2035-10-29, a Monday in the 3rd week of a different overflow // stretch, is genuinely clean (no sanctoral entry, no commemoration) @@ -147,9 +157,11 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // Thaumaturgus above -- landing on 2026-11-23 instead, where St. // Clement already natively wins; Cecilia loses that tied collision // (both Semiduplex) and joins St. Felicitas as a commemoration there. - expect(getDayLabel(resolveDay('2026-11-22')).en).toBe('The 23rd Sunday after Trinity (Semiduplex)'); + expect(getDayLabel(resolveDay('2026-11-22')).en).toBe( + "The 23rd Sunday after Trinity (Semiduplex) — St. Cecilia, Virgin and Martyr (transferred away)", + ); expect(getDayLabel(resolveDay('2026-11-23')).en).toBe( - 'St. Clement I, Pope and Martyr (Semiduplex) — St. Felicitas, Martyr — St. Cecilia, Virgin and Martyr', + 'St. Clement I, Pope and Martyr (Semiduplex) — St. Felicitas, Martyr — St. Cecilia, Virgin and Martyr (transferred)', ); // 1943-11-21 is also the Presentation of the BVM (Duplex Majus, added // 2026-08 — see presentation-of-the-bvm.yml). It's `ordinary-sunday` @@ -399,7 +411,7 @@ describe('getDayLabel/getVespersStatusLabel — Vespers evening with a temporal expect(day.date).toBe('2026-08-30'); expect(day.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-14' }); expect(getDayLabel(day).en).toBe( - 'The 13th Sunday after Trinity (Semiduplex) — Ss. Felix and Adauctus, Martyrs — The Beheading of St. John the Baptist (of today)', + "The 13th Sunday after Trinity (Semiduplex) — Ss. Felix and Adauctus, Martyrs — The Beheading of St. John the Baptist (of today) — St. Rose of Lima, Virgin (transferred away)", ); expect(getVespersStatusLabel(day, '2026-08-29').en).toBe('First Vespers (of tomorrow)'); });