ef71d9c5bc
Deploy / deploy (push) Successful in 1m7s
Nocturn 1 and 2 each get exactly one reading; Nocturn 3 absorbs the rest of the pool, however large. A pool of only one reading goes to Nocturn 3 rather than Nocturn 1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
353 lines
17 KiB
TypeScript
353 lines
17 KiB
TypeScript
// Matins — the last hour built in this project, and structurally the most
|
|
// different from every other one (see this file's own history in TODO.md).
|
|
// Builds its ordo programmatically per day rather than resolving a static
|
|
// `data/hours/matins.yml` parts list the way every other hour does, because
|
|
// the real shape (1 nocturn on a plain ferial day vs. 3 on a Sunday or a
|
|
// Duplex+ feast, with a *variable* number of readings) doesn't fit that
|
|
// pattern.
|
|
//
|
|
// Critical framing (see memory `vu-not-a-reconstruction` / `vu-matins-
|
|
// design`, and TODO.md's own Matins section): this is NOT a historical
|
|
// reconstruction. The reference engine's Monastic 1617 data is a content
|
|
// and structure source, not a spec to reproduce — several real, deliberate
|
|
// departures from it are built in here:
|
|
// - Readings are pool-assembled, not fixed-slotted (user, 2026-08,
|
|
// superseding this file's original "Nocturn 1 = plan, Nocturns 2-3 =
|
|
// patristic" design): every source that can contribute for the day —
|
|
// the user's own scripture-reading plan (src/propers/bible-plan.ts,
|
|
// a *variable* number of readings, not the historical fixed 3 or the
|
|
// Rule's own "summer" contraction, deliberately not reproduced —
|
|
// this app reads in full year-round), the office winner's and every
|
|
// commemorated saint's own patristic/hagiographic/Gospel content, the
|
|
// plain temporal day's own content, and every active octave's own
|
|
// reading — is gathered into one ordered pool (`buildReadingPool`),
|
|
// then slotted across however many nocturns the day's psalmody has
|
|
// (`distributeIntoNocturns`), with no reading kind pinned to a
|
|
// particular nocturn number. On a 3-nocturn day the slotting is
|
|
// front-light: Nocturn 1 gets one reading, Nocturn 2 gets one, and
|
|
// Nocturn 3 absorbs the rest of the pool, however large (user, 2026-08)
|
|
// — not an even chunking of the pool. A pool of only one reading total
|
|
// goes in Nocturn 3, not Nocturn 1.
|
|
// - Where the historical office splits one continuous source across
|
|
// several numbered lessons, this app recombines them into one reading
|
|
// (see src/propers/octave-readings.ts's resolvePassages / src/propers/
|
|
// nocturn-readings.ts) — split only where the underlying source
|
|
// genuinely changes (e.g. a Gospel pericope vs. the homily on it).
|
|
// - The *number* of nocturns (1 vs. 3) is still gated at Duplex-and-higher
|
|
// (plus every Sunday, unconditionally) — the user's own choice
|
|
// (2026-08), not the historical Rule's own more permissive threshold.
|
|
// This is a psalmody-structure decision only; it no longer limits which
|
|
// days get patristic reading content authored — a sub-Duplex day's
|
|
// single nocturn can and should include patristic/hagiographic content
|
|
// from the pool whenever it's been sourced for that day (user, 2026-08:
|
|
// "patristic readings for every saint where we can source one, not
|
|
// just duplex+").
|
|
// - A Gospel reading is sourced from exactly two places: the user's own
|
|
// plan (flagged via BiblePlanReading.isGospel — never present on a
|
|
// Sunday, a deliberate editorial choice in the user's own plan, not a
|
|
// gap) and the day's own genuine *proper* Gospel+homily (src/propers/
|
|
// nocturn-readings.ts). A Common-of-Saints fallback Gospel is
|
|
// deliberately never used here — nocturn-readings.ts has no
|
|
// Common-fallback mechanism at all (unlike collectCommon/
|
|
// benedictusCommon elsewhere), so this exclusion falls out of the
|
|
// store's own shape rather than needing special-case code.
|
|
// - Every commemorated saint (not just the office winner) and every
|
|
// active octave contributes its own reading to the pool, when
|
|
// authored — "be generous, not winner-takes-all" (user, 2026-08) —
|
|
// mirroring getDayCollects's own "one collect per commemoration"
|
|
// pattern, applied to readings instead.
|
|
//
|
|
// Only a small, growable slice of content is authored so far (one clean
|
|
// ferial day, one clean Sunday) — this is the mechanism build, not the
|
|
// full-calendar content pass. See TODO.md for what's deferred.
|
|
import type { ResolvedOrdo, ResolvedPart, ResolvedVerse } from './types';
|
|
import type { LiturgicalDay } from '../calendar/types';
|
|
import { resolveDay, resolveTemporalId, activeOctavesFor } from '../calendar';
|
|
import { getDayLabel } from '../calendar/day-label';
|
|
import { getPsalmVerses } from '../psalter';
|
|
import { getPsalmsFor, type PsalmRef } from '../psalter/distribution';
|
|
import { getScriptureVerses } from '../scripture';
|
|
import { getOpeningVersicleId } from './opening-versicle';
|
|
import { isDoubleOrHigher } from './antiphon';
|
|
import { resolveCommon, getDayCollect, resolveOfficeWinner, verifiedText, splitNamedAntiphon } from './resolve-common';
|
|
import { getBiblePlanReadings } from '../propers/bible-plan';
|
|
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
|
|
import { getOctaveReading } from '../propers/octave-readings';
|
|
import { getMatinsPsalmodyOverride } from './matins-psalmody-overrides';
|
|
import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml';
|
|
|
|
type BilingualText = Partial<Record<string, string>>;
|
|
interface SundayGroup {
|
|
psalms: number[];
|
|
antiphon: BilingualText;
|
|
}
|
|
interface ScriptureRef {
|
|
book: string;
|
|
chapter: number;
|
|
verses?: string;
|
|
}
|
|
interface SundayNocturn {
|
|
groups?: SundayGroup[];
|
|
// Usually one ref per canticle; more than one when the source cites a
|
|
// single canticle across a chapter boundary (e.g. St. Lawrence's own
|
|
// Nocturn 3, "Eccli 14:22;15:3-4;15:6" — one canticle, two Sirach
|
|
// chapters) — concatenated in order, not rendered as separate
|
|
// canticles, matching how the source itself presents it as one entry
|
|
// under one heading.
|
|
canticles?: { refs: ScriptureRef[] }[];
|
|
antiphon?: BilingualText;
|
|
versicle: { v: BilingualText; r: BilingualText };
|
|
}
|
|
interface MatinsSundayAntiphons {
|
|
nocturn1: SundayNocturn;
|
|
nocturn2: SundayNocturn;
|
|
nocturn3: SundayNocturn;
|
|
}
|
|
const sundayAntiphons = matinsSundayAntiphonsData as unknown as MatinsSundayAntiphons;
|
|
|
|
type PsalmPart = Extract<ResolvedPart, { kind: 'psalm' }>;
|
|
|
|
function plainPsalm(number: number): PsalmPart {
|
|
return {
|
|
kind: 'psalm',
|
|
psalmNumber: number,
|
|
verses: getPsalmVerses(number).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })),
|
|
};
|
|
}
|
|
|
|
function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] {
|
|
return refs.map((ref) => ({
|
|
kind: 'psalm' as const,
|
|
psalmNumber: ref.number,
|
|
verses: getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })),
|
|
}));
|
|
}
|
|
|
|
/** The Invitatory (Ps 94) — real practice interleaves its antiphon as a
|
|
* repeating refrain between verse groups; per direct instruction this app
|
|
* frames it like any other psalm antiphon instead (one opening, one full
|
|
* repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's
|
|
* own header. */
|
|
function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
|
|
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon'));
|
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
|
return [
|
|
{ ...plainPsalm(94), antiphon: opening },
|
|
{ kind: 'antiphon', text: full },
|
|
];
|
|
}
|
|
|
|
/** Sunday's fixed 12-psalm psalmody for Nocturns 1-2 (Ps 20-31, 3
|
|
* antiphons per nocturn each framing a pair of psalms) — see
|
|
* data/hours/matins-sunday-antiphons.yml's own header for sourcing. */
|
|
function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
|
|
const parts: ResolvedPart[] = [];
|
|
for (const g of group.groups ?? []) {
|
|
const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon));
|
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
|
g.psalms.forEach((n, i) => {
|
|
parts.push({ ...plainPsalm(n), antiphon: i === 0 ? opening : undefined });
|
|
});
|
|
parts.push({ kind: 'antiphon', text: full });
|
|
}
|
|
parts.push({ kind: 'versicle', text: verifiedText({ la: group.versicle.v.la, en: group.versicle.v.en }) });
|
|
return parts;
|
|
}
|
|
|
|
/** Nocturn 3's 3 fixed OT canticles under one shared antiphon — see
|
|
* data/hours/matins-sunday-antiphons.yml's own header. */
|
|
function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
|
|
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon ?? {}));
|
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
|
const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => {
|
|
const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses));
|
|
const text: BilingualText = {
|
|
la: verses.map((v) => v.text.la).filter(Boolean).join(' '),
|
|
en: verses.map((v) => v.text.en).filter(Boolean).join(' '),
|
|
};
|
|
return {
|
|
kind: 'canticle' as const,
|
|
canticleId: c.refs.map((ref) => `${ref.book}-${ref.chapter}${ref.verses ? `-${ref.verses}` : ''}`).join('_'),
|
|
text: verifiedText(text),
|
|
antiphon: i === 0 ? opening : undefined,
|
|
};
|
|
});
|
|
parts.push({ kind: 'antiphon', text: full });
|
|
parts.push({ kind: 'versicle', text: verifiedText(group.versicle.v) });
|
|
return parts;
|
|
}
|
|
|
|
function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
|
|
return psalmRefParts(getPsalmsFor('matins', day.weekday));
|
|
}
|
|
|
|
/** Fallback for a Duplex+ weekday feast with no matins-psalmody-overrides
|
|
* entry authored for its winner yet (most of them, until the bulk-content
|
|
* pass — see hours/matins-psalmody-overrides.ts's own doc comment): the
|
|
* plain ferial weekday table, chunked into 3 nocturns instead of 1 (no
|
|
* antiphons, no canticles — that content doesn't exist for the ferial
|
|
* table at all, unlike a real per-category override). Direct instruction:
|
|
* never blank, but honestly not that saint's own real proper psalmody
|
|
* until it's authored. */
|
|
function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], ResolvedPart[], ResolvedPart[]] {
|
|
const psalms = ferialPsalmody(day);
|
|
const size = Math.ceil(psalms.length / 3);
|
|
return [psalms.slice(0, size), psalms.slice(size, size * 2), psalms.slice(size * 2)];
|
|
}
|
|
|
|
/** Every id whose own patristic/hagiographic/Gospel content should be
|
|
* gathered for `day` — the office winner (if sanctoral), every
|
|
* commemorated saint (a transferred-in feast already appears as `day.winner`
|
|
* once `resolveDay` has applied the transfer, so it needs no separate
|
|
* lookup here), and the plain temporal id itself (for an ordinary day's own
|
|
* patristic content, e.g. a plain Sunday's Moralia-in-Job-style
|
|
* commentary) — deliberately inclusive, not just the winner, per the
|
|
* user's own "be generous, not winner-takes-all" instruction (2026-08). */
|
|
function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] {
|
|
const ids = new Set<string>();
|
|
if (day.winner.kind === 'sanctoral') ids.add(day.winner.id);
|
|
for (const c of day.commemorations) {
|
|
if (c.kind === 'sanctoral') ids.add(c.id);
|
|
}
|
|
ids.add(temporalId);
|
|
return [...ids];
|
|
}
|
|
|
|
function nocturnReadingPart(r: NocturnReading): ResolvedPart {
|
|
return {
|
|
kind: 'lesson',
|
|
text: { text: r.text, status: r.status, citation: r.citation },
|
|
label: r.source,
|
|
isGospel: r.isGospel,
|
|
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
|
|
};
|
|
}
|
|
|
|
/** The full pool of readings available for `day` — every source that can
|
|
* contribute (see this file's header): the user's own scripture-plan
|
|
* readings, the office winner's and every commemorated saint's own
|
|
* patristic/hagiographic/Gospel content, the plain temporal id's own
|
|
* content (e.g. an ordinary Sunday's patristic commentary), and every
|
|
* currently active octave's own reading. No source is pinned to a
|
|
* particular nocturn — `distributeIntoNocturns` slots the whole pool
|
|
* across however many nocturns the day's psalmody has, per the user's own
|
|
* "assemble everything, then slot it in" instruction (2026-08), a
|
|
* deliberate departure from this file's earlier "Nocturn 1 = plan,
|
|
* Nocturns 2-3 = patristic" design. Order here is preserved by
|
|
* `distributeIntoNocturns`, so it doubles as reading priority: the user's
|
|
* own scripture reading first, then each id's authored content in its own
|
|
* file order (patristic commentary typically precedes a Gospel+homily —
|
|
* see data/propers/nocturn-readings/*.yml), then active octaves. */
|
|
function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] {
|
|
const parts: ResolvedPart[] = [];
|
|
for (const r of getBiblePlanReadings(temporalId, day.weekday, date)) {
|
|
parts.push({
|
|
kind: 'lesson',
|
|
text: { text: r.text, status: r.status, citation: r.citation },
|
|
isGospel: r.isGospel,
|
|
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
|
|
});
|
|
}
|
|
for (const id of nocturnReadingIds(day, temporalId)) {
|
|
for (const reading of getNocturnReadings(id)) {
|
|
parts.push(nocturnReadingPart(reading));
|
|
}
|
|
}
|
|
for (const octave of activeOctavesFor(date)) {
|
|
const reading = getOctaveReading(octave.id, octave.dayNumber);
|
|
if (reading) {
|
|
parts.push({
|
|
kind: 'lesson',
|
|
text: { text: reading.text, status: reading.status },
|
|
label: reading.source,
|
|
isGospel: false,
|
|
responsory: reading.responsory
|
|
? { text: reading.responsory, status: { la: 'verified', en: 'verified' } }
|
|
: undefined,
|
|
});
|
|
}
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
/** Splits `pool` across `nocturnCount` nocturns. A single nocturn takes the
|
|
* whole pool. Three nocturns use a front-light, back-heavy split — Nocturn 1
|
|
* gets exactly one reading, Nocturn 2 gets exactly one, and Nocturn 3
|
|
* absorbs everything else, however many that is (user, 2026-08: "nocturn,
|
|
* one reading, nocturn, one reading, nocturn, remaining readings") — a
|
|
* deliberate departure from evenly chunking the pool, and from the
|
|
* historical fixed lesson-count-per-nocturn scheme. Special case: a pool
|
|
* with only one reading total puts it in Nocturn 3, not Nocturn 1 — a bare
|
|
* single reading reads better closing the hour than opening it. */
|
|
function distributeIntoNocturns(pool: ResolvedPart[], nocturnCount: number): ResolvedPart[][] {
|
|
if (nocturnCount === 1) return [pool];
|
|
const chunks: ResolvedPart[][] =
|
|
pool.length <= 1 ? [[], [], pool] : [[pool[0]!], [pool[1]!], pool.slice(2)];
|
|
return chunks.map((chunk, i) => chunk.map((part) => ({ ...part, nocturn: i + 1 })));
|
|
}
|
|
|
|
export function resolveOrdo(date: string): ResolvedOrdo {
|
|
const day = resolveDay(date);
|
|
const winner = resolveOfficeWinner(day);
|
|
const temporalId = resolveTemporalId(date);
|
|
// Every Sunday, unconditionally, or a Duplex+ sanctoral winner — the
|
|
// user's own chosen threshold (2026-08), not gated on whether any
|
|
// content is actually authored yet, same "eligible, not content-gated"
|
|
// convention every other per-feast override in this app already uses
|
|
// (see hours/resolve-common.ts's getOfficeOverrideId).
|
|
const threeNocturns = day.weekday === 'sunday' || isDoubleOrHigher(winner);
|
|
const pool = buildReadingPool(day, temporalId, date);
|
|
const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1);
|
|
|
|
const parts: ResolvedPart[] = [
|
|
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
|
|
plainPsalm(3),
|
|
...invitatoryParts(day),
|
|
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
|
|
];
|
|
|
|
if (threeNocturns && day.weekday === 'sunday') {
|
|
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
|
|
parts.push(...(nocturn1Readings ?? []));
|
|
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
|
|
parts.push(...(nocturn2Readings ?? []));
|
|
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
|
|
parts.push(...(nocturn3Readings ?? []));
|
|
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
|
} else if (threeNocturns) {
|
|
// A Duplex+ weekday feast — genuinely different psalmody from a real
|
|
// Sunday's, not the same content reused. getMatinsPsalmodyOverride
|
|
// itself does the proper -> Common -> (nothing) tiering; only the
|
|
// final "nothing authored at all" tier — the plain ferial weekday
|
|
// table, redistributed into 3 nocturns — lives here (see
|
|
// hours/matins-psalmody-overrides.ts's own doc comment for the full
|
|
// three-tier picture).
|
|
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
|
|
if (override) {
|
|
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
|
|
parts.push(...(nocturn1Readings ?? []));
|
|
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
|
|
parts.push(...(nocturn2Readings ?? []));
|
|
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
|
|
parts.push(...(nocturn3Readings ?? []));
|
|
} else {
|
|
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
|
|
parts.push(...n1);
|
|
parts.push(...(nocturn1Readings ?? []));
|
|
parts.push(...n2);
|
|
parts.push(...(nocturn2Readings ?? []));
|
|
parts.push(...n3);
|
|
parts.push(...(nocturn3Readings ?? []));
|
|
}
|
|
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
|
} else {
|
|
parts.push(...ferialPsalmody(day));
|
|
parts.push(...(nocturn1Readings ?? []));
|
|
parts.push({ kind: 'chapter', text: resolveCommon('matins-capitulum-ferial') });
|
|
}
|
|
|
|
parts.push({ kind: 'prayer', text: getDayCollect(day) });
|
|
|
|
return { hourId: 'matins', date, parts, dayLabel: getDayLabel(day) };
|
|
}
|