Compare commits

..

4 Commits

Author SHA1 Message Date
will b0d1cb2807 Document Matins mechanism build in TODO.md
Deploy / deploy (push) Successful in 1m0s
2026-08-14 11:40:56 -04:00
will 974c156255 Add Matins tests; update tests that assumed it was still a stub
tests/hours/matins.test.ts covers both branches (ferial, Sunday)
end-to-end through the real resolveOrdo mechanism against two clean,
live-verified dates — psalm/canticle counts, Te Deum presence, reading
citations/statuses, Gospel flagging and its Common-fallback exclusion,
and responsory matching.

tests/hours/prime.test.ts and tests/ui/shell.test.ts both had assertions
that Matins was still pending — replaced with assertions that it now
renders real content, mirroring the equivalent Compline test.
2026-08-14 11:40:51 -04:00
will bedbf577ea 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).
2026-08-14 11:40:43 -04:00
will 83bf55efdb Add reusable Matins building blocks: types, content stores, seed data
New ResolvedPart kinds (nocturn-psalmody, te-deum) and lesson fields
(nocturn, source, isGospel, responsory) in hours/types.ts. Three new
content-store modules: propers/bible-plan.ts (the user's own continuous
scripture-reading plan, replacing the historical Nocturn-1 lectionary),
propers/nocturn-readings.ts (general feast/temporal-day Nocturn 2-3
readings, extending propers/octave-readings.ts's combine-into-one-reading
pattern via a newly-exported resolvePassages), and
propers/matins-responsories.ts (a per-book responsory pool, matched
loosely rather than by exact citation).

