import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { LiturgicalDay, Weekday } from '../calendar/types'; import { resolveDay, isSundayOrFeast, activeOctavesFor } from '../calendar'; import { getDayLabel } from '../calendar/day-label'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getPsalmVerses } from '../psalter'; import { getCanticle } from './lauds-canticles'; import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollects, getBenedictusAntiphon, splitNamedAntiphon, openingAntiphon, resolveOfficeWinner, verifiedText, seasonalOfficeSuffix, isFerialOrVigil, getOfficeOverrideId, resolveResponsory, resolveOfficeBundle, } from './resolve-common'; import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; const laudsDefinition = laudsDefinitionData as HourDefinition; type BilingualText = Partial>; interface LaudsGroup { psalms: number[]; antiphon: BilingualText; } /** Shared by both the plain per-weekday default (data/hours/lauds- * antiphons.yml) and a duplex-majus+/Marian-Saturday override (hours/ * lauds-psalmody-overrides.ts) — resolvePsalmody doesn't care which one * produced it. */ interface PsalmodyBlock { groups: LaudsGroup[]; // `split`: verse count of the canticle's first part, when it's said in // two pieces (own Gloria Patri each) rather than continuously — see // lauds-antiphons.yml's saturday.canticle comment. Only Saturday's // plain default uses this today; no override needs it yet. canticle: { id: string; antiphon: BilingualText; split?: number }; laudate: { antiphon: BilingualText }; } 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. * * 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 * resolveOfficeWinner doc comment). */ function getPsalmodyOverrideFor(day: LiturgicalDay): LaudsPsalmodyOverride | undefined { const id = getOfficeOverrideId(day); return id ? getLaudsPsalmodyOverride(id) : undefined; } // Mon-Fri share one capitulum verbatim (confirmed against the live engine // — see lauds-capitulum-ferial.yml); Sunday and Saturday each have their // own. function capitulumId(weekday: Weekday): string { if (weekday === 'sunday') { return 'lauds-capitulum-sunday'; } if (weekday === 'saturday') { return 'lauds-capitulum-saturday'; } return 'lauds-capitulum-ferial'; } function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] { const parts: ResolvedPart[] = numbers.map((number, i) => { const rawVerses = getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })); const { verses, antiphon: psalmAntiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); return { kind: 'psalm' as const, psalmNumber: number, antiphon: psalmAntiphon, verses, }; }); parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full }); return parts; } const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const; /** Joins a canticle's verse array (or a `[start, end)` slice of it — see * Saturday's split canticle below) into one flowing text block, the same * shape nunc-dimittis/benedictus already use — Lauds' weekday canticles * are stored verse-by-verse (see lauds-canticles.ts) purely because that's * how they were transcribed, not because callers need per-verse access. * Does NOT append a closing Gloria Patri — that's attached centrally * (hours/index.ts) as its own 'canticle' field, same as every other * canticle/psalm, so the Sacred Triduum omission only has to be handled * in one place. */ function canticleText(canticleId: string, slice?: [number, number]): ResolvedText { const canticle = getCanticle(canticleId); const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses; const text: Partial> = {}; const status: Partial> = {}; for (const lang of ['la', 'en'] as const) { const lines = verses.map((v) => v.text[lang]).filter((t): t is string => Boolean(t)); if (lines.length === 0) { continue; } text[lang] = lines.join('\n'); let worst: 'verified' | 'draft' | 'missing' = 'verified'; for (const v of verses) { const s = v.status[lang] ?? 'missing'; if (STATUS_RANK[s] > STATUS_RANK[worst]) { worst = s; } } status[lang] = worst; } return { text, status }; } /** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody' * doc comment for the scope, and getPsalmodyOverrideFor above for when the * plain weekday default below gets substituted. */ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday]; const winner = resolveOfficeWinner(day); const opening = (antiphon: BilingualText) => openingAntiphon(verifiedText(antiphon), winner); const parts: ResolvedPart[] = [ { kind: 'psalm', psalmNumber: 66, verses: getPsalmVerses(66).map((v) => ({ n: v.n, text: v.text, status: v.status })), }, ]; for (const group of wd.groups) { parts.push(...psalmParts(group.psalms, group.antiphon, opening(group.antiphon))); } const canticle = getCanticle(wd.canticle.id); const canticleOpening = opening(wd.canticle.antiphon); const canticleClosing = splitNamedAntiphon(verifiedText(wd.canticle.antiphon)).full; if (wd.canticle.split) { // Said in two pieces, own Gloria Patri each, one shared antiphon // framing both (opening before the first, full repeated only after // the second) — see lauds-antiphons.yml's saturday.canticle comment. parts.push({ kind: 'canticle', canticleId: canticle.id, antiphon: canticleOpening, text: canticleText(canticle.id, [0, wd.canticle.split]), }); parts.push({ kind: 'canticle', canticleId: canticle.id, text: canticleText(canticle.id, [wd.canticle.split, canticle.verses.length]), }); } else { parts.push({ kind: 'canticle', canticleId: canticle.id, antiphon: canticleOpening, text: canticleText(canticle.id), }); } parts.push({ kind: 'antiphon', text: canticleClosing }); parts.push(...psalmParts([148, 149, 150], wd.laudate.antiphon, opening(wd.laudate.antiphon))); return parts; } /** Any sanctoral winner's (or named-temporal feast's) own capitulum/ * responsory/hymn/versicle (data/propers/common/lauds-{part}-${id}.yml), * else their Common's (`${part}-${common}`), when authored — see * resolve-common.ts's resolveOfficeBundle for the eligibility/fallback * rule (deliberately rank-agnostic as of 2026-08-25: a simplex feast's * Common chapter/hymn is real content, not gated behind duplex-majus+ * like the psalmody override above is). Falls to a *seasonal* default * next (Advent/Lent/Passiontide/Paschaltide — see resolve-common.ts's * seasonalOfficeSuffix), and only then the plain weekday default — a * feast's own override always wins over the season it happens to fall * in, same as everywhere else in this codebase. */ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { const bundle = resolveOfficeBundle('lauds', day); if (bundle) { return [ { kind: 'chapter', text: bundle.chapter }, { kind: 'responsory', text: bundle.responsory }, { kind: 'hymn', text: bundle.hymn }, { kind: 'versicle', text: bundle.versicle }, ]; } const seasonSuffix = seasonalOfficeSuffix(day.season); const key = seasonSuffix ?? day.weekday; return [ { kind: 'chapter', text: resolveCommon(seasonSuffix ? `lauds-capitulum-${key}` : capitulumId(day.weekday)) }, { kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${key}`), day) }, { kind: 'hymn', text: resolveCommon(`lauds-hymn-${key}`) }, { kind: 'versicle', text: resolveCommon(`lauds-versicle-${key}`) }, ]; } const SUFFRAGES = [ 'lauds-suffrage-cross', 'lauds-suffrage-bvm', 'lauds-suffrage-joseph', 'lauds-suffrage-apostles', 'lauds-suffrage-peace', ]; const SUFFRAGE_LABELS: Record = { 'lauds-suffrage-cross': 'Of the Holy Cross', 'lauds-suffrage-bvm': 'Of the Blessed Virgin Mary', 'lauds-suffrage-joseph': 'Of St. Joseph', 'lauds-suffrage-apostles': 'Of the Holy Apostles Peter and Paul', 'lauds-suffrage-peace': 'For Peace', }; function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { switch (part.kind) { case 'opening-versicle': return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; case 'lauds-psalmody': return resolvePsalmody(day); case 'lauds-office': return resolveOffice(day); case 'benedictus': { const antiphonText = getBenedictusAntiphon(day); const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day)); const { full } = splitNamedAntiphon(antiphonText); return [ { kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening }, { kind: 'antiphon', text: full }, ]; } case 'preces': return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; case 'day-collects': return getDayCollects(day); case 'suffrages': { // isDoubleOrHigher only ever looks at a sanctoral rank — Christ the // King is modeled as a named temporal winner (see // ALWAYS_OVERRIDE_TEMPORAL_IDS above) with no FeastClass to check at // all, so it needs its own explicit inclusion here. Confirmed live: // "Suffragium{omittitur}" — the whole set drops, same as any other // Double-or-higher day. const isChristTheKing = day.winner.kind === 'temporal' && day.winner.id === 'christ-the-king'; // Confirmed live against Monastic Tridentinum 1617 directly (this // isn't a Tridentine-1906-only quirk -- the same exclusions hold // under the app's own primary rubric track): suffrages are omitted // *entirely* during Advent, Christmastide, and Passiontide (not all // of Lent -- ordinary Lent ferias still get them, checked directly // against 2026-02-20; only the last two weeks), and on any day // within an active octave, however low that octave's own rank is // -- checked directly against day 3 of St. Lawrence's own // (Semiduplex) octave, which still omits them despite being well // below the Duplex+ rank threshold below. const isSeasonallyExcluded = day.season === 'advent' || day.season === 'christmastide' || day.season === 'passiontide'; const isWithinAnOctave = activeOctavesFor(day.date).length > 0; if (part.omitOnDouble && (isDoubleOrHigher(day.winner) || isChristTheKing || isSeasonallyExcluded || isWithinAnOctave)) { return []; } // "Of the Holy Cross" is the real *ferial*-office suffrage, sourced // from Tridentine 1906/1910 (a different track than the other four, // which come from Monastic 1617 -- that track has no Cross suffrage // at Lauds at all) -- live-verified against a plain ferial win, a // plain Sunday win, a low-rank saint's own win (even bare Simplex), // and Marian Saturday: Cross shows only when the bare temporal // feria itself is what's being prayed -- the exact *opposite* of // "Sunday or a feast of the Lord." In this app's own vocabulary, // that's `!isSundayOrFeast(day)` (the same ferial/festive split // Prime's capitulum already keys off) with one more exclusion for a // named temporal identity like Marian Saturday, which isn't Sunday // or a sanctoral winner either but still isn't a bare ferial office. const showCross = !isSundayOrFeast(day) && !(day.winner.kind === 'temporal' && getTemporalFeastRecord(day.winner.id)); // "Of the Blessed Virgin Mary" is omitted specifically when the // day's own office is already Marian (would be redundant to // suffrage Mary again on her own day) -- confirmed live on Marian // Saturday (Simplex-strength, so it reaches this far rather than // being excluded outright above); the only case this app currently // has of a day using the Marian common below the suffrage-omitting // Duplex+ threshold (every actual Marian *feast* modeled so far is // Duplex-majus+ and already returns above). const isMarianSaturday = day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday'; const ids = SUFFRAGES.filter((id) => { if (id === 'lauds-suffrage-cross') return showCross; if (id === 'lauds-suffrage-bvm') return !isMarianSaturday; return true; }); return ids.map((id) => ({ kind: 'preces' as const, text: resolveCommon(id), label: SUFFRAGE_LABELS[id], })); } case 'versicle': case 'chapter': case 'responsory': case 'prayer': return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; case 'marian-antiphon': { const id = getMarianAntiphonId(day); return [{ kind: 'preces', text: resolveCommon(id), label: getMarianAntiphonLabel(id) }]; } case 'lauds-preces': { const id = isFerialOrVigil(day) ? 'lauds-preces-feriales' : 'lauds-short-litany'; return [{ kind: 'preces', text: resolveCommon(id) }]; } // Not used by Lauds. case 'hymn': case 'lesson': case 'psalm': case 'canticle': case 'martyrology': case 'rule-reading': case 'creed': case 'closing-antiphon': case 'by-day-kind': case 'variable': case 'day-collect': case 'nunc-dimittis': case 'vespers-psalmody': case 'vespers-office': case 'magnificat': case 'vespers-preces': throw new Error(`Lauds' ordo doesn't support a '${part.kind}' part`); } } export function resolveOrdo(date: string): ResolvedOrdo { const day = resolveDay(date); const parts = laudsDefinition.parts.flatMap((part) => resolvePart(part, day)); return { hourId: 'lauds', date, parts, dayLabel: getDayLabel(day) }; }