Fix Vespers ferial psalmody weekday bug, add duplex-majus+ Common psalmody override
Deploy / deploy (push) Successful in 1m33s
Deploy / deploy (push) Successful in 1m33s
Vespers psalms on Mon-Sat were silently following the anticipated First-Vespers day's weekday instead of the actual calendar date's, since resolvePsalmody reused resolveEveningDay's result (correct for propers/office, which legitimately borrow tomorrow's identity, but wrong for the ferial psalm cycle, which has no per-feast override of its own). Also adds a duplex-majus+ Common-category psalmody override to both Lauds and Vespers, mirroring Matins' existing saint/category mechanism. Lauds already had a per-feast-only override; Vespers had none. 8 of 24 Common categories authored for both hours, live-verified against the reference engine. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHXJAWVAabCKX1JgmDh4Rn
This commit is contained in:
@@ -1,13 +1,33 @@
|
||||
import { getSaintRecord } from '../calendar/feasts';
|
||||
|
||||
/** Same shape as data/hours/lauds-antiphons.yml's per-weekday entries
|
||||
* (see hours/lauds.ts) — a whole-block substitute for the plain ferial
|
||||
* weekday psalmody, keyed by id: either a saint's own id (gated at
|
||||
* duplex-majus+, see hours/lauds.ts's getPsalmodyOverrideFor) or the
|
||||
* literal 'marian-saturday' (applies unconditionally whenever it wins).
|
||||
* Deliberately per-feast, not per-Common — explicit choice, even though
|
||||
* it means most duplex-majus+ saints across the year don't have one of
|
||||
* these authored yet and fall back to the plain weekday default until
|
||||
* they do (same "mechanism first, content incrementally" pattern as
|
||||
* everywhere else — not a bug, an expected, honest gap). */
|
||||
* weekday psalmody. Two tiers, consulted in order by
|
||||
* `getLaudsPsalmodyOverride` (mirrors matins-psalmody-overrides.ts's own
|
||||
* saint/category split):
|
||||
*
|
||||
* 1. **Proper** — `data/hours/lauds-psalmody-overrides/*.yml`, keyed by a
|
||||
* saint's own id (gated at duplex-majus+, see hours/lauds.ts's
|
||||
* getPsalmodyOverrideFor) or the literal 'marian-saturday' (applies
|
||||
* unconditionally whenever it wins) — real proper antiphon text for a
|
||||
* saint who has one, live-verified.
|
||||
* 2. **Common** — `data/hours/lauds-psalmody-overrides/categories/*.yml`,
|
||||
* one file per `SaintRecord.common` category (with optional `aliases`
|
||||
* for a second category id sharing the identical scheme, e.g. Common-
|
||||
* of-a-Confessor-Doctor reuses Common-of-a-Confessor-Bishop's Lauds
|
||||
* psalmody verbatim in the reference engine). Live-verified fact
|
||||
* (2026-08-28, `votive=C1`..`C11` queries): the psalm *numbers* here
|
||||
* (92, 99, 62, Canticum Trium Puerorum) are the same for every
|
||||
* Common — only the antiphons vary by category — which is exactly why
|
||||
* the existing per-feast overrides above (e.g. st-lawrence.yml) already
|
||||
* reuse those same three numbers with their own proper antiphons; the
|
||||
* category tier is just the generic version of that when no per-feast
|
||||
* proper has been authored yet.
|
||||
*
|
||||
* Falls through to the plain weekday default (data/hours/lauds-
|
||||
* antiphons.yml) when neither tier has anything for this id — same
|
||||
* "mechanism first, content incrementally" pattern as everywhere else,
|
||||
* not a bug. */
|
||||
export interface LaudsPsalmodyOverride {
|
||||
id: string;
|
||||
groups: { psalms: number[]; antiphon: Partial<Record<string, string>> }[];
|
||||
@@ -16,17 +36,45 @@ export interface LaudsPsalmodyOverride {
|
||||
canticle: { id: string; antiphon: Partial<Record<string, string>>; split?: number };
|
||||
laudate: { antiphon: Partial<Record<string, string>> };
|
||||
}
|
||||
export interface LaudsPsalmodyCategory extends LaudsPsalmodyOverride {
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
const modules = import.meta.glob<{ default: LaudsPsalmodyOverride }>(
|
||||
'../data/hours/lauds-psalmody-overrides/*.yml',
|
||||
{ eager: true },
|
||||
);
|
||||
const categoryModules = import.meta.glob<{ default: LaudsPsalmodyCategory }>(
|
||||
'../data/hours/lauds-psalmody-overrides/categories/*.yml',
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const overrides = new Map<string, LaudsPsalmodyOverride>();
|
||||
for (const mod of Object.values(modules)) {
|
||||
overrides.set(mod.default.id, mod.default);
|
||||
}
|
||||
|
||||
export function getLaudsPsalmodyOverride(id: string): LaudsPsalmodyOverride | undefined {
|
||||
const categories = new Map<string, LaudsPsalmodyCategory>();
|
||||
for (const mod of Object.values(categoryModules)) {
|
||||
categories.set(mod.default.id, mod.default);
|
||||
for (const alias of mod.default.aliases ?? []) {
|
||||
categories.set(alias, mod.default);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLaudsPsalmodyOverride(id: string | undefined): LaudsPsalmodyOverride | undefined {
|
||||
if (!id) return undefined;
|
||||
return getLaudsSaintOverride(id) ?? getLaudsCommonOverride(id);
|
||||
}
|
||||
|
||||
/** Saint-specific proper only, skipping the Common-category tier. */
|
||||
export function getLaudsSaintOverride(id: string | undefined): LaudsPsalmodyOverride | undefined {
|
||||
if (!id) return undefined;
|
||||
return overrides.get(id);
|
||||
}
|
||||
|
||||
/** Common-category lookup only, joining via `SaintRecord.common`. */
|
||||
export function getLaudsCommonOverride(id: string | undefined): LaudsPsalmodyOverride | undefined {
|
||||
if (!id) return undefined;
|
||||
return categories.get(getSaintRecord(id)?.common ?? '');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { getSaintRecord } from '../calendar/feasts';
|
||||
|
||||
/** Vespers' own analogue of hours/lauds-psalmody-overrides.ts (and, more
|
||||
* distantly, matins-psalmody-overrides.ts) — a whole-block substitute for
|
||||
* the plain ferial weekday psalmody (data/hours/vespers-antiphons.yml),
|
||||
* gated the same duplex-majus+ way as Lauds' own override
|
||||
* (hours/vespers.ts's getPsalmodyOverrideFor, reusing resolve-common.ts's
|
||||
* getOfficeOverrideId). Two tiers, consulted in order by
|
||||
* `getVespersPsalmodyOverride`:
|
||||
*
|
||||
* 1. **Proper** — `data/hours/vespers-psalmody-overrides/saints/*.yml`,
|
||||
* a specific saint's own proper Vespers psalmody, keyed by saint id.
|
||||
* None authored yet (no duplex-majus+ saint in this app currently has
|
||||
* proper Vespers psalm antiphons distinct from their Common's) — the
|
||||
* tier exists so one can be added later without a mechanism change,
|
||||
* same "mechanism first, content incrementally" pattern as Lauds'.
|
||||
* 2. **Common** — `data/hours/vespers-psalmody-overrides/categories/
|
||||
* *.yml`, one file per `SaintRecord.common` category (with optional
|
||||
* `aliases` for a second category id sharing the identical scheme).
|
||||
* Live-verified (2026-08-28, `votive=C1`..`C11` queries against
|
||||
* Divinum Officium, Monastic Tridentinum 1617): unlike Lauds (whose
|
||||
* psalm *numbers* are the same fixed set for every Common), Vespers'
|
||||
* psalm numbers genuinely differ by Common — e.g. Common-of-an-Apostle
|
||||
* is 109/112/115/138, Common-of-a-Confessor-Bishop is 109/111/112/131
|
||||
* — so this tier carries its own `groups` per category, not just
|
||||
* antiphons layered onto one shared number set.
|
||||
*
|
||||
* Falls through to the plain weekday default (data/hours/vespers-
|
||||
* antiphons.yml) when neither tier has anything for this id. */
|
||||
export interface VespersPsalmodyGroup {
|
||||
psalms: (number | { number: number; verses?: string })[];
|
||||
antiphon: Partial<Record<string, string>>;
|
||||
}
|
||||
export interface VespersPsalmodyOverride {
|
||||
id: string;
|
||||
groups: VespersPsalmodyGroup[];
|
||||
}
|
||||
export interface VespersPsalmodyCategory extends VespersPsalmodyOverride {
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
const saintModules = import.meta.glob<{ default: VespersPsalmodyOverride }>(
|
||||
'../data/hours/vespers-psalmody-overrides/saints/*.yml',
|
||||
{ eager: true },
|
||||
);
|
||||
const categoryModules = import.meta.glob<{ default: VespersPsalmodyCategory }>(
|
||||
'../data/hours/vespers-psalmody-overrides/categories/*.yml',
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const overrides = new Map<string, VespersPsalmodyOverride>();
|
||||
for (const mod of Object.values(saintModules)) {
|
||||
overrides.set(mod.default.id, mod.default);
|
||||
}
|
||||
|
||||
const categories = new Map<string, VespersPsalmodyCategory>();
|
||||
for (const mod of Object.values(categoryModules)) {
|
||||
categories.set(mod.default.id, mod.default);
|
||||
for (const alias of mod.default.aliases ?? []) {
|
||||
categories.set(alias, mod.default);
|
||||
}
|
||||
}
|
||||
|
||||
export function getVespersPsalmodyOverride(id: string | undefined): VespersPsalmodyOverride | undefined {
|
||||
if (!id) return undefined;
|
||||
return getVespersSaintOverride(id) ?? getVespersCommonOverride(id);
|
||||
}
|
||||
|
||||
/** Saint-specific proper only, skipping the Common-category tier. */
|
||||
export function getVespersSaintOverride(id: string | undefined): VespersPsalmodyOverride | undefined {
|
||||
if (!id) return undefined;
|
||||
return overrides.get(id);
|
||||
}
|
||||
|
||||
/** Common-category lookup only, joining via `SaintRecord.common`. */
|
||||
export function getVespersCommonOverride(id: string | undefined): VespersPsalmodyOverride | undefined {
|
||||
if (!id) return undefined;
|
||||
return categories.get(getSaintRecord(id)?.common ?? '');
|
||||
}
|
||||
+28
-10
@@ -5,6 +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 { applyFlexaMark } from './antiphon';
|
||||
import {
|
||||
resolveCommon,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
verifiedText,
|
||||
seasonalOfficeSuffix,
|
||||
isFerialOrVigil,
|
||||
getOfficeOverrideId,
|
||||
resolveResponsory,
|
||||
resolveOfficeBundle,
|
||||
resolveSuffrages,
|
||||
@@ -68,19 +70,35 @@ 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. */
|
||||
function getPsalmodyOverrideFor(day: LiturgicalDay): VespersDay | undefined {
|
||||
const id = getOfficeOverrideId(day);
|
||||
return id ? getVespersPsalmodyOverride(id) : undefined;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* groups in sequence. No per-feast override mechanism yet — see
|
||||
* hours/types.ts's 'vespers-psalmody' doc comment. Deliberately keyed off
|
||||
* `weekday` (today's actual calendar weekday), not `day.weekday` — `day`
|
||||
* 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. */
|
||||
* groups in sequence, substituted wholesale by getPsalmodyOverrideFor
|
||||
* above when eligible. Deliberately keyed off `weekday` (today's actual
|
||||
* calendar weekday), not `day.weekday`, for the plain-ferial-fallback
|
||||
* case — `day` may be tomorrow's identity when tonight anticipates First
|
||||
* Vespers (see resolveEveningDay), but the ferial psalm cycle has no
|
||||
* per-feast override of its own 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];
|
||||
const wd = getPsalmodyOverrideFor(day) ?? vespersAntiphons[weekday];
|
||||
return wd.groups.flatMap((group) => psalmParts(group, day));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user