Compare commits

...

2 Commits

Author SHA1 Message Date
will 631d133b16 Apply the same psalm-number/antiphon split to Vespers' psalmody override
Deploy / deploy (push) Successful in 1m24s
Mirrors the Lauds fix: a proper override's psalm numbers stay gated at
duplex-majus+, while its antiphons still layer onto the ferial weekday's
own numbers below that rank, paired by group index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sPkfGLiq58pmmDoBWH5UX
2026-08-29 10:23:33 -04:00
will f733ffa71f Re-gate Lauds proper psalm numbers behind duplex-majus+, keep antiphons rank-agnostic
A feast's own proper psalm-group antiphons should surface at any rank
(f3cf30c), but the actual psalm numbers sung should only substitute at
duplex-majus+, per real Tridentine/Monastic rubric — a lower-rank feast
keeps the ferial psalm cycle and only gets proper text layered onto it.
Below duplex-majus, merge the proper override's antiphons onto the
ferial weekday's own psalm numbers/canticle, paired by group index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sPkfGLiq58pmmDoBWH5UX
2026-08-29 10:23:29 -04:00
2 changed files with 83 additions and 20 deletions
+54 -14
View File
@@ -50,18 +50,18 @@ interface PsalmodyBlock {
const laudsAntiphons = laudsAntiphonsData as Record<Weekday, PsalmodyBlock>; const laudsAntiphons = laudsAntiphonsData as Record<Weekday, PsalmodyBlock>;
/** /**
* Two independent tiers, tried in order: * Two independent tiers, tried in order, for a duplex-majus+ (or named-
* temporal) feast eligible to substitute its own psalm *numbers*, not
* just its antiphons — see resolvePsalmody below for the sub-duplex-majus
* case, which merges tier 1's antiphon text onto the ferial numbers
* instead of calling this at all:
* *
* 1. **Proper** — this feast's own authored antiphons (per-feast, not * 1. **Proper** — this feast's own authored antiphons+numbers (per-feast,
* per-Common — explicit choice), eligible at *any rank* via * not per-Common — explicit choice), via resolve-common.ts's
* resolve-common.ts's getPsalmodyProperOverrideId. A saint's own real * getPsalmodyProperOverrideId.
* proper text is never gated behind rank — see that function's own * 2. **Common category** — a generic-by-category substitute, gated at
* doc comment for why this used to be wrongly bundled with tier 2's * duplex-majus+ via getOfficeOverrideId (a real, distinct, weaker
* gate (fixed 2026-08-29). * tier: same conventional psalm numbers, not this feast's own text).
* 2. **Common category** — a generic-by-category substitute, still
* gated at duplex-majus+ via getOfficeOverrideId (a real, distinct,
* weaker tier: same conventional psalm numbers, not this feast's own
* text).
* *
* Falls back to `undefined` — the plain weekday default — for an eligible * Falls back to `undefined` — the plain weekday default — for an eligible
* feast with no override authored yet in either tier, same honest * feast with no override authored yet in either tier, same honest
@@ -86,6 +86,35 @@ function getPsalmodyOverrideFor(day: LiturgicalDay): LaudsPsalmodyOverride | und
return categoryId ? getLaudsCommonOverride(categoryId) : undefined; return categoryId ? getLaudsCommonOverride(categoryId) : undefined;
} }
/**
* Below duplex-majus, a feast's own proper psalm *numbers* never
* substitute for the ferial cycle (real Tridentine/Monastic rubric: only
* a strong-enough feast gets proper psalms — a lesser one keeps the
* ferial psalmody unchanged and gets its own proper text only at the
* Benedictus antiphon and chapter/responsory/hymn/versicle bundle,
* already-separate rank-agnostic mechanisms in resolve-common.ts). But a
* saint's own authored antiphon text for these same psalm slots is still
* real content worth showing (getPsalmodyProperOverrideId's own "any
* rank" eligibility) — so it's layered onto the ferial numbers here
* instead of being discarded outright.
*
* A proper override's own group count doesn't always match the ferial
* weekday's (e.g. Saturday's Monastic 2-group split vs. the usual 3), so
* pairing is by index against `ferial`'s own groups/canticle/laudate:
* ferial's numbers (and canticle id/split) always win; each slot's
* antiphon comes from `proper`'s same-index entry when one exists, else
* stays ferial's own. Any extra `proper` groups beyond `ferial`'s count
* are simply unused — confirmed with the user 2026-08-29 rather than
* assumed, since there's no rubric covering this specific mismatch.
*/
function mergeFerialNumbersWithProperAntiphons(ferial: PsalmodyBlock, proper: LaudsPsalmodyOverride): PsalmodyBlock {
return {
groups: ferial.groups.map((g, i) => ({ psalms: g.psalms, antiphon: proper.groups[i]?.antiphon ?? g.antiphon })),
canticle: { ...ferial.canticle, antiphon: proper.canticle?.antiphon ?? ferial.canticle.antiphon },
laudate: { antiphon: proper.laudate?.antiphon ?? ferial.laudate.antiphon },
};
}
// Mon-Fri share one capitulum verbatim (confirmed against the live engine // Mon-Fri share one capitulum verbatim (confirmed against the live engine
// — see lauds-capitulum-ferial.yml); Sunday and Saturday each have their // — see lauds-capitulum-ferial.yml); Sunday and Saturday each have their
// own. // own.
@@ -149,10 +178,21 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
} }
/** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody' /** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody'
* doc comment for the scope, and getPsalmodyOverrideFor above for when the * doc comment for the scope, getPsalmodyOverrideFor above for when the
* plain weekday default below gets substituted. */ * plain weekday default gets substituted wholesale (numbers and
* antiphons together, duplex-majus+ only), and
* mergeFerialNumbersWithProperAntiphons for the sub-duplex-majus case
* (ferial numbers, a saint's own antiphon text layered on top). */
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday]; const ferial = laudsAntiphons[day.weekday];
let wd: PsalmodyBlock;
if (getOfficeOverrideId(day)) {
wd = getPsalmodyOverrideFor(day) ?? ferial;
} else {
const properId = getPsalmodyProperOverrideId(day);
const proper = properId ? getLaudsSaintOverride(properId) : undefined;
wd = proper ? mergeFerialNumbersWithProperAntiphons(ferial, proper) : ferial;
}
const winner = resolveOfficeWinner(day); const winner = resolveOfficeWinner(day);
const opening = (antiphon: BilingualText) => openingAntiphon(verifiedText(antiphon), winner); const opening = (antiphon: BilingualText) => openingAntiphon(verifiedText(antiphon), winner);
const parts: ResolvedPart[] = [ const parts: ResolvedPart[] = [
+29 -6
View File
@@ -72,15 +72,18 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
} }
/** Two independent tiers, tried in order — same split as hours/lauds.ts's /** Two independent tiers, tried in order — same split as hours/lauds.ts's
* own getPsalmodyOverrideFor, see its doc comment for the full rationale: * own getPsalmodyOverrideFor, see its doc comment for the full rationale.
* Substitutes psalm *numbers*, not just antiphons, so only applies for a
* duplex-majus+ (or named-temporal) feast — see resolvePsalmody below for
* the sub-duplex-majus case, which merges tier 1's antiphons onto the
* ferial numbers instead of calling this at all:
* *
* 1. **Proper** — this feast's own authored psalmody, eligible at *any * 1. **Proper** — this feast's own authored psalmody, via resolve-
* rank* via resolve-common.ts's getPsalmodyProperOverrideId (a saint's * common.ts's getPsalmodyProperOverrideId.
* own real proper text is never gated behind rank).
* 2. **Common category** — per hours/vespers-psalmody-overrides.ts's own * 2. **Common category** — per hours/vespers-psalmody-overrides.ts's own
* doc comment, genuinely per-Common here (the psalm *numbers* * doc comment, genuinely per-Common here (the psalm *numbers*
* themselves differ by Common, not just the antiphons, unlike Lauds) * themselves differ by Common, not just the antiphons, unlike Lauds)
* — still gated at duplex-majus+ via getOfficeOverrideId. * — gated at duplex-majus+ via getOfficeOverrideId.
* *
* Eligibility follows the evening's governing `day` (same * Eligibility follows the evening's governing `day` (same
* resolveOfficeWinner-based test as the chapter/responsory/hymn/versicle * resolveOfficeWinner-based test as the chapter/responsory/hymn/versicle
@@ -98,6 +101,18 @@ function getPsalmodyOverrideFor(day: LiturgicalDay): VespersDay | undefined {
return categoryId ? getVespersCommonOverride(categoryId) : undefined; return categoryId ? getVespersCommonOverride(categoryId) : undefined;
} }
/** Below duplex-majus, mirrors hours/lauds.ts's identical
* mergeFerialNumbersWithProperAntiphons — real proper psalm numbers stay
* gated at duplex-majus+, but a saint's own authored antiphon text is
* still layered onto the ferial numbers when authored, paired by index
* against `ferial`'s own groups (extra `proper` groups beyond `ferial`'s
* count go unused — same confirmed convention as Lauds). */
function mergeFerialNumbersWithProperAntiphons(ferial: VespersDay, proper: VespersDay): VespersDay {
return {
groups: ferial.groups.map((g, i) => ({ psalms: g.psalms, antiphon: proper.groups[i]?.antiphon ?? g.antiphon })),
};
}
/** 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, substituted wholesale by getPsalmodyOverrideFor * groups in sequence, substituted wholesale by getPsalmodyOverrideFor
@@ -110,7 +125,15 @@ function getPsalmodyOverrideFor(day: LiturgicalDay): VespersDay | undefined {
* propers. `day` is still threaded into psalmParts for antiphon-fullness * propers. `day` is still threaded into psalmParts for antiphon-fullness
* resolution, which does care who governs tonight. */ * resolution, which does care who governs tonight. */
function resolvePsalmody(day: LiturgicalDay, weekday: Weekday): ResolvedPart[] { function resolvePsalmody(day: LiturgicalDay, weekday: Weekday): ResolvedPart[] {
const wd = getPsalmodyOverrideFor(day) ?? vespersAntiphons[weekday]; const ferial = vespersAntiphons[weekday];
let wd: VespersDay;
if (getOfficeOverrideId(day)) {
wd = getPsalmodyOverrideFor(day) ?? ferial;
} else {
const properId = getPsalmodyProperOverrideId(day);
const proper = properId ? getVespersSaintOverride(properId) : undefined;
wd = proper ? mergeFerialNumbersWithProperAntiphons(ferial, proper) : ferial;
}
return wd.groups.flatMap((group) => psalmParts(group, day)); return wd.groups.flatMap((group) => psalmParts(group, day));
} }