From bedbf577ea70bba15c05a9b982a6395bf0c47605 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Fri, 14 Aug 2026 11:40:43 -0400 Subject: [PATCH] Build Matins' real dynamic ordo, wire into the UI hours/matins.ts no longer a stub: resolves a full ordo assembled programmatically per day rather than a static parts list, since the real structure (1 nocturn ferial vs. 3 on a Sunday or Duplex+ feast, a variable number of readings) doesn't fit the pattern every other hour uses. Several deliberate departures from the historical office, all per direct instruction (see TODO.md's own writeup): Nocturn 1 always draws from the user's bible-plan rather than the historical lectionary and never contracts for "summer"; split historical lessons are recombined except where the source genuinely changes; Nocturn 3 is gated at Duplex-and-higher plus every Sunday; every commemorated saint and active octave contributes its own reading, not just the office winner; a Gospel reading is sourced only from the user's plan or the day's own proper content, never a Common-of-Saints fallback; the Invitatory is framed like an ordinary antiphoned psalm rather than the historical repeating refrain. ui/hour-view.ts renders the two new part kinds (nocturn-psalmody, te-deum) and the extended lesson shape (responsory, Gospel flag). --- src/hours/matins.ts | 266 +++++++++++++++++++++++++++++++++++++++++++- src/ui/hour-view.ts | 23 +++- 2 files changed, 281 insertions(+), 8 deletions(-) diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 645d89b..4c569b6 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -1,8 +1,262 @@ -import type { ResolvedOrdo } from './types'; +// 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: +// - Nocturn 1 is always the user's own continuous scripture-reading plan +// (src/propers/bible-plan.ts), never the historical per-day lectionary +// — a *variable* number of readings, not the historical fixed 3 (or 1, +// in the source's own "summer" contraction — deliberately not +// reproduced here; this app reads in full year-round). +// - 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). +// - Nocturn 3 is 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. +// - 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 gets its own Nocturn 2/3 contribution, 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 matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml'; -// Milestone 5, most complex. Needs its own resolution path (reading-candidate -// pool + rank/season-driven slotting), not the static parts-array pattern the -// other hours use — see the plan's "Matins schema seam" note. Not built yet. -export function resolveOrdo(date: string): ResolvedOrdo { - return { hourId: 'matins', date, parts: [], notImplemented: true }; +type BilingualText = Partial>; +interface SundayGroup { + psalms: number[]; + antiphon: BilingualText; +} +interface SundayNocturn { + groups?: SundayGroup[]; + canticles?: { book: string; chapter: number; verses?: string }[]; + 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; + +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 = getScriptureVerses(c.book, c.chapter, c.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.book}-${c.chapter}${c.verses ? `-${c.verses}` : ''}`, + 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)); +} + +/** Nocturn 1 — always the user's own reading plan, on every kind of day + * (see this file's header). Empty when nothing's authored for this + * (temporalId, weekday) yet — an honest absence, not a placeholder. */ +function nocturn1ReadingParts(temporalId: string, day: LiturgicalDay): ResolvedPart[] { + const readings = getBiblePlanReadings(temporalId, day.weekday); + return readings.map((r) => ({ + kind: 'lesson', + text: { text: r.text, status: r.status, citation: r.citation }, + nocturn: 1, + isGospel: r.isGospel, + responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, + })); +} + +/** Every id whose own Nocturn 2/3 content should be gathered for `day` — + * the office winner (if sanctoral), every commemorated saint, 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(); + 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, + nocturn: r.nocturn, + isGospel: r.isGospel, + responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, + }; +} + +/** Nocturns 2-3's patristic/hagiographic/Gospel content for a 3-nocturn + * day — gathered from every relevant id (see nocturnReadingIds) plus every + * currently active octave's own already-authored octave-day reading (see + * src/propers/octave-readings.ts — built well ahead of this file, never + * wired to anything until now). Filtered to the requested nocturn number. */ +function nocturnReadingParts(nocturn: number, day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] { + const parts: ResolvedPart[] = []; + for (const id of nocturnReadingIds(day, temporalId)) { + for (const reading of getNocturnReadings(id)) { + if (reading.nocturn === nocturn) parts.push(nocturnReadingPart(reading)); + } + } + if (nocturn === 2) { + 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, + nocturn: 2, + isGospel: false, + responsory: reading.responsory + ? { text: reading.responsory, status: { la: 'verified', en: 'verified' } } + : undefined, + }); + } + } + } + return parts; +} + +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 parts: ResolvedPart[] = [ + { kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }, + plainPsalm(3), + ...invitatoryParts(day), + { kind: 'hymn', text: resolveCommon('matins-hymn-ferial') }, + ]; + + if (threeNocturns) { + parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day)); + parts.push(...nocturn1ReadingParts(temporalId, day)); + parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day)); + parts.push(...nocturnReadingParts(2, day, temporalId, date)); + parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day)); + parts.push(...nocturnReadingParts(3, day, temporalId, date)); + parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') }); + } else { + parts.push(...ferialPsalmody(day)); + parts.push(...nocturn1ReadingParts(temporalId, day)); + parts.push({ kind: 'chapter', text: resolveCommon('matins-capitulum-ferial') }); + } + + parts.push({ kind: 'prayer', text: getDayCollect(day) }); + + return { hourId: 'matins', date, parts, dayLabel: getDayLabel(day) }; } diff --git a/src/ui/hour-view.ts b/src/ui/hour-view.ts index bd32966..9076690 100644 --- a/src/ui/hour-view.ts +++ b/src/ui/hour-view.ts @@ -69,10 +69,11 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string { `; case 'lesson': return ` -
-

${part.label ? escapeHtml(part.label) : 'Reading'}

+
+

${part.isGospel ? 'Gospel' : part.label ? escapeHtml(part.label) : 'Reading'}

${renderCitation(part.text)} ${renderColumns(part.text, languages)} + ${part.responsory ? `
${renderColumns(part.responsory, languages)}
` : ''}
`; case 'psalm': { @@ -110,6 +111,24 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
`; } + // Matins only. Not currently emitted by hours/matins.ts (its psalms + // render as a flat sequence of plain 'psalm'/'antiphon'/'versicle' + // parts instead) — handled here for the ResolvedPart union's sake, + // ready for whenever nocturn-grouped UI treatment is wanted. + case 'nocturn-psalmody': + return ` +
+

Nocturn ${part.nocturn}

+ ${part.psalms.map((p) => renderPart(p, languages)).join('')} +
+ `; + case 'te-deum': + return ` +
+

Te Deum

+ ${renderColumns(part.text, languages)} +
+ `; } }