From f8de318b7ff19faabb8d3395b5f91fea6d1079c4 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Mon, 24 Aug 2026 07:11:09 -0400 Subject: [PATCH] Fix Matins nocturn readings leaking suppressed Sunday content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nocturnReadingIds unconditionally pooled the plain temporal-cycle and month-week reading ids into every day's nocturn-reading pool, even when a real feast wins the day outright with zero commemorations (decideOccurrence's `ordinary-feria` branch, which correctly suppresses the temporal identity entirely). St. Bartholomew (duplex-2-classis, 2026-08-24, a Monday) has no Nocturn 3 content of his own, so the leftover 13th-Sunday-after-Pentecost and month-week readings wrongly filled his Nocturn 3. Gate the pooling on whether the day's own occurrence decision actually retained the temporal identity (day.winner.kind === 'temporal', or a `temporal`-kind entry in day.commemorations) — mirrors getDayCollects's existing pattern for the same question. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS --- src/hours/matins.ts | 21 ++++++++++++++++++--- tests/hours/matins.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 8cecbcc..a7c68f8 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -353,9 +353,24 @@ function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string) // nocturn-readings content too, not just a commemorated sanctoral one. if (c.kind === 'sanctoral' || c.kind === 'temporal') ids.add(c.id); } - ids.add(temporalId); - const monthWeek = monthWeekId(date); - if (monthWeek) ids.add(`month-week-${monthWeek}`); + // The plain temporalId/month-week content is only pooled when the day's + // own occurrence decision (calendar/commemorations.ts's decideOccurrence) + // actually retained the temporal identity in some form: the temporal + // cycle won outright (day.winner.kind === 'temporal' -- a plain ferial/ + // Sunday, or a named temporal override like Christ the King), or it + // survives as a commemoration alongside a sanctoral winner. Excluded: + // decideOccurrence's `ordinary-feria` branch, where a real feast -- + // however low-ranked -- wins with zero commemorations, correctly + // suppressing the temporal identity entirely (e.g. St. Bartholomew, + // duplex-2-classis, 2026-08-24 -- his own proper reading has no Nocturn + // 3 content, and without this gate the leftover 13th-Sunday-after- + // Pentecost/month-week content wrongly filled Nocturn 3 instead). + const temporalKept = day.winner.kind === 'temporal' || day.commemorations.some((c) => c.kind === 'temporal'); + if (temporalKept) { + ids.add(temporalId); + const monthWeek = monthWeekId(date); + if (monthWeek) ids.add(`month-week-${monthWeek}`); + } return [...ids]; } diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 55fdb48..4f6cc41 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -490,3 +490,38 @@ describe('resolveOrdo("matins", ...) calendar-month/week nocturn-readings import } }); }); + +// Real bug (2026-08-24 fix): nocturnReadingIds unconditionally pooled the +// plain temporalId/month-week ids into every day's nocturn-reading pool, +// even on a day where a real feast wins outright with zero commemorations +// (decideOccurrence's `ordinary-feria` branch) -- correctly suppressing the +// temporal identity entirely. St. Bartholomew (duplex-2-classis, 2026-08-24, +// a Monday) has no Nocturn 3 content of his own, so the leftover 13th- +// Sunday-after-Pentecost (post-pentecost-13.yml) and month-week (month- +// week-084.yml) readings wrongly filled his Nocturn 3. Fixed by gating that +// pooling on whether the day's own occurrence decision actually retained +// the temporal identity (day.winner.kind === 'temporal', or a `kind: +// 'temporal'` entry in day.commemorations). +describe('resolveOrdo("matins", ...) nocturn-reading temporal/month-week suppression (2026-08-24 fix)', () => { + function lessonLabels(date: string) { + const ordo = resolveOrdo('matins', date); + return ordo.parts.filter((p) => p.kind === 'lesson').map((p) => (p as { label?: string }).label); + } + + it("St. Bartholomew (2026-08-24, duplex-2-classis, zero commemorations) does not pool the suppressed Sunday/month-week readings", () => { + const labels = lessonLabels('2026-08-24'); + expect(labels).not.toContain('St. Augustine, Bishop of Hippo, Book 2, Questions on the Gospels, ch. 40'); + expect(labels).not.toContain('St. Gregory the Great, Moralia in Job, Book 1, ch. 10'); + }); + + it("St. Bartholomew's own Nocturn 2 still carries his own proper vita reading", () => { + const ordo = resolveOrdo('matins', '2026-08-24'); + const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { text: Record } }[]; + expect(lessons.some((l) => l.text.text.la?.includes('Bartholomǽus Apóstolus'))).toBe(true); + }); + + it('a plain ferial weekday with no sanctoral winner (2026-09-04, a gap day in sanctoral-calendar.yml) still pools the plain temporal/month-week readings, unaffected by the gate', () => { + const ordo = resolveOrdo('matins', '2026-09-04'); + const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { status: Record } }[]; + expect(lessons.length).toBeGreaterThan(0); + expect(lessons.some((l) => l.text.status.la !== 'missing' && l.text.status.en !== 'missing')).toBe(true);