Data: Sunday's fixed 12-psalm/3-canticle psalmody (live-verified against
the reference engine), the invitatory antiphon, ferial hymn and
capitulum, the Te Deum, a seeded Isaiah responsory, two live-transcribed
scripture chapters, and the two bible-plan rows needed for this pass'
proof content.
2026-08-14 11:40:31 -04:00
22 changed files with 1550 additions and 23 deletions
+138 -3
View File
@@ -1129,6 +1129,144 @@ draft-English antiphon (`tests/hours/resolve-common.test.ts`).
simplex", live-verified) both apply before any part runs. See simplex", live-verified) both apply before any part runs. See
`data/hours/vespers.yml`'s header and `tests/hours/vespers.test.ts`. `data/hours/vespers.yml`'s header and `tests/hours/vespers.test.ts`.
### Matins ordo — mechanism built, small content slice (2026-08)
`src/hours/matins.ts` was the last stub hour. Built as a genuinely different
shape from every other hour: no static `data/hours/matins.yml` parts list —
the ordo is assembled programmatically per day, since the real structure (1
nocturn on a plain ferial day, 3 on a Sunday or Duplex-and-higher feast,
with a *variable* number of readings) doesn't fit the static-array pattern
every other hour uses.
**Explicit, direct-instruction departures from the historical office** (see
memory `vu-not-a-reconstruction`/`vu-matins-design` — this app was never
meant to reconstruct any one historical form, Matins least of all):
- **Nocturn 1 is always the user's own continuous scripture-reading plan**
(`src/propers/bible-plan.ts`, sourced from a personal TSV outside this
repo), never the historical per-day lectionary — a variable number of
readings per day, and deliberately *not* the Rule of Benedict's own
"summer" reading contraction (Low Sunday1st Sunday of November): this
app reads in full year-round, live-confirmed the reference engine's own
Monastic 1617 data does apply that contraction (`monastic.pl`'s
`brevis_monastic`/`legend_monastic`) before deciding not to reproduce it.
- **Recombine, don't reproduce the historical lesson split**: where the
source splits one continuous work across several numbered lessons purely
to fill a fixed lesson-count slot, this app joins them into one reading
(`src/propers/octave-readings.ts`'s `resolvePassages`, now reused outside
the octave-day store it was built for — extended via the new
`src/propers/nocturn-readings.ts`). Split only kept where the underlying
source genuinely changes (e.g. a Gospel pericope vs. the homily on it).
- **Nocturn 3 gated at Duplex-and-higher**, plus every Sunday
unconditionally — the user's own chosen threshold (real Monastic 1617 is
more permissive), reusing the already-existing `isDoubleOrHigher` (which
already means rank ≥ `duplex`, so no new rank helper was needed).
- **Generous multi-source reading inclusion, not winner-takes-all**: every
commemorated saint (not just the office winner) and every active octave
contributes its own Nocturn 2/3 reading when authored — mirrors
`getDayCollects`'s existing "one collect per commemoration" pattern,
applied to readings. Wired `src/propers/octave-readings.ts`'s
`getOctaveReading` into a real caller for the first time — it had zero
callers anywhere in the codebase before this, clearly built ahead of
Matins and left unwired until now.
- **Gospel sourcing has exactly two allowed sources**: the user's own plan
(flagged via `isGospel`, book-derived — confirmed the user's plan never
assigns one on a Sunday, a deliberate editorial choice, not a gap) and
the day's own genuine *proper* Gospel+homily. **A Common-of-Saints
fallback Gospel is never used** — `nocturn-readings.ts` has no
Common-fallback mechanism at all (unlike `collectCommon`/
`benedictusCommon` elsewhere), so the exclusion falls out of the store's
own shape rather than needing special-case code.
- **Invitatory (Ps 94) framed like an ordinary antiphoned psalm** — one
opening (incipit-or-full), one full repeat after — not the historical
interspersed-refrain shape (confirmed live: real practice repeats the
antiphon between every verse group). Direct instruction, not an
oversight.
- **Responsories matched loosely by scriptural book**, not exact citation —
per the user's own description of how they actually worked historically.
New seeded pool, `data/hours/matins-responsories-by-book.yml` +
`src/propers/matins-responsories.ts` (currently just `isa`, from a live
Advent-ferial query — a real seed, not a full pool).
**Sunday's fixed 12-psalm/3-canticle psalmody** (`data/hours/matins-sunday-
antiphons.yml`) — Nocturn 1 = Ps 20-21/22-23/24-25, Nocturn 2 = Ps
26-27/28-29/30-31 (Rule of Benedict ch. 18's own arrangement, distinct from
Roman Matins' 9-psalm Sunday scheme), Nocturn 3 = 3 OT canticles (Isa
33:2-10, Isa 33:13-18, Sir 36:14-19) under one shared antiphon. Transcribed
from the reference engine's own Monastic-labeled block (`Psalterium/Psalmi/
Psalmi matutinum.txt`'s `[Daym0]`), then live-verified end-to-end
(`command=prayMatutinum`, 2026-09-06) — psalm numbers, canticle citations,
and the 12-lesson/Te Deum sequence all matched exactly. Real gap found
along the way: the reference engine's own UI flags Monastic 1617 Matins as
"still incomplete and under construction" — the pieces actually queried
checked out regardless, but worth remembering if a future pull comes back
looking wrong.
**Real content authored for two clean proof dates** (one per branch, per
this project's usual "mechanism first, minimal live-verified proof, content
later" build order):
- **Ferial**: 2026-12-15 (Tuesday of Advent III) — the user's own plan's
"Isa 36-37, Wis 14" (citation-only for now; full Vulgate/Douay-Rheims
import is a deferred separate project, see below), matched against the
seeded Isaiah responsory pool.
- **Sunday**: 2026-09-06 ("14th Sunday after Trinity", `post-pentecost-15`
— the Trinity-counted display label and the Pentecost-offset storage id
are a fixed, already-computed one-off relationship, not something new
built here) — Nocturn 1 from the user's plan ("Tob 1-2, Sir 45",
citation-only), Nocturn 2 a real, fully-translated patristic reading
(St. Gregory the Great, *Moralia in Job*, Bk. 9 — live-transcribed, both
languages), Nocturn 3 the day's own proper Gospel (Luke 7:11-16, the
raising of the widow's son at Naim) plus St. Augustine's homily on it
(Sermon 44), also fully transcribed. Real seam flagged, not papered over:
this Sunday's *real* historical Nocturn 1 is the Book of Job (confirmed
live) — Nocturn 2's Job commentary is kept as-is even though this app's
own Nocturn 1 no longer reads Job that week, since it's real, valuable,
season-proper patristic content, not tied to any specific saint.
- Two Isaiah chapters actually populated in `src/data/scripture/` (33,
live-transcribed for the Sunday canticles) — small, real progress against
the eventual bulk Bible import, not just placeholders.
Verified end-to-end: `tests/hours/matins.test.ts`, both branches, resolving
real dates through the actual `resolveOrdo` mechanism (psalm/canticle
counts, Te Deum presence, reading citations and statuses, Gospel flagging
and its Common-fallback exclusion, responsory matching) — not just checking
the YAML files exist. `npm test` (359 tests) and `npm run build` both pass.
**Not done in this pass — explicitly deferred, not silently dropped:**
- All ~390 rows of the user's own bible-plan TSV — only the two proof
dates' rows are converted so far.
- Full Sunday/feast-calendar patristic-reading authoring (the ~75 Duplex+
saints eligible for Nocturn 3, per the user's own threshold).
- Pre-1955-but-post-1617 saints' differently-structured readings (source
confirmed as Tridentine/Divino Afflatu rather than Monastic 1617,
combine-where-sensible rule confirmed to apply the same way — the actual
sourcing pass not started).
- The per-book responsory pool beyond its one seeded book (`isa`).
- The bulk Vulgate (Latin) + Douay-Rheims (English) import into
`src/data/scripture/` — in scope for the project long-term, explicitly
deferred past this pass per direct instruction.
- Seasonal variants of the invitatory antiphon, the ferial hymn, and
Sunday's own psalmody antiphons — all currently one fixed year-round
text each, even though the source confirms at least the invitatory
antiphon and Sunday psalmody genuinely do vary by season (e.g. `[Advm0]`
right after `[Daym0]` in the source).
- Whether any real supported year reaches a 6th Sunday of Epiphany
(`post-epiphany-6`) — the user's own plan has no row for it by design;
confirmed real (some years have it, some don't) but not yet checked
against which years this app actually needs to support. Resolved
separately: on such a date, the bible-plan loader returns honestly
`missing` rather than reusing the 5th week's rows — a deliberate,
direct-instruction exception to this project's usual "prefer real text
over missing" stance.
- The grand closing "Duo Seraphim" responsory before Te Deum (sung after
the very last lesson, distinct from that lesson's own responsory) isn't
modeled — the schema only carries one responsory per reading, and that
closing one doesn't belong to any single reading the way the others do.
Flagged in `post-pentecost-15.yml`'s own header, not fixed.
- What "a Gospel reading is its own kind" should mean beyond the `isGospel`
flag on a plain `lesson` — the user wasn't sure yet what that distinction
should look like; worth a follow-up once there's a UI to evaluate it
against.
### Vespers ferial Preces + per-feast office overrides — done (2026-08) ### Vespers ferial Preces + per-feast office overrides — done (2026-08)
**Ferial Preces**: `vespers-preces-feriales.yml`, sourced from Tridentine **Ferial Preces**: `vespers-preces-feriales.yml`, sourced from Tridentine
@@ -1189,9 +1327,6 @@ seasonally variable — what's stored is whichever form rendered live on
each one's already-chosen clean query date, not a season-aware each one's already-chosen clean query date, not a season-aware
substitution. Same class of gap as the Paschaltide alleluia suffix substitution. Same class of gap as the Paschaltide alleluia suffix
elsewhere in this file, flagged not fixed. elsewhere in this file, flagged not fixed.
- **Matins** (`src/hours/matins.ts` is still a stub) — needs its own
resolution path (reading-candidate pool, nocturn structure), not the
static parts-array pattern the other hours use.
- **The "which Sunday governs this date" temporal-id algorithm** — the fixed - **The "which Sunday governs this date" temporal-id algorithm** — the fixed
23rd-Sunday-before-Advent rule, Epiphany Sundays transferring to the end 23rd-Sunday-before-Advent rule, Epiphany Sundays transferring to the end
of the liturgical year when they don't fit before Septuagesima, and the of the liturgical year when they don't fit before Septuagesima, and the
@@ -0,0 +1,24 @@
# The user's own continuous scripture-reading plan (source: personal TSV,
# not a historical lectionary — see src/propers/bible-plan.ts's header),
# row "Tuesday of III Advent" -> "Isa 36-37, Wis 14". Two readings, no
# Gospel that day. Each `passages` list becomes one combined Matins
# reading (see hours/matins.ts) — Isa 36-37 is one continuous 2-chapter
# reading, not two separate ones, matching the user's own "recombine
# where the source doesn't genuinely split" instruction (there's no
# source split here at all; this is just how the plan cites a multi-
# chapter passage).
#
# Verse text for these chapters isn't authored in src/data/scripture yet
# (the full Vulgate/Douay-Rheims import is a deferred, separate project) —
# these readings resolve with real citations and honestly `missing` body
# text, which is expected for this proof pass.
temporalId: advent-3
weekday: tuesday
readings:
- passages:
- { book: isa, chapter: 36 }
- { book: isa, chapter: 37 }
status: { la: missing, en: missing }
- passages:
- { book: sap, chapter: 14 }
status: { la: missing, en: missing }
@@ -0,0 +1,21 @@
# The user's own continuous scripture-reading plan, row "14th Sunday
# after Trinity" -> "Tob 1-2, Sir 45" (temporalId post-pentecost-15 =
# "14th Sunday after Trinity" in this app's own Trinity-counted display
# label — Trinity Sunday itself is post-pentecost-01, so "Nth Sunday
# after Trinity" is always post-pentecost-(N+1); see calendar/
# day-label.ts and calendar/temporal-id.ts). No Gospel reading on this
# Sunday row — confirmed a deliberate editorial choice across the whole
# plan (the user's plan never puts a Gospel reading on a Sunday), not a
# gap. This Sunday's own Nocturn 1 (see hours/matins.ts) uses this data;
# Nocturns 2-3 use the day's own patristic/Gospel content instead (see
# src/propers/nocturn-readings.ts).
temporalId: post-pentecost-15
weekday: sunday
readings:
- passages:
- { book: tob, chapter: 1 }
- { book: tob, chapter: 2 }
status: { la: missing, en: missing }
- passages:
- { book: sir, chapter: 45 }
status: { la: missing, en: missing }
@@ -0,0 +1,38 @@
# A per-book pool of Matins responsories, matched loosely (by scriptural
# book, not exact citation) rather than tied to a specific chapter/verse —
# per the user's own description (2026-08): responsories aren't attuned to
# chapter/verse historically, so a book-level match is the right
# granularity, not a gap to fill in later with exact citations.
#
# Seeded, not a full pool — only the books needed for this build's proof
# content are here (see TODO.md for what's deferred). Cycled in order
# per-day when a Nocturn-1 reading's book has more than one candidate
# responsory available; the day's own responsory count doesn't have to
# match the pool's size.
#
# `isa` sourced from a live query (Monastic Tridentinum 1617, 2026-12-15,
# a plain ferial Tuesday of Advent III — real, un-summer-contracted
# Lectio 1-3 on Isa 30:18-28) — this is genuine historical Advent-season
# Isaiah content, reused here as a loose match for the user's own
# continuous Isaiah reading even though the exact chapters differ (Isa
# 36-37 vs. the source's own Isa 30), consistent with "not attuned to
# chapter/verse."
isa:
- r:
la: "Ægýpte, noli flere, quia Dominátor tuus véniet tibi, ante cujus conspéctum movebúntur abýssi, * Liberáre pópulum suum de manu poténtiæ."
en: "Weep not, O Egypt, for the Ruler cometh unto thee, and the depths shall be moved at His presence. * To deliver His people out of the hand of the mighty."
v:
la: "Ecce véniet Dóminus exercítuum, Deus tuus cum potestáte magna."
en: "Behold, the Lord of hosts, thy God, cometh with great power."
- r:
la: "Prope est ut véniat tempus ejus, et dies ejus non elongabúntur: * Miserébitur Dóminus Jacob, et Israël salvábitur."
en: "Her time is near to come, and her days shall not be prolonged. * The Lord shall have mercy on Jacob, and Israel shall be saved."
v:
la: "Revértere, virgo Israël, revértere ad civitátes tuas."
en: "Turn again, O Virgin of Israel, turn again to thy cities."
- r:
la: "Descéndet Dóminus sicut plúvia in vellus: * Oriétur in diébus ejus justítia, et abundántia pacis."
en: "The Lord shall come down like rain upon a fleece. * In His days shall righteousness flourish, and abundance of peace."
v:
la: "Et adorábunt eum omnes reges, omnes gentes sérvient ei."
en: "All the kings of the earth shall fall down before Him, all nations shall serve Him."
@@ -0,0 +1,73 @@
# Sunday Matins' fixed 12-psalm/3-canticle psalmody (Rule of Benedict ch.
# 18's own Sunday arrangement, distinct from Roman Matins' 9-psalm Sunday
# scheme). Sourced from Divinum Officium's Monastic-labeled block,
# `Latin/Psalterium/Psalmi/Psalmi matutinum.txt`'s `[Daym0]` section
# (English parallel at the same section name in the `English/` tree), and
# live-verified against a real `prayMatutinum` query (Monastic Tridentinum
# 1617, 2026-09-06, an ordinary Sunday with no feast collision) — the
# psalm numbers, canticle citations, and lesson/Te Deum sequence all
# matched exactly.
#
# Unlike the ferial weekday table (psalter-distribution.yml's `matins:`),
# this doesn't vary by weekday — it's the same every Sunday, but the
# antiphon *text* does vary by season even though the psalm numbers don't:
# `[Advm0]` right after `[Daym0]` in the same source file has its own
# Advent-specific antiphon set. Only the plain (non-seasonal) `[Daym0]` set
# is authored here; seasonal variants are a deferred follow-on, not
# required for this build (see TODO.md).
#
# Nocturn 3's 3 canticles are identified via `Psalterium/Monastic
# canticles.txt`'s internal numbering key (266 = Isa 33:2-10, 267 = Isa
# 33:13-18, 268 = Sirach/Ecclesiasticus 36:14-19, cited "sir" per this
# project's own scripture-store convention), confirmed by the live query
# ("Canticum Isaiæ" / "Canticum Isaiae" / "Canticum Ecclesiasticae").
# Unlike nocturns 1-2 (each psalm pair under its own antiphon), all 3
# canticles share a single antiphon ("Allelúja, allelúja, allelúja").
nocturn1:
groups:
- psalms: [20, 21]
antiphon:
la: "Dómine * in virtúte tua lætábitur rex."
en: "The king rejoices * in Thy strength, O Lord."
- psalms: [22, 23]
antiphon:
la: "Dóminus regit me * et nihil mihi déerit: in loco páscuæ ibi me collocávit."
en: "The Lord ruleth me * and I shall want nothing. He hath set me in a place of pasture."
- psalms: [24, 25]
antiphon:
la: "Óculi mei * semper ad Dóminum."
en: "My eyes * are ever towards the Lord."
versicle:
v: { la: "Memor fui nocte nóminis tui Dómine.", en: "In the night I have remembered thy name, O Lord:" }
r: { la: "Et custodívi legem tuam.", en: "And have kept thy law." }
nocturn2:
groups:
- psalms: [26, 27]
antiphon:
la: "Dóminus * defénsor vitæ meæ."
en: "The Lord is the defence * of my life."
- psalms: [28, 29]
antiphon:
la: "Adoráte * Dóminum in aula sancta ejus."
en: "Worship the Lord * in His holy courts."
- psalms: [30, 31]
antiphon:
la: "In tua justítia * líbera me, Dómine."
en: "Deliver me * in Thy righteousness."
versicle:
v: { la: "Média nocte surgébam ad confiténdum tibi.", en: "I rose at midnight to give praise to thee." }
r: { la: "Super justítia justificatiónis tuæ.", en: "For the judgments of thy justification." }
nocturn3:
canticles:
- { book: isa, chapter: 33, verses: "2-10" }
- { book: isa, chapter: 33, verses: "13-18" }
- { book: sir, chapter: 36, verses: "14-19" }
antiphon:
la: "Allelúja, allelúja, allelúja."
en: "Alleluia, alleluia, alleluia."
versicle:
v: { la: "Exaltáre Dómine in virtúte tua.", en: "Be Thou exalted, O Lord, in Thine own strength." }
r: { la: "Cantábimus et psallémus virtútes tuas.", en: "We will sing and praise Thy power." }
@@ -0,0 +1,17 @@
# Live-verified (Monastic Tridentinum 1617, 2026-12-15, a plain ferial
# Tuesday) — closes ferial (1-nocturn) Matins, same fixed per-annum
# chapter regardless of weekday (confirmed by the source's own rubric:
# "in the Ferial Office of either three or one lesson, [this] is always
# said as follows" — i.e. the same closing chapter whether the day got
# its full 3 lessons or the summer-contracted single short reading this
# app doesn't reproduce anyway, see hours/matins.ts).
id: matins-capitulum-ferial
text:
la: "Vigiláte, state in fide, viríliter ágite, et confortámini. Ómnia vestra in caritáte fiant."
en: "Watch ye, stand fast in the faith, do manfully, and be strengthened. Let all your things be done in charity."
status:
la: verified
en: verified
citation:
la: "1 Cor 16:13-14"
en: "1 Cor 16:13-14"
@@ -0,0 +1,99 @@
# Live-verified (Monastic Tridentinum 1617, 2026-12-15, a plain ferial
# Tuesday) — "{ex Psalterio secundum tempora}" in the source, meaning this
# is itself one of several season-variable hymns rather than a true fixed
# year-round text; only this one (evidently the ordinary-time/per-annum
# form) is authored so far. Seasonal variants deferred (see TODO.md),
# same incremental-content pattern as everywhere else in this project.
id: matins-hymn-ferial
text:
la: >-
Somno reféctis ártubus,
Spreto cubíli, súrgimus:
Nobis, Pater, canéntibus
Adésse te depóscimus.
Te lingua primum cóncinat,
Te mentis ardor ámbiat:
Ut áctuum sequéntium
Tu, Sancte, sis exórdium.
Cedant tenébræ lúmini,
Et nox diúrno síderi,
Ut culpa, quam nox íntulit,
Lucis labáscat múnere.
Precámur iídem súpplices,
Noxas ut omnes ámputes,
Et ore te canéntium
Laudéris in perpétuum.
Præsta, Pater piíssime,
Patríque compar Únice,
Cum Spíritu Paráclito
Regnans per omne sǽculum. Amen.
en: >-
Our limbs refreshed with slumber now,
And sloth cast off, in prayer we bow;
And while we sing thy praises dear,
O Father, be thou present here.
To thee our earliest morning song,
To thee our hearts full powers belong;
And thou, O Holy One, prevent
Each following action and intent.
As shades at morning flee away,
And night before the star of day;
So each transgression of the night
Be purged by thee, celestial Light!
Cut off, we pray thee, each offense,
And every lust of thought and sense:
That by their lips who thee adore
Thou mayst be praised forevermore.
Grant this, O Father ever One
With Christ, thy sole-begotten Son,
And Holy Ghost, whom all adore,
Reigning and blest forevermore. Amen.
status:
la: verified
en: verified
@@ -0,0 +1,17 @@
# Live-verified (Monastic Tridentinum 1617, 2026-09-06 and 2026-12-15).
# Real practice repeats this antiphon as an interspersed refrain between
# verse groups of Psalm 94 (the historical "Venite" invitatory shape) —
# deliberately not modeled that way here, per direct instruction: this app
# frames it like any other psalm antiphon instead, one opening
# (incipit-or-full, same rank rule as everywhere else) and one full
# repeat after, via hours/matins.ts's plain 'psalm' + 'antiphon' parts.
# Fixed year-round for now — real practice does vary this by season/feast
# (`{Antiphona ex Psalterio secundum tempora}` in the source); not yet
# modeled, a deferred follow-on (see TODO.md).
id: matins-invitatory-antiphon
text:
la: "Veníte, * Exsultémus Dómino."
en: "O come, * let us sing unto the Lord."
status:
la: verified
en: verified
+125
View File
@@ -0,0 +1,125 @@
# Live-verified (Monastic Tridentinum 1617, 2026-09-06) — fixed text, sung
# after the last lesson on a 3-nocturn day (Sunday or Duplex+; see
# hours/matins.ts and hours/types.ts's 'te-deum' ResolvedPart). Never said
# on a plain ferial (1-nocturn) day.
id: te-deum
text:
la: >-
Te Deum laudámus: * te Dóminum confitémur.
Te ætérnum Patrem * omnis terra venerátur.
Tibi omnes Ángeli, * tibi Cæli, et univérsæ Potestátes:
Tibi Chérubim et Séraphim * incessábili voce proclámant:
Sanctus, Sanctus, Sanctus * Dóminus Deus Sábaoth.
Pleni sunt cæli et terra * majestátis glóriæ tuæ.
Te gloriósus * Apostolórum chorus,
Te Prophetárum * laudábilis númerus,
Te Mártyrum candidátus * laudat exércitus.
Te per orbem terrárum * sancta confitétur Ecclésia,
Patrem * imménsæ majestátis;
Venerándum tuum verum * et únicum Fílium;
Sanctum quoque * Paráclitum Spíritum.
Tu Rex glóriæ, * Christe.
Tu Patris * sempitérnus es Fílius.
Tu, ad liberándum susceptúrus hóminem: * non horruísti Vírginis úterum.
Tu, devícto mortis acúleo, * aperuísti credéntibus regna cælórum.
Tu ad déxteram Dei sedes, * in glória Patris.
Judex créderis * esse ventúrus.
Te ergo quǽsumus, tuis fámulis súbveni, * quos pretióso sánguine redemísti.
Ætérna fac cum Sanctis tuis * in glória numerári.
Salvum fac pópulum tuum, Dómine, * et bénedic hereditáti tuæ.
Et rege eos, * et extólle illos usque in ætérnum.
Per síngulos dies * benedícimus te.
Et laudámus nomen tuum in sǽculum, * et in sǽculum sǽculi.
Dignáre, Dómine, die isto * sine peccáto nos custodíre.
Miserére nostri, Dómine, * miserére nostri.
Fiat misericórdia tua, Dómine, super nos, * quemádmodum sperávimus in te.
In te, Dómine, sperávi: * non confúndar in ætérnum.
en: >-
We praise thee, O God, * we acknowledge thee to be the Lord.
All the earth doth worship thee, * the Father everlasting.
To thee all Angels cry aloud, * the Heavens, and all the Powers therein.
To thee Cherubim and Seraphim * continually do cry.
Holy, Holy, Holy * Lord God of Sabaoth;
Heaven and earth are full * of the Majesty of thy glory.
The glorious company of the Apostles * praise thee.
The goodly fellowship of the Prophets * praise thee.
The noble army of Martyrs * praise thee.
The holy Church throughout all the world * doth acknowledge thee;
The Father, * of an infinite Majesty.
Thine honourable, true, * and only Son;
Also the Holy Ghost, * the Comforter.
Thou art the King of Glory, * O Christ.
Thou art the everlasting * Son of the Father.
When thou tookest upon thee to deliver man, * thou didst not abhor the Virgin's womb.
When thou hadst overcome the sharpness of death, * thou didst open the Kingdom of Heaven to all believers.
Thou sittest at the right hand of God, * in the glory of the Father.
We believe that thou shalt come * to be our Judge.
We therefore pray thee, help thy servants, * whom thou hast redeemed with thy precious Blood.
Make them to be numbered with thy Saints, * in glory everlasting.
O Lord, save thy people, * and bless thine heritage.
Govern them, * and lift them up for ever.
Day by day * we magnify thee;
And we worship thy Name * ever, world without end.
Vouchsafe, O Lord, to keep us * this day without sin.
O Lord, have mercy upon us, * have mercy upon us.
O Lord, let thy mercy lighten upon us, * as our trust is in thee.
O Lord, in thee have I trusted, * let me never be confounded.
status:
la: verified
en: verified
@@ -0,0 +1,202 @@
# Nocturn 2-3 content for an ordinary Sunday with no saint of its own
# (post-pentecost-15 = "14th Sunday after Trinity" in this app's own
# display labeling — see calendar/day-label.ts). Live-verified against
# Divinum Officium (Monastic Tridentinum 1617, 2026-09-06, a plain Sunday
# with no feast collision — confirmed by cross-checking the same date
# against data/calendar/sanctoral-calendar.yml).
#
# Nocturn 1 for this same Sunday comes from the user's own bible-plan
# instead (data/hours/bible-plan/post-pentecost-15-sunday.yml, Tob 1-2 /
# Sir 45) — historically this Sunday's real Nocturn 1 continuous reading
# is the Book of Job (confirmed live), but the user's plan deliberately
# substitutes its own reading there. Nocturn 2 below is still the real
# historical St. Gregory commentary *on Job*, even though this app's own
# Nocturn 1 no longer reads Job that week — kept as-is (real, valuable
# patristic content proper to the ordinary-time cycle, not tied to a
# specific saint) rather than dropped for no longer lining up thematically
# with Nocturn 1. Flagged, not silently resolved — a real seam between
# the historical Moralia-in-Job commentary cycle and this app's own
# substituted Nocturn 1 plan.
#
# Combined per the user's "recombine where the source doesn't genuinely
# split" instruction: Nocturn 2's 4 lessons (Lectio 5-8) are one
# continuous excerpt from the same work (Moralia, Bk. 9, ch. 2) — no real
# source break between them, so one reading. Nocturn 3's Lectio 9 is
# genuinely two different sources in the one numbered lesson (the Gospel
# pericope itself, then the opening of St. Augustine's homily on it) —
# kept split into two readings here; the homily then continues unbroken
# through Lectio 10-12, recombined into that second reading.
#
# Responsory: this project's own established convention (see
# octave-readings.ts) is "the first one, when several collapse" — applied
# here per resulting reading: Nocturn 2's reading uses Lectio 5's
# responsory; the Gospel reading uses Lectio 9's (the only one that's
# genuinely its own, since Lectio 9 itself split across both new
# readings); the homily reading uses Lectio 10's (the first responsory
# that's unambiguously the homily's own). Known simplification, flagged
# not fixed: Lectio 12's own closing "Duo Seraphim" responsory (with its
# own Gloria Patri, sung specially before the Te Deum) isn't represented
# here at all — this schema only carries one responsory per reading, and
# that grand closing responsory doesn't belong to any single one of these
# readings the way the others do.
id: post-pentecost-15
readings:
- nocturn: 2
isGospel: false
source: "St. Gregory the Great, Moralia in Job, Book 9, ch. 2"
text:
la: >-
Vero scio quod ita sit, et quod non justificábitur homo compósitus Deo. Homo quippe Deo non
compósitus justítiam pércipit, compósitus amíttit; quia quisquis se auctóri bonórum cómparat,
bono se, quod accéperat, privat. Qui enim accépta bona sibi árrogat, suis contra Deum donis
pugnat. Unde ergo despéctus erígitur, dignum est ut eréctus inde destruátur.
Sanctus autem vir, quia omne virtútis nostræ méritum esse vítium cónspicit, si ab intérno
árbitro distrícte judicétur, recte subjúngit: Si volúerit conténdere cum eo, non póterit
respondére ei unum pro mille.
In Scriptúra sancta millenárius númerus pro universitáte solet intélligi. Hinc étenim
Psalmísta ait: Verbi, quod mandávit in mille generatiónes: cum profécto constet, quod ab ipso
mundi exórdio usque ad Redemptiónis advéntum per Evangelístam non ámplius quam septuagínta et
septem propágines numeréntur. Quid ergo in millenário número nisi ad proferéndam novam
sóbolem perfécta univérsitas præscítæ generatiónis exprímitur? Hinc et per Joánnem dícitur: Et
regnábunt cum eo mille annis: quia vidélicet regnum sanctæ Ecclésiæ universitátis perfectióne
solidátur.
Quia vero monas décies multiplicáta in denárium dúcitur, denárius per semetípsum ductus in
centenárium dilatátur, qui rursus per denárium ductus in millenárium ténditur; cum ab uno
incípimus, ut ad millenárium veniámus, quid hoc loco uníus appellatióne, nisi bene vivéndi
inítium? quid millenárii númeri amplitúdine, nisi ejúsdem bonæ vitæ perféctio designátur? Cum
Deo autem conténdere, est, non ei tribúere, sed sibi glóriam suæ virtútis arrogáre. Sed
sanctus vir conspíciat, quia et qui summa jam dona percépit, si de accéptis extóllitur, cuncta
quæ accéperat, amíttit.
en: >-
We know that it is so of a truth, and that a man cannot be justified as against God. When God
is put out of the consideration, a man may be considered to be just, but considered as against
God, his righteousness vanisheth away. When a man measureth himself by his relation to Him,
Who is the Author of all good, he doth thereby acknowledge that of himself he hath no good in
him, but hath received from God whatsoever he hath. He that glorifieth himself because of good
which hath been given him, fighteth against God with God's own gifts. It is just therefore
that the grounds upon which he ought to have been humbled, but upon which he hath puffed
himself up, should be used to humble his vain-glory.
But an holy man, because he perceiveth that the worth of our own good deeds falleth short,
when he considereth his own spiritual man, justly saith If He will contend with him, he cannot
answer Him one of a thousand.
In the Holy Scriptures the numeral a thousand is used to be taken as signifying a
generalization. Thus, the Psalmist saith The word which He commanded to a thousand generations
(Ps. civ. 8), whereas it is notorious that the Evangelist doth not reckon more then
seventy-and-seven generations between the very beginning of the world and the coming of our
Redeemer. What therefore is to be understood here by a thousand? The general ripeness of the
old generation to bring forth a new offspring. Hence also it is said by John And shall reign
with Him a thousand years (Apoc. xx. 6), because the reign of the Holy Church will be over all
mankind made perfect.
When times one is ten, and ten times ten is an hundred, and ten times an hundred is a
thousand. Observing therefore this connection between one and a thousand, what are we to
understand by the one (in the text, connected as it is with the thousand whereby we understand
perfection)? Is it not the beginning of a good life, even as the thousand representeth
perfection? The contending with God (which is spoken of in the text) is the non-acknowledgment
of that which is owed to Him, and the vain-glorying instead in our own strength. But an holy
man should see, that even if one had received the gifts of perfection, and were to make them
the grounds of self-glorifying, such an one would thereby lose all that he had received.
responsory:
la: "Indúta est caro mea putrédine, et sórdibus púlveris cutis mea áruit et contrácta est: * Meménto mei, Dómine, quóniam ventus est vita mea.\nV. Dies mei velócius transiérunt quam a texénte tela succíditur, et consúmpti sunt absque ulla spe.\nR. Meménto mei, Dómine, quóniam ventus est vita mea."
en: "My flesh is clothed with worms and clods of dust. My skin is dry and drawn together. * Remember me, O Lord, for my life is wind.\nV. My days are swifter than a weaver's shuttle, and are spent without hope.\nR. Remember me, Lord, for my life is wind."
status: { la: verified, en: verified }
- nocturn: 3
isGospel: true
citation: { la: "Luc 7:11-16", en: "Luke 7:11-16" }
# Full text, not the "Et réliqua" incipit Lectio 9 itself uses — the
# live rendering has this same Gospel read out in full a second time,
# right after the Te Deum, closing with its own "Te decet laus"
# versicle rather than the usual "Tu autem, Dómine, miserére nobis" —
# using that fuller form here rather than modeling the two Gospel
# readings (incipit-only at Lectio 9, full after Te Deum) as separate
# parts, a known simplification (see hours/matins.ts).
text:
la: "In illo témpore: Ibat Jesus in civitátem, quæ vocátur Naim: et ibant cum eo discípuli ejus et turba copiósa. Cum autem appropinquáret portæ civitátis, ecce, defúnctus efferebátur fílius únicus matris suæ: et hæc vídua erat, et turba civitátis multa cum illa. Quam cum vidísset Dóminus, misericórdia motus super eam, dixit illi: Noli flere. Et accéssit et tétigit lóculum. Hi autem, qui portábant, stetérunt. Et ait: Adoléscens, tibi dico, surge. Et resédit, qui erat mórtuus, et cœpit loqui. Et dedit illum matri suæ. Accépit autem omnes timor: et magnificábant Deum, dicéntes: Quia Prophéta magnus surréxit in nobis: et quia Deus visitávit plebem suam."
en: "At that time, Jesus went to a town called Naim; and His disciples and a large crowd went with Him. And as He drew near the gate of the town, behold, a dead man was being carried out, the only son of his mother, and she was a widow; and a large gathering from the town was with her. And the Lord, seeing her, had compassion on her, and said to her, Do not weep. And He went up and touched the stretcher; and the bearers stood still. And He said, Young man, I say to you, arise. And he who was dead, sat up, and began to speak. And He gave him to his mother. But fear seized upon all, and they began to glorify God, saying, A great prophet has risen among us, and God has visited His people."
responsory:
la: "Quis mihi tríbuat, ut in inférno prótegas me et abscóndas me, donec pertránseat furor tuus, Dómine, nisi tu, qui solus es Deus? * Et constítuas mihi tempus, in quo recordéris mei?\nV. Numquid sicut dies hóminis dies tui, ut quæras iniquitátem meam; cum sit nemo, qui de manu tua possit erúere.\nR. Et constítuas mihi tempus, in quo recordéris mei?"
en: "O that Thou wouldest hide me in the grave; that Thou wouldest keep me secret, until thy wrath be past, even thine, O Lord, Thou That alone art God; * That Thou wouldest appoint me a set time, and remember me!\nV. Are thy days as the days of man, that Thou inquirest after mine iniquity, and there is none that can deliver out of thine hand.\nR. That Thou wouldest appoint me a set time, and remember me!"
status: { la: verified, en: verified }
- nocturn: 3
isGospel: false
source: "St. Augustine, Bishop of Hippo, Sermon 44 on the Words of the Lord"
text:
la: >-
De júvene illo resuscitáto gavísa est mater vídua; de homínibus in spíritu quotídie suscitátis
gaudet mater Ecclésia. Ille quidem mórtuus erat córpore; illi autem mente. Illíus mors
visíbilis visibíliter plangebátur; illórum mors invisíbilis nec quærebátur, nec videbátur.
Quæsívit ille, qui nóverat mórtuos. Ille solus nóverat mórtuos, qui póterat fácere vivos. Nisi
enim ad mórtuos suscitándos venísset, Apóstolus non díceret: Surge, qui dormis, et exsúrge a
mórtuis, et illuminábit te Christus.
Tres autem mórtuos invenímus a Dómino resuscitátos visibíliter, míllia invisibíliter. Quot
autem mórtuos visibíliter suscitáverit, quis novit? Non enim ómnia, quæ fecit, scripta sunt.
Joánnes hoc dixit: Multa ália fecit Jesus, quæ si scripta essent, árbitror totum mundum non
posse libros cápere. Multi ergo sunt álii sine dúbio suscitáti, sed non tres frustra
commemoráti.
Dóminus enim noster Jesus Christus ea quæ faciébat corporáliter, étiam spiritáliter volébat
intélligi. Neque enim tantum mirácula propter mirácula faciébat; sed ut illa, quæ faciébat,
mira essent vidéntibus, vera essent intelligéntibus. Quemádmodum qui videt lítteras in códice
óptime scripto, et non novit légere, laudat quidem antiquárii manum, admírans ápicum
pulchritúdinem; sed quid sibi velint, quid índicent illi ápices, nescit, et est óculis
laudátor, mente non cógnitor.
Alius autem et laudat artifícium, et capit intelléctum: ille útique, qui non solum vidére quod
commúne est ómnibus, potest, sed étiam légere; quod qui non dídicit, non potest. Ita qui
vidérunt Christi mirácula, et non intellexérunt quid sibi vellent, et quid intelligéntibus
quodámmodo innúerent, miráti sunt tantum quia facta sunt; álii vero et facta miráti, et
intellécta assecúti. Tales nos in schola Christi esse debémus.
en: >-
That her son was called again to life was the joy of that widowed mother; that souls of men
are every day called to life is the joy of our Mother the Church. He was dead in body they
have been dead in mind. His death was outward, and was outwardly bewailed; their inward. Death
hath been neither mourned for nor seen. But He hath sought for them, Who hath seen that they
are dead, and He only hath seen that they are dead, Who hath been able to make them alive. If
He had not come to raise the dead, the Apostle had not said: "Awake, thou that sleepest, and
arise from the dead, and Christ shall give thee light." (Eph. v. 14.)
We find written how the Lord raised from the dead three persons visibly, but thousands
invisibly. But how many they may have been whom He raised visibly, who knoweth? For all the
things which He did are not written. John saith thus: "There are also many other things which
Jesus did, the which, if they should be written every one, I suppose that even the world
itself could not contain the books that should be written." (John xxi. 25.) There were then,
doubtless, many more raised to life, but it is not meaningless that three are recorded.
For our Lord Jesus Christ hath willed that those things which He did carnally, we should
understand also spiritually. He worked not miracles only for the sake of working wonders, but
that His works might be at once wonderful to them that beheld, and true to them that
understand them. Even as one that looketh upon a scroll right fairly written, and knoweth not
how to read therein, praiseth the hand of the old scribe when he seeth the beauty of the
points, but what it saith, what those points mean, he knoweth not, and praiseth by the eye,
without understanding by the mind.
Another both praiseth the workmanship, and grasps the meaning: that is, he who can not only
see what is common to all, but can also read; which he who hath not learned cannot do. So they
who saw the miracles of Christ, and understood not what they meant, and what they intimated in
some sort to those who understand, only marvelled that they were done; but others both
marvelled at the deeds, and grasped what they meant. Such ought we to be in the school of
Christ.
responsory:
la: "Numquid supplántat judícium? aut Omnípotens subvértit quod iustum est? † Líbera me, Dómine, et pone me juxta te: * Et cujúsvis manus pugnet contra me.\nV. Apprehénde arma et scutum, et exsúrge in adjutórium mihi.\nR. Et cujúsvis manus pugnet contra me."
en: "Does God pervert judgment, or does the Almighty overthrow that which is just? Deliver me, O Lord, and set me beside you, * And let any man's hand fight against me.\nV. Take hold of arms and shield: and rise up to help me.\nR. And let any man's hand fight against me."
status: { la: verified, en: verified }
+94
View File
@@ -0,0 +1,94 @@
# Isaiah 33 — verses 2-10 and 13-18 transcribed from a live Divinum
# Officium query (Monastic Tridentinum 1617, 2026-09-06, Sunday Matins
# Nocturn III canticles) — see data/hours/matins-sunday-antiphons.yml.
# Only the two verse ranges actually used by that canticle are authored;
# the rest of the chapter is unauthored (empty per src/scripture's own
# "unauthored -> not present" convention).
book: isa
chapter: 33
verses:
- n: 2
text:
la: "Dómine, miserére nostri, te enim exspectávimus; esto bráchium nostrum in mane, * et salus nostra in témpore tribulatiónis."
en: "O Lord, have mercy on us: for we have waited for thee: be thou our arm in the morning, and our salvation in the time of trouble."
status: { la: verified, en: verified }
- n: 3
text:
la: "A voce Angeli fugérunt pópuli, * et ab exaltatióne tua dispérsæ sunt gentes."
en: "At the voice of the angel the people fled, and at the lifting up thyself the nations are scattered."
status: { la: verified, en: verified }
- n: 4
text:
la: "Et congregabúntur spólia vestra sicut collígitur bruchus, * velut cum fossæ plenæ fúerint de eo."
en: "And your spoils shall be gathered together as the locusts are gathered, as when the ditches are full of them."
status: { la: verified, en: verified }
- n: 5
text:
la: "Magnificátus est Dóminus, quóniam habitávit in excélso; * implévit Sion judício et justítia."
en: "The Lord is magnified, for he hath dwelt on high: he hath filled Sion with judgment and justice."
status: { la: verified, en: verified }
- n: 6
text:
la: "Et erit fides in tempóribus tuis: divítiæ salútis sapiéntia: * et sciéntia; timor Dómini ipse est thesáurus ejus."
en: "And there shall be faith in thy times: riches of salvation, wisdom and knowledge: the fear of the Lord is his treasure."
status: { la: verified, en: verified }
- n: 7
text:
la: "Ecce vidéntes clamábunt foris; * ángeli pacis amáre flebunt."
en: "Behold they that see shall cry without, the angels of peace shall weep bitterly."
status: { la: verified, en: verified }
- n: 8
text:
la: "Dissipátæ sunt viæ, cessávit tránsiens per sémitam: írritum factum est pactum, * projécit civitátes, non reputávit hómines."
en: "The ways are made desolate, no one passeth by the road, the covenant is made void, he hath rejected the cities, he hath not regarded the men."
status: { la: verified, en: verified }
- n: 9
text:
la: "Luxit et elánguit terra; * confúsus est Líbanus, et obsórduit."
en: "The land hath mourned, and languished: Libanus is confounded and become foul, and Saron is become as a desert: and Basan and Carmel are shaken."
status: { la: verified, en: verified }
- n: 9
text:
la: "Et factus est Saron sicut desértum, * et concússa est Basan, et Carmélus."
en: ""
status: { la: verified, en: missing }
- n: 10
text:
la: "Nunc consúrgam, dicit Dóminus; * nunc exaltábor, nunc sublevábor."
en: "Now will I rise up, saith the Lord: now will I be exalted, now will I lift up myself."
status: { la: verified, en: verified }
- n: 13
text:
la: "Audíte, qui longe estis, quæ fécerim; * et cognóscite, vicíni, fortitúdinem meam."
en: "Hear, you that are far off, what I have done, and you that are near know my strength."
status: { la: verified, en: verified }
- n: 14
text:
la: "Contérriti sunt in Sion peccatóres; * possédit tremor hypócritas."
en: "The sinners in Sion are afraid, trembling hath seized upon the hypocrites. Which of you can dwell with devouring fire? which of you shall dwell with everlasting burnings?"
status: { la: verified, en: verified }
- n: 14
text:
la: "Quis póterit habitáre de vobis cum igne devoránte? * quis habitábit ex vobis cum ardóribus sempitérnis?"
en: ""
status: { la: verified, en: missing }
- n: 15
text:
la: "Qui ámbulat in justítiis et lóquitur veritátem, qui próicit avarítiam ex calúmnia, * et éxcutit manus suas ab omni múnere,"
en: "He that walketh in justices, and speaketh truth, that casteth away avarice by oppression, and shaketh his hands from all bribes, that stoppeth his ears lest he hear blood, and shutteth his eyes that he may see no evil."
status: { la: verified, en: verified }
- n: 16
text:
la: "Qui obtúrat aures suas ne áudiat sánguinem, * et claudit óculos suos ne vídeat malum."
en: "He shall dwell on high, the fortifications of rocks shall be his highness: bread is given him, his waters are sure."
status: { la: verified, en: verified }
- n: 17
text:
la: "Iste in excélsis habitábit; muniménta saxórum sublímitas ejus: * panis ei datus est, aquæ ejus fidéles sunt."
en: "His eyes shall see the king in his beauty, they shall see the land far off."
status: { la: verified, en: verified }
- n: 18
text:
la: "Regem in decóre suo vidébunt óculi ejus, cernent terram de longe. * Cor tuum meditábitur timórem."
en: "Thy heart shall meditate fear: where is the learned? where is he that pondereth the words of the law? where is the teacher of little ones?"
status: { la: verified, en: verified }
+43
View File
@@ -0,0 +1,43 @@
# Sirach (Ecclesiasticus) 36:14-19, transcribed from a live Divinum
# Officium query (Monastic Tridentinum 1617, 2026-09-06, Sunday Matins
# Nocturn III's third canticle) — see
# data/hours/matins-sunday-antiphons.yml. Only this verse range is
# authored; the rest of the chapter is unauthored.
book: sir
chapter: 36
verses:
- n: 14
text:
la: "Miserére plebi tuæ, super quam invocátum est nomen tuum: * et Israël quem coæquásti primogénito tuo."
en: "Have mercy on thy people, upon whom thy name is invoked: and upon Israel, whom thou hast raised up to be thy firstborn."
status: { la: verified, en: verified }
- n: 15
text:
la: "Miserére civitáti sanctificatiónis tuæ, Jerúsalem, * civitáti requiéi tuæ."
en: "Have mercy on Jerusalem, the city which thou hast sanctified, the city of thy rest."
status: { la: verified, en: verified }
- n: 16
text:
la: "Reple Sion inenarrabílibus verbis tuis, * et glória tua pópulum tuum."
en: "Fill Sion with thy unspeakable words, and thy people with thy glory."
status: { la: verified, en: verified }
- n: 17
text:
la: "Da testimónium his qui ab inítio creatúræ tuæ sunt, * et súscita prædicatiónes quas locúti sunt in nómine tuo prophétæ prióres."
en: "Give testimony to them that are thy creatures from the beginning, and raise up the prophecies which the former prophets spoke in thy name."
status: { la: verified, en: verified }
- n: 18
text:
la: "Da mercédem sustinéntibus te, * ut prophétæ tui fidéles inveniántur:"
en: "Reward them that patiently wait for thee, that thy prophets may be found faithful: and hear the prayers of thy servants,"
status: { la: verified, en: verified }
- n: 18
text:
la: "Et exáudi oratiónes servórum tuórum, secúndum benedictiónem Aaron de pópulo tuo: * et dírige nos in viam justítiæ."
en: ""
status: { la: verified, en: missing }
- n: 19
text:
la: "Et sciant omnes qui hábitant terram, * quia tu es Deus conspéctor sæculórum."
en: "According to the blessing of Aaron over thy people, and direct us into the way of justice, and let all know that dwell upon the earth, that thou art God the beholder of all ages."
status: { la: verified, en: verified }
+260 -6
View File
@@ -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 type BilingualText = Partial<Record<string, string>>;
// pool + rank/season-driven slotting), not the static parts-array pattern the interface SundayGroup {
// other hours use — see the plan's "Matins schema seam" note. Not built yet. psalms: number[];
export function resolveOrdo(date: string): ResolvedOrdo { antiphon: BilingualText;
return { hourId: 'matins', date, parts: [], notImplemented: true }; }
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<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 = 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<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,
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) };
} }
+33 -1
View File
@@ -204,10 +204,42 @@ export type ResolvedPart =
// A standalone antiphon, said on its own rather than framing a psalm — // A standalone antiphon, said on its own rather than framing a psalm —
// see 'closing-antiphon' above. // see 'closing-antiphon' above.
| { kind: 'antiphon'; text: ResolvedText } | { kind: 'antiphon'; text: ResolvedText }
// Matins only. Groups a nocturn's psalms for the UI — each entry is a
// plain 'psalm' ResolvedPart (Sunday/Duplex+ nocturns 1-2 use the fixed
// Ps 20-31 table, nocturn 3 uses 'canticle' entries instead; ferial days
// have a single unnumbered nocturn and don't use this wrapper at all —
// see hours/matins.ts). `nocturn` is 1-3.
| { kind: 'nocturn-psalmody'; nocturn: number; psalms: ResolvedPart[] }
// Matins only. Sung after the last lesson on a 3-nocturn day (Sunday or
// a Duplex+ sanctoral winner) — see hours/matins.ts. Never present on a
// plain ferial (1-nocturn) day.
| { kind: 'te-deum'; text: ResolvedText }
// Martyrology and Regula both render as a block of prose text with a // Martyrology and Regula both render as a block of prose text with a
// label (a fixed section name for Martyrology, a date-derived chapter // label (a fixed section name for Martyrology, a date-derived chapter
// title for Regula) rather than a fixed textRef. // title for Regula) rather than a fixed textRef.
| { kind: 'lesson'; text: ResolvedText; label?: string }; // `nocturn`/`source` are Matins-only (see hours/matins.ts): `nocturn`
// groups a reading under its 'nocturn-psalmody' sibling for the UI (1-3,
// omitted on a ferial 1-nocturn day); `source` is a human-readable
// attribution ("St. John Chrysostom, Homily 3 on Matthew"; the user's
// own bible-reading plan doesn't carry one). `isGospel` flags a reading
// as a Gospel pericope (from either the user's own continuous-reading
// plan or the day's own proper Gospel+homily — never a Common-of-Saints
// fallback Gospel, which this app deliberately excludes for Matins, see
// hours/matins.ts) rather than a distinct part kind, since a Gospel
// reading is still fundamentally a lesson — just one worth marking.
// `responsory` is the short responsory said after the reading, when one
// was sourced (matched loosely by scriptural book for the user's own
// plan, live-queried directly for patristic/hagiographic readings) —
// absent, not fabricated, when none could be sourced.
| {
kind: 'lesson';
text: ResolvedText;
label?: string;
nocturn?: number;
source?: string;
isGospel?: boolean;
responsory?: ResolvedText;
};
export interface ResolvedVerse { export interface ResolvedVerse {
n: number; n: number;
+110
View File
@@ -0,0 +1,110 @@
// The user's own continuous scripture-reading plan for Matins Nocturn 1 —
// deliberately not the historical per-day lectionary (see hours/matins.ts's
// header and TODO.md's Matins section): a personal year-round reading plan,
// keyed by the same (temporalId, weekday) pair every other content store in
// this project already uses to identify a day, sourced from a TSV the user
// maintains outside this repo. Replaces the historical Nocturn-1 lesson
// count entirely — a *variable* number of readings per day, no RB summer
// contraction (see calendar/temporal-id.ts's resolveTemporalId for the
// id scheme this keys off).
//
// Only a small, growable subset is authored so far — this is the mechanism
// build, not the full-calendar content pass (~390 rows total in the
// source TSV); see TODO.md for what's deferred. Every entry not yet
// authored here simply resolves to no readings (honest absence, not a
// placeholder) — matins.ts falls back to whatever nocturn-2/3 content
// exists for the day, or renders nothing for Nocturn 1 on a day with
// neither.
import type { LanguageCode, TranslationStatus } from '../psalter/types';
import { resolvePassages, type ScriptureCitation } from './octave-readings';
import { getResponsoryForBook } from './matins-responsories';
const GOSPEL_BOOKS = new Set(['matt', 'mark', 'luke', 'john']);
export interface BiblePlanReading {
text: Partial<Record<LanguageCode, string>>;
status: Partial<Record<LanguageCode, TranslationStatus>>;
citation: Partial<Record<LanguageCode, string>>;
/** True when every passage in this reading is from one of the four
* Gospels the user's own plan deliberately never assigns one on a
* Sunday (confirmed 2026-08, not a gap) see hours/matins.ts for how
* this combines with the day's own proper Gospel+homily, when one
* exists, rather than replacing it. */
isGospel: boolean;
/** Matched loosely by the reading's own (first) book against
* data/hours/matins-responsories-by-book.yml undefined when nothing's
* seeded for that book yet, an honest absence, not a placeholder. */
responsory?: Partial<Record<LanguageCode, string>>;
}
interface BiblePlanReadingRecord {
passages: ScriptureCitation[];
/** Set explicitly when the cited passages aren't in src/data/scripture
* yet (the common case until the full Vulgate/Douay-Rheims import
* lands) mirrors octave-readings.ts's own convention rather than
* inferring "missing" from empty resolved text, since a genuinely short
* authored passage could otherwise look indistinguishable from an
* unauthored one. */
status?: Partial<Record<LanguageCode, TranslationStatus>>;
}
interface BiblePlanDayRecord {
temporalId: string;
weekday: string;
readings: BiblePlanReadingRecord[];
}
function citationLabel(citation: ScriptureCitation): string {
const book = citation.book.charAt(0).toUpperCase() + citation.book.slice(1);
return citation.verses ? `${book} ${citation.chapter}:${citation.verses}` : `${book} ${citation.chapter}`;
}
function isGospelReading(passages: ScriptureCitation[]): boolean {
return passages.every((p) => GOSPEL_BOOKS.has(p.book));
}
function resolveReading(record: BiblePlanReadingRecord, bookIndex: number): BiblePlanReading {
const text = resolvePassages(record.passages);
const status = record.status ?? {
la: text.la ? 'verified' : 'missing',
en: text.en ? 'verified' : 'missing',
};
const label = record.passages.map(citationLabel).join('; ');
const firstBook = record.passages[0]?.book;
const responsory = firstBook ? getResponsoryForBook(firstBook, bookIndex)?.text : undefined;
return {
text,
status,
citation: { la: label, en: label },
isGospel: isGospelReading(record.passages),
responsory,
};
}
const modules = import.meta.glob<{ default: BiblePlanDayRecord }>('../data/hours/bible-plan/*.yml', {
eager: true,
});
const readingsByKey = new Map<string, BiblePlanReading[]>();
for (const mod of Object.values(modules)) {
const { temporalId, weekday, readings } = mod.default;
// Cycles independently per book (not per reading) so two same-day
// readings from the same book don't collide on the pool's first entry —
// see matins-responsories.ts's own cycling doc comment.
const seenPerBook = new Map<string, number>();
const resolved = readings.map((record) => {
const book = record.passages[0]?.book ?? '';
const index = seenPerBook.get(book) ?? 0;
seenPerBook.set(book, index + 1);
return resolveReading(record, index);
});
readingsByKey.set(`${temporalId}-${weekday}`, resolved);
}
/** Empty array, not undefined, when nothing's authored for this day yet
* every caller already treats "no readings" as a valid, renderable state
* (see hours/matins.ts), so there's no separate "missing entirely" signal
* to preserve here the way getOctaveReading needs `undefined` for. */
export function getBiblePlanReadings(temporalId: string, weekday: string): BiblePlanReading[] {
return readingsByKey.get(`${temporalId}-${weekday}`) ?? [];
}
+58
View File
@@ -0,0 +1,58 @@
// The per-book Matins responsory pool — see
// data/hours/matins-responsories-by-book.yml's own header for why matching
// is book-level, not citation-level, and why this is a seeded subset, not
// a full pool.
import type { LanguageCode, TranslationStatus } from '../psalter/types';
interface BilingualText {
la?: string;
en?: string;
}
interface ResponsoryRecord {
r: BilingualText;
v: BilingualText;
}
/** Shaped like hours/types.ts's ResolvedText, but this module doesn't
* import from hours/ (propers/ is lower in the dependency graph) callers
* assemble the real ResolvedText themselves. The responsory's versicle is
* folded into `text` as a second line (R./V. read together as one block in
* this app's rendering, same convention as the octave-readings responsory
* field). */
export interface MatinsResponsory {
text: Partial<Record<LanguageCode, string>>;
status: Partial<Record<LanguageCode, TranslationStatus>>;
}
const modules = import.meta.glob<{ default: Record<string, ResponsoryRecord[]> }>(
'../data/hours/matins-responsories-by-book.yml',
{ eager: true },
);
const poolByBook = new Map<string, ResponsoryRecord[]>();
for (const mod of Object.values(modules)) {
for (const [book, entries] of Object.entries(mod.default)) {
poolByBook.set(book, entries);
}
}
function joinResponsory(record: ResponsoryRecord): MatinsResponsory {
const la = record.r.la && record.v.la ? `${record.r.la}\n${record.v.la}` : record.r.la;
const en = record.r.en && record.v.en ? `${record.r.en}\n${record.v.en}` : record.r.en;
return {
text: { la, en },
status: { la: la ? 'verified' : 'missing', en: en ? 'verified' : 'missing' },
};
}
/** Undefined when no responsory is seeded for this book yet an honest
* absence (see hours/types.ts's 'lesson' ResolvedPart doc comment), not a
* placeholder. `index` cycles through the pool (e.g. the Nth reading from
* this book this nocturn) via modulo, so a short pool still gives every
* reading *some* responsory rather than only the first one. */
export function getResponsoryForBook(book: string, index: number): MatinsResponsory | undefined {
const pool = poolByBook.get(book);
if (!pool || pool.length === 0) return undefined;
return joinResponsory(pool[index % pool.length]!);
}
+49
View File
@@ -0,0 +1,49 @@
// Matins Nocturn 2/3 readings for regular (non-octave-day) Sunday and
// Duplex+ feast days — extends src/propers/octave-readings.ts's own
// "combine several historical lessons into one reading, per real source
// change" pattern to ordinary days, not just the days *within* an octave
// that file already covers. Kept as a separate store (not folded into
// octave-readings.ts) because the id scheme is flat (a saint id or a
// temporal id, not `${octaveId}-octave-day-${n}`) and because a day can
// have several of these active at once — see hours/matins.ts, which
// loops every commemorated saint (calendar/types.ts's
// LiturgicalDay.commemorations) and every calendar/octaves.ts
// activeOctavesFor(date) entry, not just the office winner, per the
// user's own "be generous, not winner-takes-all" instruction (2026-08).
import type { LanguageCode, TranslationStatus } from '../psalter/types';
export interface NocturnReading {
nocturn: number;
isGospel: boolean;
/** Attribution, e.g. "St. Gregory the Great, Moralia in Job, Bk. 9"
* absent for a bare Gospel pericope (the citation itself is enough). */
source?: string;
citation?: Partial<Record<LanguageCode, string>>;
text: Partial<Record<LanguageCode, string>>;
responsory?: Partial<Record<LanguageCode, string>>;
status: Partial<Record<LanguageCode, TranslationStatus>>;
}
interface NocturnReadingsFile {
id: string;
readings: NocturnReading[];
}
const modules = import.meta.glob<{ default: NocturnReadingsFile }>('../data/propers/nocturn-readings/*.yml', {
eager: true,
});
const readingsById = new Map<string, NocturnReading[]>();
for (const mod of Object.values(modules)) {
readingsById.set(mod.default.id, mod.default.readings);
}
/** Empty array, not undefined a saint/temporal id with nothing authored
* here yet is the common case during this build's incremental content
* pass (see TODO.md), not an error; callers just get nothing to render
* for that id's own Nocturn 2/3 contribution. `id` is a saint id or a
* temporal id (calendar/temporal-id.ts's resolveTemporalId), matched
* exactly as `resolveOfficeWinner`/`day.commemorations` entries name it. */
export function getNocturnReadings(id: string): NocturnReading[] {
return readingsById.get(id) ?? [];
}
+5 -1
View File
@@ -58,7 +58,11 @@ interface OctaveReadingRecord {
status: Partial<Record<LanguageCode, TranslationStatus>>; status: Partial<Record<LanguageCode, TranslationStatus>>;
} }
function resolvePassages(passages: ScriptureCitation[]): Partial<Record<LanguageCode, string>> { /** Exported for reuse by src/propers/bible-plan.ts and
* src/propers/nocturn-readings.ts, which need the same "pull several
* scripture passages and join them as paragraphs" behavior for Matins
* readings outside the octave-day-keyed store this file owns. */
export function resolvePassages(passages: ScriptureCitation[]): Partial<Record<LanguageCode, string>> {
const laParagraphs: string[] = []; const laParagraphs: string[] = [];
const enParagraphs: string[] = []; const enParagraphs: string[] = [];
for (const citation of passages) { for (const citation of passages) {
+21 -2
View File
@@ -69,10 +69,11 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
`; `;
case 'lesson': case 'lesson':
return ` return `
<section class="ordo-part ordo-part-lesson"> <section class="ordo-part ordo-part-lesson${part.isGospel ? ' ordo-part-lesson-gospel' : ''}">
<h3 class="ordo-part-label">${part.label ? escapeHtml(part.label) : 'Reading'}</h3> <h3 class="ordo-part-label">${part.isGospel ? 'Gospel' : part.label ? escapeHtml(part.label) : 'Reading'}</h3>
${renderCitation(part.text)} ${renderCitation(part.text)}
${renderColumns(part.text, languages)} ${renderColumns(part.text, languages)}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
</section> </section>
`; `;
case 'psalm': { case 'psalm': {
@@ -110,6 +111,24 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
</section> </section>
`; `;
} }
// 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 `
<section class="ordo-part ordo-part-nocturn-psalmody">
<h3 class="ordo-part-label">Nocturn ${part.nocturn}</h3>
${part.psalms.map((p) => renderPart(p, languages)).join('')}
</section>
`;
case 'te-deum':
return `
<section class="ordo-part ordo-part-te-deum">
<h3 class="ordo-part-label">Te Deum</h3>
${renderColumns(part.text, languages)}
</section>
`;
} }
} }
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import { resolveOrdo } from '../../src/hours';
// Two clean proof dates, one per branch — see hours/matins.ts's own header
// and TODO.md for why this is a mechanism build with a small content slice,
// not full-calendar content. Both live-verified against the reference
// engine (Monastic Tridentinum 1617) before authoring.
const FERIAL_DATE = '2026-12-15'; // Tuesday of Advent III — plain ferial, no feast collision.
const SUNDAY_DATE = '2026-09-06'; // 14th Sunday after Trinity — plain Sunday, no feast collision.
describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
const ordo = resolveOrdo('matins', FERIAL_DATE);
it('is no longer notImplemented', () => {
expect(ordo.notImplemented).toBeUndefined();
});
it('opens with the Deus in adjutorium versicle, then Psalm 3, then the Invitatory (Psalm 94)', () => {
expect(ordo.parts[0]?.kind).toBe('versicle');
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
expect(ordo.parts[2]).toMatchObject({ kind: 'psalm', psalmNumber: 94 });
const invitatoryAntiphon = (ordo.parts[2] as { antiphon?: { text: Record<string, string> } }).antiphon;
expect(invitatoryAntiphon?.text.la).toContain('Veníte');
// Real practice repeats the invitatory antiphon between verse groups —
// deliberately not modeled that way here (see hours/matins.ts's own
// header): confirm there's exactly one standalone repeat, not several.
const standaloneAntiphons = ordo.parts.filter((p) => p.kind === 'antiphon');
expect(standaloneAntiphons.length).toBeGreaterThan(0);
});
it('has exactly 9 ferial psalms, no 3-nocturn structure, and no Te Deum', () => {
const psalms = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
// Ps 3 and 94 (opening + invitatory) plus Tuesday's own 9-psalm ferial table.
expect(psalms).toEqual([3, 94, 43, 44, 47, 48, 49, 54, 55, 58, 59]);
expect(ordo.parts.some((p) => p.kind === 'te-deum')).toBe(false);
expect(ordo.parts.some((p) => p.kind === 'canticle')).toBe(false);
});
it("resolves the user's own bible-plan readings for Advent III Tuesday, not the historical lectionary", () => {
const lessons = ordo.parts.filter((p) => p.kind === 'lesson');
expect(lessons).toHaveLength(2);
const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en);
expect(citations).toEqual(['Isa 36; Isa 37', 'Sap 14']);
// Real content isn't imported yet (Vulgate/Douay-Rheims bulk import is
// a deferred follow-on) — honest `missing`, not fabricated text.
expect((lessons[0] as { text: { status: { en?: string } } }).text.status.en).toBe('missing');
});
it("matches the first reading (Isaiah) against the seeded per-book responsory pool", () => {
const lessons = ordo.parts.filter((p) => p.kind === 'lesson');
const first = lessons[0] as { responsory?: { text: { la?: string } } };
expect(first.responsory?.text.la).toContain('Ægýpte');
});
it('closes with the fixed ferial capitulum, then the day collect', () => {
const chapterIndex = ordo.parts.findIndex((p) => p.kind === 'chapter');
const prayerIndex = ordo.parts.findIndex((p) => p.kind === 'prayer');
expect(chapterIndex).toBeGreaterThan(-1);
expect(prayerIndex).toBeGreaterThan(chapterIndex);
const chapter = ordo.parts[chapterIndex] as { text: { citation?: { en?: string } } };
expect(chapter.text.citation?.en).toBe('1 Cor 16:13-14');
});
});
describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => {
const ordo = resolveOrdo('matins', SUNDAY_DATE);
it('has the full 12-psalm Sunday psalmody (Ps 20-31) plus 3 canticles, and a Te Deum', () => {
const psalms = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
// Ps 3 + 94 (opening/invitatory) + 20-31 (Sunday's fixed 12).
expect(psalms).toEqual([3, 94, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]);
const canticles = ordo.parts.filter((p) => p.kind === 'canticle');
expect(canticles).toHaveLength(3);
expect(ordo.parts.some((p) => p.kind === 'te-deum')).toBe(true);
});
it("resolves the user's own bible-plan reading (Tobit/Sirach) for Nocturn 1", () => {
const lessons = ordo.parts.filter((p) => p.kind === 'lesson');
const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en);
expect(citations).toContain('Tob 1; Tob 2');
expect(citations).toContain('Sir 45');
});
it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily, both flagged correctly', () => {
const lessons = ordo.parts.filter(
(p) => p.kind === 'lesson',
) as { label?: string; isGospel?: boolean; text: { text: { la?: string } } }[];
const gregory = lessons.find((l) => l.label?.includes('Gregory'));
expect(gregory).toBeDefined();
expect(gregory?.isGospel).toBe(false);
const gospel = lessons.find((l) => l.isGospel);
expect(gospel).toBeDefined();
expect(gospel?.text.text.la).toContain('Naim');
const homily = lessons.find((l) => l.label?.includes('Augustine'));
expect(homily).toBeDefined();
expect(homily?.isGospel).toBe(false);
});
it('never sources a Gospel from a Common-of-Saints fallback — every Gospel-flagged reading traces to the bible-plan or a real proper', () => {
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { isGospel?: boolean; label?: string }[];
const gospels = lessons.filter((l) => l.isGospel);
// This Sunday's own TSV row deliberately has no Gospel (confirmed
// editorial choice) — the only Gospel-flagged reading should be the
// day's own proper Nocturn 3 Gospel, not a substituted Common one.
expect(gospels).toHaveLength(1);
});
it('orders Te Deum after every reading, and the day collect last', () => {
const teDeumIndex = ordo.parts.findIndex((p) => p.kind === 'te-deum');
const lastLessonIndex = ordo.parts.map((p) => p.kind).lastIndexOf('lesson');
const prayerIndex = ordo.parts.findIndex((p) => p.kind === 'prayer');
expect(teDeumIndex).toBeGreaterThan(lastLessonIndex);
expect(prayerIndex).toBeGreaterThan(teDeumIndex);
});
});
-8
View File
@@ -224,11 +224,3 @@ describe('resolveOrdo("prime", ...)', () => {
} }
}); });
}); });
describe('resolveOrdo for not-yet-built hours', () => {
it('flags matins as not implemented rather than throwing', () => {
const ordo = resolveOrdo('matins', MONDAY);
expect(ordo.notImplemented).toBe(true);
expect(ordo.parts).toEqual([]);
});
});
+6 -2
View File
@@ -37,7 +37,7 @@ describe('shell', () => {
expect(root.querySelectorAll('.ordo-part-psalm').length).toBeGreaterThan(0); expect(root.querySelectorAll('.ordo-part-psalm').length).toBeGreaterThan(0);
}); });
it('switches to a not-yet-built hour and shows the pending message', () => { it('switches to Matins and shows its real content, not the pending message', () => {
const root = document.getElementById('app')!; const root = document.getElementById('app')!;
mountShell(root); mountShell(root);
@@ -47,7 +47,11 @@ describe('shell', () => {
matinsBtn?.click(); matinsBtn?.click();
expect(root.querySelector('.hour-view h2')?.textContent).toBe('Matins'); expect(root.querySelector('.hour-view h2')?.textContent).toBe('Matins');
expect(root.querySelector('.hour-view-pending')).not.toBeNull(); expect(root.querySelector('.hour-view-pending')).toBeNull();
// Ferial psalmody always resolves regardless of date (unauthored
// reading content is the only genuinely date-gated part) — see
// hours/matins.ts.
expect(root.querySelectorAll('.ordo-part-psalm').length).toBeGreaterThan(0);
}); });
it('switches to Compline and shows its real content, not the pending message', () => { it('switches to Compline and shows its real content, not the pending message', () => {