diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 4c01d9c..bd43821 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -78,7 +78,7 @@ const EMBER_DAY_NAMES: Record = { 'ember-advent-saturday': bi('Ember Saturday in Advent', 'Sabbato Quattuor Temporum in Adventu'), }; -function emberDayLabel(id: string): Bi | undefined { +export function emberDayLabel(id: string): Bi | undefined { return EMBER_DAY_NAMES[id]; } @@ -488,9 +488,22 @@ function collectCommemorations( sanctoral.push(name); } } else if (c.kind === 'temporal') { - if (c.id === temporalId) { - temporal.push(anchorDayName(day, false) ?? temporalLabel(day)); - } else { + // `c.id === temporalId` (the plain governing weekday, e.g. "Saturday + // in the 15th week after Trinity") is deliberately never shown here: + // this function only ever runs to list *secondary* identities + // alongside an already-displayed primary winner (a plain temporal + // day that wins outright never reaches `day.commemorations` at all — + // its own name is the header itself, via a different branch of + // `getDayLabel`). Once something else is already being celebrated, + // restating "this is also just the Nth ordinary weekday" tells the + // reader nothing they don't already know from the winner+rank shown + // — unlike a *named* temporal identity (an Ember day, a vigil), which + // genuinely adds information. Not Ember-specific (a 2026-09-05 first + // pass wrongly scoped this to "only when an Ember commemoration is + // also present" — user, 2026-09-05: "it has nothing to do with + // ember-ness"); this plain generic label is dropped unconditionally + // whenever it shows up as a mere commemoration, Ember day or not. + if (c.id !== temporalId) { const emberName = emberDayLabel(c.id); if (emberName) { temporal.push(emberName); diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 97c9b69..dc8856b 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -71,7 +71,7 @@ import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './ import type { LiturgicalDay, DayWinner } from '../calendar/types'; import { resolveDay, resolveTemporalId, monthWeekId, activeOctavesFor, resolveActiveOctave, isAtLeast } from '../calendar'; import { isInTriduum } from '../calendar/temporal'; -import { getDayLabel } from '../calendar/day-label'; +import { getDayLabel, emberDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getPsalmsFor, type PsalmRef } from '../psalter/distribution'; import { getScriptureVerses } from '../scripture'; @@ -692,9 +692,37 @@ function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string, if (day.winner.kind === 'sanctoral' || day.winner.id !== temporalId || threeNocturns) { ids.add(day.winner.id); } + // Two passes, not one: every *temporal* commemoration's id is added + // before any *sanctoral* commemoration's, matching `day-label.ts`'s own + // `collectCommemorations` convention (`[...temporal, ...sanctoral]`) — + // so a reading's pool position agrees with where its own occasion is + // named in the day's displayed label. `day.commemorations`' own array + // order doesn't already reflect this grouping (live-verified as a real + // bug, 2026-09-19: St. Januarius's readings listed the Vigil of St. + // Matthew's homily before Ember Saturday's, backwards from the label's + // own "... — Ember Saturday in September — Vigil of St. Matthew + // (transferred)"), so it must be imposed here rather than assumed. for (const c of day.commemorations) { - // Same reasoning as day.winner.id just above. - if (c.kind === 'sanctoral' || (c.kind === 'temporal' && (c.id !== temporalId || threeNocturns))) ids.add(c.id); + // Same reasoning as day.winner.id just above, but a *commemorated* + // (not winning) temporal identity that equals the plain governing- + // Sunday `temporalId` is exactly the "Commemoratio Feriæ" case the + // `day.weekday === 'sunday'` gate below exists to guard (see that + // gate's own long comment) — a Duplex+ saint winning outright on a + // *weekday* (e.g. St. Joseph of Cupertino, Friday Sept 18) still has + // `threeNocturns` true, so `c.id !== temporalId || threeNocturns` + // alone let the week's separate governing-Sunday homily (Ambrose's, + // live-verified 2026-09-05) sneak back into a weekday's pool through + // this loop even though the gate below correctly kept it out of the + // `temporalKept` block. `c.id !== temporalId` must hold on its own + // unless today genuinely *is* that Sunday. + if (c.kind === 'temporal' && (c.id !== temporalId || (threeNocturns && day.weekday === 'sunday'))) { + ids.add(c.id); + } + } + for (const c of day.commemorations) { + if (c.kind === 'sanctoral') { + ids.add(c.id); + } } // The plain temporalId/month-week content is the *governing Sunday's own* // Nocturn 2/3 patristic material, real content for that Sunday itself — @@ -753,6 +781,28 @@ function fallbackHagiographicLabel(id: string): string | undefined { return name.startsWith('The ') ? `On the ${name.slice(4)}` : `On ${name}`; } +/** The human-readable occasion a `nocturn-readings` id stands for — a + * saint's or named temporal feast's own display name, or an Ember day's + * (which has no `TemporalFeastRecord` of its own, see `emberDayLabel`'s own + * doc comment). Used to annotate an authored patristic `source` with *why* + * it's here, since a single day can pool several ids' worth of readings + * (e.g. a commemorated Ember day's own Gospel homily alongside the winning + * saint's own) and a bare "Pope St. Gregory the Great, Homily 33" doesn't + * tell a reader which occasion that homily belongs to. */ +function occasionName(id: string): string | undefined { + return getSaintRecord(id)?.name ?? getTemporalFeastRecord(id)?.name ?? emberDayLabel(id)?.en; +} + +/** Appends `(for )` to an authored patristic `source` string, + * when the contributing id resolves to a human-readable occasion name — + * see `occasionName`. A no-op when `source` is undefined (the hagiographic + * fallback case, which already names its own saint). */ +function withOccasion(source: string | undefined, id: string): string | undefined { + if (!source) return source; + const occasion = occasionName(id); + return occasion ? `${source} (for ${occasion})` : source; +} + /** `index` is this reading's position among this same id's own * nocturn-readings (not the whole day's pool) — the cycling key for * `getResponsoryForCommon` when `r` has no proper `responsory` of its own, @@ -764,7 +814,7 @@ function nocturnReadingPart(r: NocturnReading, id: string, index: number): Resol return { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, - label: r.source ?? fallbackHagiographicLabel(id), + label: withOccasion(r.source, id) ?? fallbackHagiographicLabel(id), responsory: responsoryText ? { text: responsoryText, status: { la: 'verified', en: 'verified' } } : undefined, }; } @@ -779,15 +829,23 @@ function nocturnReadingPart(r: NocturnReading, id: string, index: number): Resol * were). */ function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined, id: string, index: number): ResolvedPart { const incipit = getGospelIncipitFromCitation(r.citation); - const responsoryText = r.responsory ?? getResponsoryForCommon(getSaintRecord(id)?.common, index); + // A responsory authored on the *homily* (the reference engine's own + // convention — e.g. post-pentecost-16.yml's Ambrose homily, or a saint's + // own Gospel-homily pair) belongs to the pericope+homily as a whole, not + // just the bare Gospel text, and must fall back here before the generic + // Common-category pool — this file's own header says a governing + // Sunday's homily "and its responsory" are pooled together; only `r`'s + // own field was ever checked, silently dropping every authored homily + // responsory (found 2026-09-05 while live-verifying an unrelated report). + const responsoryText = r.responsory ?? homily?.responsory ?? getResponsoryForCommon(getSaintRecord(id)?.common, index); return { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, - source: r.source, + source: withOccasion(r.source, id), label: incipit ? { la: incipit.la, en: incipit.en } : undefined, responsory: responsoryText ? { text: responsoryText, status: { la: 'verified', en: 'verified' } } : undefined, homily: homily - ? { source: homily.source, text: { text: homily.text, status: homily.status, citation: homily.citation } } + ? { source: withOccasion(homily.source, id), text: { text: homily.text, status: homily.status, citation: homily.citation } } : undefined, }; } @@ -827,18 +885,40 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string, : { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory, label }, ); } - // Grouped by the reading's own `nocturn` tag (ascending), not by which id - // contributed it — a later post-Pentecost Sunday's Nocturn 2 now comes - // from a different source (the month-week id) than its Nocturn 3 (its own - // temporalId file), and pooling in plain id order would put that Nocturn - // 3 content ahead of the Nocturn 2 content supplied by a later-processed - // id. Within each nocturn-number group, id order (and each file's own - // reading order) is preserved, matching this pool's usual priority rule. - // Not hardcoded to [2, 3]: Ember days' own nocturn-readings files use - // `nocturn: 1` (their single-nocturn structure), so every tag present - // must be handled, not just the usual Sunday/feast pair. const ids = nocturnReadingIds(day, temporalId, date, threeNocturns); - const byNocturn = new Map(); + // Ordering is by *id priority* (winner first, then commemorations in + // `day.commemorations` order — a winning saint's own content always + // precedes a demoted commemoration's, matching the day-label convention + // of listing the winner first), with one deliberate exception: the + // temporalId itself and `month-week-${monthWeek}` are split halves of + // ONE governing Sunday's own Nocturn 2/3 content (see + // `nocturnReadingIds`'s `temporalKept` block) — pooling those two in + // plain id-priority order would put the temporalId file's own Nocturn 3 + // content ahead of month-week's Nocturn 2 content, so this pair alone is + // grouped by raw `nocturn` tag (ascending) instead. Every other id is its + // own atomic block: previously *every* id was grouped globally by raw + // `nocturn` tag, which wrongly sorted a winning saint's own content after + // a demoted commemoration's whenever the commemoration's file happened to + // use a lower tag — e.g. an Ember day's single-nocturn file always tags + // `nocturn: 1`, sorting it ahead of a winning Duplex+ saint's own + // nocturn:2/3 content even though the saint, not the Ember day, is what's + // actually being celebrated (user, 2026-09-05, re: St. Joseph of + // Cupertino/Ember Friday, Sept 18). Within a single id's own block, + // `nocturn` tag order (== that file's own reading order) is still used to + // order its own multiple readings relative to each other. + const monthWeek = monthWeekId(date); + const splitIds = new Set(threeNocturns && day.weekday === 'sunday' && monthWeek ? [temporalId, `month-week-${monthWeek}`] : []); + let splitPriority = -1; + const priorityOf = new Map(); + ids.forEach((id, i) => { + if (splitIds.has(id)) { + if (splitPriority === -1) splitPriority = i; + priorityOf.set(id, splitPriority); + } else { + priorityOf.set(id, i); + } + }); + const byGroup = new Map(); // The reference engine's own Tempora files sometimes independently reuse // the exact same excerpt across two different Sunday-numbering schemes // (e.g. a Pentecost-numbered Sunday and a calendar-month Sunday landing on @@ -856,7 +936,8 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string, const textKey = reading.text.la ?? reading.text.en ?? ''; if (textKey && seenReadingText.has(textKey)) continue; if (textKey) seenReadingText.add(textKey); - const bucket = byNocturn.get(reading.nocturn) ?? []; + const groupKey = `${priorityOf.get(id)}:${reading.nocturn}`; + const bucket = byGroup.get(groupKey) ?? []; if (reading.isGospel) { // A genuine separate homily on this pericope is the very next // reading in the same file, in the same nocturn, not itself flagged @@ -872,11 +953,16 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string, } else { bucket.push(nocturnReadingPart(reading, id, commonPoolIndex++)); } - byNocturn.set(reading.nocturn, bucket); + byGroup.set(groupKey, bucket); } } - for (const nocturnNumber of [...byNocturn.keys()].sort((a, b) => a - b)) { - parts.push(...byNocturn.get(nocturnNumber)!); + const sortedGroupKeys = [...byGroup.keys()].sort((a, b) => { + const [aPriority, aNocturn] = a.split(':').map(Number); + const [bPriority, bNocturn] = b.split(':').map(Number); + return aPriority! - bPriority! || aNocturn! - bNocturn!; + }); + for (const key of sortedGroupKeys) { + parts.push(...byGroup.get(key)!); } for (const octave of activeOctavesFor(date)) { const reading = getOctaveReading(octave.id, octave.dayNumber); diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 1d35542..9e730bf 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -80,8 +80,12 @@ describe('getDayLabel — ordinal temporal label', () => { // for a Duplex II. classis) — live-verified 2026-09-01, the real // Monastic 1617 engine shows "S. Andreæ Apostoli ~ Duplex II. classis" // there outright, "Tempora: Feria II infra Hebdomadam I Adventus" - // underneath, matching this. - expect(getDayLabel(resolveDay('2025-12-01')).en).toBe('St. Andrew, Apostle (Duplex II Class, transferred) — In the 1st week of Advent'); + // underneath, matching this. The plain "In the 1st week of Advent" + // feria commemoration this day also carries internally (day.commemorations + // still has it) is no longer shown in the label (2026-09-05: a plain + // temporal commemoration is dropped from the label whenever anything + // else is already being celebrated — general, not Ember-specific). + expect(getDayLabel(resolveDay('2025-12-01')).en).toBe('St. Andrew, Apostle (Duplex II Class, transferred)'); // Dec 4 (St. Barbara, merely commemorated, not high-ranked enough to // win outright) is a clean example of the season-ordinal label itself. // Advent drops the redundant leading weekday word now that the header @@ -180,29 +184,29 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // one, carry no special first-class standing of their own), so // commemorations.ts's `ordinary-sunday` case's plain Duplex+ threshold // is what lets her win outright rather than being commemorated — a - // pre-existing rule, not new behavior from adding her. This case - // additionally confirms the displaced Sunday is still commemorated in - // return, using the same fixed 23rd-after-Trinity ordinal, not a raw - // elapsed-week count recomputed for 1943. - expect(getDayLabel(resolveDay('1943-11-21')).en).toBe( - 'The Presentation of the Blessed Virgin Mary (Duplex Majus) — The 23rd Sunday after Trinity', - ); + // pre-existing rule, not new behavior from adding her. The displaced + // Sunday is still commemorated internally (day.commemorations), using + // the same fixed 23rd-after-Trinity ordinal, not a raw elapsed-week + // count recomputed for 1943 — but per the 2026-09-05 label rule, a + // plain temporal commemoration like this one is no longer shown in the + // label once something else already has the primary slot. + expect(getDayLabel(resolveDay('1943-11-21')).en).toBe('The Presentation of the Blessed Virgin Mary (Duplex Majus)'); }); }); describe('getDayLabel — commemorated Sunday/feria under a sanctoral winner', () => { - it('a saint winning outright on a privileged-feria-minor day still leaves that feria commemorated', () => { + it('a saint winning outright on a privileged-feria-minor day still leaves that feria commemorated internally, but the label no longer restates it', () => { // 2027-12-07, a Tuesday of Advent: St. Ambrose (Duplex) clears // privileged-feria-minor's own Semiduplex+ threshold and wins - // outright, but Advent's own feria is still commemorated in return - // (`decideOccurrence`'s `privileged-feria-minor` branch) -- live- - // verified shape, same mechanism as the ordinary-Sunday case above, - // for the feria-tier branch instead. - // Advent drops the redundant leading weekday word (see the Dec 1 case - // above). - expect(getDayLabel(resolveDay('2027-12-07')).en).toBe( - 'St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex) — In the 2nd week of Advent', - ); + // outright, and Advent's own feria is still commemorated in return + // internally (`decideOccurrence`'s `privileged-feria-minor` branch) -- + // live-verified shape, same mechanism as the ordinary-Sunday case + // above, for the feria-tier branch instead. But per the 2026-09-05 + // label rule, a plain temporal commemoration (a bare "In the Nth week + // of Advent") is dropped from the label once something else already + // has the primary slot -- it isn't Ember-specific, and it applies here + // too even though this is a feria, not an Ember day. + expect(getDayLabel(resolveDay('2027-12-07')).en).toBe('St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex)'); }); }); @@ -289,7 +293,7 @@ describe('getDayLabel — active octave', () => { ); }); - it('a sanctoral winner on a Sunday still leaves the Sunday commemorated, but neither octave it also happens to overlap -- not an octave name at all', () => { + it('a sanctoral winner on a Sunday still leaves the Sunday commemorated internally, but neither octave it also happens to overlap -- not an octave name at all, nor the plain Sunday ordinal any more', () => { // 2026-08-16 is a Sunday genuinely within both St. Lawrence's and the // Assumption's overlapping octave windows -- St. Joachim (Duplex II // Class) wins outright per the ordinary-Sunday Duplex+ threshold, not @@ -297,14 +301,17 @@ describe('getDayLabel — active octave', () => { // this case existed). Live-verified against Divino Afflatu 1954: the // commemoration line reads only "Dominica XII Post Pentecosten" (this // app's own Trinity-counted display is one week off from that - // Pentecost-counted number, so "11th ... after Trinity" here) -- no - // octave named at all, even though both are technically active, same - // as this app's own established `ordinary-feria`-only gate for - // octave mentions alongside a sanctoral winner. + // Pentecost-counted number, so "11th ... after Trinity" internally) -- + // no octave named at all, even though both are technically active, + // same as this app's own established `ordinary-feria`-only gate for + // octave mentions alongside a sanctoral winner. Per the 2026-09-05 + // label rule, the plain Sunday commemoration itself is now also + // dropped from the label (still present in day.commemorations) once + // a sanctoral winner already has the primary slot. const day = resolveDay('2026-08-16'); expect(day.weekday).toBe('sunday'); expect(getDayLabel(day).en).toBe( - 'St. Joachim, Confessor, Father of the Blessed Virgin Mary (Duplex II Class) — The 11th Sunday after Trinity', + 'St. Joachim, Confessor, Father of the Blessed Virgin Mary (Duplex II Class)', ); expect(getDayLabel(day).en).not.toContain('Octave'); });