Fix Vespers ferial psalmody following an anticipated feast's identity

When tonight anticipates tomorrow's First Vespers, resolvePsalmody was
keying off the borrowed day's weekday instead of today's actual calendar
weekday, silently shifting the fixed 6-day psalm rotation. The psalm
cycle has no per-feast override and must stay on today's own place in
the rotation regardless of whose office governs the evening's propers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1LqodVbQdQVoTGPz9sSw4
This commit is contained in:
2026-08-28 19:46:33 -04:00
parent 4fdddbf4a2
commit 6a17a74b9b
2 changed files with 32 additions and 6 deletions
+18 -6
View File
@@ -1,6 +1,7 @@
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types'; import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveEveningDay } from '../calendar/vespers'; import { resolveEveningDay } from '../calendar/vespers';
import { weekdayOf } from '../calendar/weekday';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
@@ -70,9 +71,16 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
/** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm /** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm
* the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4 * the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4
* groups in sequence. No per-feast override mechanism yet — see * groups in sequence. No per-feast override mechanism yet — see
* hours/types.ts's 'vespers-psalmody' doc comment. */ * hours/types.ts's 'vespers-psalmody' doc comment. Deliberately keyed off
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { * `weekday` (today's actual calendar weekday), not `day.weekday` — `day`
const wd = vespersAntiphons[day.weekday]; * may be tomorrow's identity when tonight anticipates First Vespers (see
* resolveEveningDay), but the psalm cycle has no per-feast override and
* must stay on today's place in the fixed 6-day rotation regardless of
* whose office governs the evening's propers. `day` is still threaded
* into psalmParts for antiphon-fullness resolution, which does care who
* governs tonight. */
function resolvePsalmody(day: LiturgicalDay, weekday: Weekday): ResolvedPart[] {
const wd = vespersAntiphons[weekday];
return wd.groups.flatMap((group) => psalmParts(group, day)); return wd.groups.flatMap((group) => psalmParts(group, day));
} }
@@ -154,12 +162,12 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
]; ];
} }
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { function resolvePart(part: HourPart, day: LiturgicalDay, weekday: Weekday): ResolvedPart[] {
switch (part.kind) { switch (part.kind) {
case 'opening-versicle': case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'vespers-psalmody': case 'vespers-psalmody':
return resolvePsalmody(day); return resolvePsalmody(day, weekday);
case 'vespers-office': case 'vespers-office':
return resolveOffice(day); return resolveOffice(day);
case 'magnificat': { case 'magnificat': {
@@ -216,6 +224,10 @@ export function resolveOrdo(date: string): ResolvedOrdo {
// vespers.ts) — getDayCollects picks that up for free, no special // vespers.ts) — getDayCollects picks that up for free, no special
// handling needed here. // handling needed here.
const day = resolveEveningDay(date); const day = resolveEveningDay(date);
const parts = vespersDefinition.parts.flatMap((part) => resolvePart(part, day)); // Today's own weekday, independent of the possibly-anticipated `day`
// above — see resolvePsalmody's doc comment for why psalm selection
// must not follow the evening's borrowed identity.
const weekday = weekdayOf(date);
const parts = vespersDefinition.parts.flatMap((part) => resolvePart(part, day, weekday));
return { hourId: 'vespers', date, parts, dayLabel: getDayLabel(day) }; return { hourId: 'vespers', date, parts, dayLabel: getDayLabel(day) };
} }
+14
View File
@@ -235,6 +235,20 @@ describe('resolveOrdo("vespers", ...)', () => {
} }
}); });
it('keeps ferial psalmody on the actual calendar weekday even while the office anticipates tomorrow\'s First Vespers', () => {
// 2026-08-25 is a Tuesday, but its evening anticipates St. Zephyrinus's
// First Vespers (Aug 26) per the test just above -- the psalm cycle has
// no per-feast override (see hours/vespers.ts's resolvePsalmody), so it
// must stay on Tuesday's own group [129, 130, 131, 132], not slip to
// Wednesday's [134, 135, 136, 137] just because the office borrowed
// Wednesday's identity.
const ordo = resolveOrdo('vespers', '2026-08-25');
const psalmNumbers = ordo.parts
.filter((p) => p.kind === 'psalm')
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
expect(psalmNumbers).toEqual([129, 130, 131, 132]);
});
it("uses Common-of-Several-Martyrs' real office bundle on Ss. Placid and Companions' own day (Duplex II. classis, keeps own Vespers)", () => { it("uses Common-of-Several-Martyrs' real office bundle on Ss. Placid and Companions' own day (Duplex II. classis, keeps own Vespers)", () => {
// Live-verified against Divinum Officium (Monastic Tridentinum 1617) // Live-verified against Divinum Officium (Monastic Tridentinum 1617)
// votive=C3 -- see vespers-hymn-common-of-several-martyrs.yml's header. // votive=C3 -- see vespers-hymn-common-of-several-martyrs.yml's header.