From f3cf30c1c78cebb52ad000854ca631cf6d00a639 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sat, 29 Aug 2026 07:34:48 -0400 Subject: [PATCH] Stop gating Lauds/Vespers proper psalmody antiphons behind rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPsalmodyOverrideFor bundled "which psalm numbers are said" and "which antiphons are used" into one lookup, both gated behind the same duplex-majus+ rank threshold. A saint's own authored proper antiphon text should never be gated behind rank — only the weaker, generic Common-category substitute has any business being rank-gated. Splits the lookup into two independent tiers: the feast's own proper (any rank, via the new getPsalmodyProperOverrideId) tried first, then the existing duplex-majus+ Common-category fallback — mirroring the split getMinorHourOverrideId already made for the chapter/responsory/hymn/ versicle bundle. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VFGHb4XMe6Wya4pGpPhEEi --- src/hours/lauds.ts | 47 +++++++++++++++++++++++++------------ src/hours/resolve-common.ts | 28 ++++++++++++++++++++++ src/hours/vespers.ts | 40 ++++++++++++++++++++----------- 3 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 80c52a5..eddfdff 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -4,7 +4,7 @@ import { resolveDay } from '../calendar'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getCanticle } from './lauds-canticles'; -import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; +import { getLaudsSaintOverride, getLaudsCommonOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { applyFlexaMark } from './antiphon'; @@ -19,6 +19,7 @@ import { seasonalOfficeSuffix, isFerialOrVigil, getOfficeOverrideId, + getPsalmodyProperOverrideId, resolveResponsory, resolveOfficeBundle, resolveSuffrages, @@ -49,24 +50,40 @@ interface PsalmodyBlock { const laudsAntiphons = laudsAntiphonsData as Record; /** - * A duplex-majus+ saint's own proper psalmody (per-feast, not per-Common — - * explicit choice), or one of the named temporal feasts eligible via - * resolve-common.ts's getOfficeOverrideId. Falls back to `undefined` — the - * plain weekday default — for an eligible feast with no override authored - * yet, same honest incremental-content convention as everywhere else in - * this codebase; it's not gated behind whether content exists, just - * behind whether it's *eligible* to override at all. + * Two independent tiers, tried in order: * - * getOfficeOverrideId itself checks resolveOfficeWinner(day), not the raw - * `day.winner` — on an octave day this is the octave's own feast (e.g. St. - * Lawrence, days 2-8 of his own octave), which is exactly what should - * supply the psalmody override there too, live-verified alongside the - * collect/Benedictus-antiphon fix (see resolve-common.ts's + * 1. **Proper** — this feast's own authored antiphons (per-feast, not + * per-Common — explicit choice), eligible at *any rank* via + * resolve-common.ts's getPsalmodyProperOverrideId. A saint's own real + * proper text is never gated behind rank — see that function's own + * doc comment for why this used to be wrongly bundled with tier 2's + * gate (fixed 2026-08-29). + * 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 + * feast with no override authored yet in either tier, same honest + * incremental-content convention as everywhere else in this codebase; + * it's not gated behind whether content exists, just behind whether it's + * *eligible* to override at all. + * + * Both id-eligibility functions check resolveOfficeWinner(day), not the + * raw `day.winner` — on an octave day this is the octave's own feast + * (e.g. St. Lawrence, days 2-8 of his own octave), which is exactly what + * should supply the psalmody override there too, live-verified alongside + * the collect/Benedictus-antiphon fix (see resolve-common.ts's * resolveOfficeWinner doc comment). */ function getPsalmodyOverrideFor(day: LiturgicalDay): LaudsPsalmodyOverride | undefined { - const id = getOfficeOverrideId(day); - return id ? getLaudsPsalmodyOverride(id) : undefined; + const properId = getPsalmodyProperOverrideId(day); + const proper = properId ? getLaudsSaintOverride(properId) : undefined; + if (proper) { + return proper; + } + const categoryId = getOfficeOverrideId(day); + return categoryId ? getLaudsCommonOverride(categoryId) : undefined; } // Mon-Fri share one capitulum verbatim (confirmed against the live engine diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 23b2c98..ce42767 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -180,6 +180,34 @@ export function getOfficeOverrideId(day: LiturgicalDay): string | undefined { return undefined; } +/** + * The id to look up for today's winner's own *proper* Lauds/Vespers + * psalmody antiphons (`hours/lauds.ts`'s and `hours/vespers.ts`'s own + * `getPsalmodyOverrideFor`) — eligible at **any rank**, unlike + * `getOfficeOverrideId` above. A saint's own authored proper antiphon + * text isn't something rank should ever gate — rank only has standing to + * gate the weaker, generic Common-category substitute (still looked up + * via `getOfficeOverrideId`'s duplex-majus+ threshold, a real distinct + * tier: same conventional psalm numbers, a generic-by-category antiphon, + * not this feast's own text). Mirrors `getMinorHourOverrideId`'s identical + * "any sanctoral winner is eligible, it's the caller that decides whether + * anything's actually authored for the id" reasoning — the psalmody + * override table previously reused `getOfficeOverrideId`'s single + * duplex-majus+ gate for both its proper and Common tiers at once, which + * silently discarded a proper antiphon whenever it happened to be + * authored for a sub-duplex-majus feast (fixed 2026-08-29). + */ +export function getPsalmodyProperOverrideId(day: LiturgicalDay): string | undefined { + const winner = resolveOfficeWinner(day); + if (winner.kind === 'temporal' && ALWAYS_OVERRIDE_TEMPORAL_IDS.has(winner.id)) { + return winner.id; + } + if (winner.kind === 'sanctoral') { + return winner.id; + } + return undefined; +} + /** * The id to look up for today's office winner's (resolveOfficeWinner) * minor-hour content (antiphon + chapter) — `${hourId}-antiphon-${id}`/ diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index c7f7024..edcfe62 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -5,7 +5,7 @@ import { weekdayOf } from '../calendar/weekday'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { getVespersPsalmodyOverride } from './vespers-psalmody-overrides'; +import { getVespersSaintOverride, getVespersCommonOverride } from './vespers-psalmody-overrides'; import { applyFlexaMark } from './antiphon'; import { resolveCommon, @@ -18,6 +18,7 @@ import { seasonalOfficeSuffix, isFerialOrVigil, getOfficeOverrideId, + getPsalmodyProperOverrideId, resolveResponsory, resolveOfficeBundle, resolveSuffrages, @@ -70,20 +71,31 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] { return parts; } -/** A duplex-majus+ saint's own proper Vespers psalmody (none authored - * yet), or their Common's (live-verified 2026-08-28, see - * hours/vespers-psalmody-overrides.ts's own doc comment for why this - * mechanism is genuinely per-Common — unlike Lauds, the psalm *numbers* - * themselves differ by Common here, not just the antiphons). Eligibility - * follows the evening's governing `day` (same getOfficeOverrideId test - * as the chapter/responsory/hymn/versicle bundle below) — a First - * Vespers of a majus+ feast should get that feast's own psalms, same as - * it gets that feast's own office. Falls back to `undefined` for an - * eligible feast with no override authored yet, same honest incremental- - * content convention as everywhere else. */ +/** Two independent tiers, tried in order — same split as hours/lauds.ts's + * own getPsalmodyOverrideFor, see its doc comment for the full rationale: + * + * 1. **Proper** — this feast's own authored psalmody, eligible at *any + * rank* via resolve-common.ts's getPsalmodyProperOverrideId (a saint's + * own real proper text is never gated behind rank). + * 2. **Common category** — per hours/vespers-psalmody-overrides.ts's own + * doc comment, genuinely per-Common here (the psalm *numbers* + * themselves differ by Common, not just the antiphons, unlike Lauds) + * — still gated at duplex-majus+ via getOfficeOverrideId. + * + * Eligibility follows the evening's governing `day` (same + * resolveOfficeWinner-based test as the chapter/responsory/hymn/versicle + * bundle below) — a First Vespers of a feast should get that feast's own + * psalms, same as it gets that feast's own office. Falls back to + * `undefined` for an eligible feast with no override authored in either + * tier, same honest incremental-content convention as everywhere else. */ function getPsalmodyOverrideFor(day: LiturgicalDay): VespersDay | undefined { - const id = getOfficeOverrideId(day); - return id ? getVespersPsalmodyOverride(id) : undefined; + const properId = getPsalmodyProperOverrideId(day); + const proper = properId ? getVespersSaintOverride(properId) : undefined; + if (proper) { + return proper; + } + const categoryId = getOfficeOverrideId(day); + return categoryId ? getVespersCommonOverride(categoryId) : undefined; } /** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm