diff --git a/src/data/hours/none-antiphons.yml b/src/data/hours/none-antiphons.yml new file mode 100644 index 0000000..4a51195 --- /dev/null +++ b/src/data/hours/none-antiphons.yml @@ -0,0 +1,19 @@ +# See terce-antiphons.yml for the full explanation of this table's shape, +# sourcing, and the Friday/Saturday derivation caveat. +sunday: + la: "Allelúja." + en: "Alleluia." +monday: + la: "Fiat manus tua." + en: "Let thy hand be with me to save me." +tuesday: &gradual-none-ant + la: "Beáti omnes." + en: "Blessed are all they." +wednesday: *gradual-none-ant +thursday: *gradual-none-ant +friday: + la: "Si vere útique justítiam loquímini." + en: "If in very deed you speak justice." +saturday: + la: "Dóminus regnávit." + en: "The Lord hath reigned." diff --git a/src/data/hours/none.yml b/src/data/hours/none.yml new file mode 100644 index 0000000..c7c5336 --- /dev/null +++ b/src/data/hours/none.yml @@ -0,0 +1,16 @@ +# See terce.yml for the full explanation of this ordo's structure and +# deliberate deviations — None is identical in shape, only its hymn, +# antiphon/psalms, and capitulum differ. +id: none +parts: + - kind: opening-versicle + - kind: hymn + textRef: { source: common, id: none-hymn } + - kind: variable + resolve: by-weekday + - kind: chapter + textRef: { source: common, id: none-capitulum-per-annum } + - kind: day-collect + - kind: preces + textRef: { source: common, id: little-hour-conclusio } + label: Conclusion diff --git a/src/data/hours/sext-antiphons.yml b/src/data/hours/sext-antiphons.yml new file mode 100644 index 0000000..e8cda95 --- /dev/null +++ b/src/data/hours/sext-antiphons.yml @@ -0,0 +1,19 @@ +# See terce-antiphons.yml for the full explanation of this table's shape, +# sourcing, and the Friday/Saturday derivation caveat. +sunday: + la: "Allelúja." + en: "Alleluia." +monday: + la: "Áspice in me." + en: "Look upon me." +tuesday: &gradual-sext-ant + la: "Qui hábitas." + en: "Who dwellest." +wednesday: *gradual-sext-ant +thursday: *gradual-sext-ant +friday: + la: "Quid gloriáris in malítia." + en: "Why dost thou glory in malice." +saturday: + la: "Fundaménta ejus in móntibus sanctis." + en: "The foundations thereof are in the holy mountains." diff --git a/src/data/hours/sext.yml b/src/data/hours/sext.yml new file mode 100644 index 0000000..8dbaef8 --- /dev/null +++ b/src/data/hours/sext.yml @@ -0,0 +1,16 @@ +# See terce.yml for the full explanation of this ordo's structure and +# deliberate deviations — Sext is identical in shape, only its hymn, +# antiphon/psalms, and capitulum differ. +id: sext +parts: + - kind: opening-versicle + - kind: hymn + textRef: { source: common, id: sext-hymn } + - kind: variable + resolve: by-weekday + - kind: chapter + textRef: { source: common, id: sext-capitulum-per-annum } + - kind: day-collect + - kind: preces + textRef: { source: common, id: little-hour-conclusio } + label: Conclusion diff --git a/src/data/hours/terce-antiphons.yml b/src/data/hours/terce-antiphons.yml new file mode 100644 index 0000000..92c262f --- /dev/null +++ b/src/data/hours/terce-antiphons.yml @@ -0,0 +1,38 @@ +# One antiphon per weekday, framing that day's whole psalm group (see +# hours/terce.ts and psalter/distribution.ts's 'terce' table) — same shape +# and convention as data/hours/prime-antiphons.yml (inline text, no +# separate common-propers id, incipit/full split via an embedded "*" where +# the source has one). None of these happen to have a "*" — each is a +# short single-clause antiphon and splitAntiphon() already degrades +# gracefully (incipit == full) when there isn't one. +# +# Verified against Divinum Officium (Monastic Tridentinum 1617): +# - sunday/monday/tuesday: clean per-annum runs with no sanctoral +# override (2026-08-09 Sun, 2026-08-31 Mon, 2026-10-13 Tue). +# - wednesday/thursday: share Tuesday's antiphon — same native psalms +# (119-121), confirmed identical across all three. +# - friday/saturday: these two days' psalms were editorially +# redistributed (see psalter-distribution.yml's long comment on +# 'terce'), so there's no real historical antiphon for this specific +# grouping. Per explicit decision, each is instead derived from the +# opening phrase of that day's first psalm (Ps 41 for Friday, Ps 69 for +# Saturday) — editorial, not independently verified against the +# reference engine as a real antiphon, same caveat as the +# redistribution itself. +sunday: + la: "Allelúja." + en: "Alleluia." +monday: + la: "Ádjuva me." + en: "Help me." +tuesday: &gradual-terce-ant + la: "Clamávi." + en: "I cried unto the Lord." +wednesday: *gradual-terce-ant +thursday: *gradual-terce-ant +friday: + la: "Quemádmodum desíderat cervus." + en: "As the hart panteth." +saturday: + la: "Deus, in adjutórium meum inténde." + en: "O God, come to my assistance." diff --git a/src/data/hours/terce.yml b/src/data/hours/terce.yml new file mode 100644 index 0000000..a51cb01 --- /dev/null +++ b/src/data/hours/terce.yml @@ -0,0 +1,29 @@ +# Ordo verified against Divinum Officium (Monastic Tridentinum 1617), clean +# per-annum runs with no sanctoral override (2026-08-09 Sun, 2026-08-31 Mon, +# 2026-10-13 Tue) — see src/hours/terce.ts for how each part resolves. +# +# Deliberately deviates from that verified source in one place, matching +# Prime's own precedent: the opening Pater/Ave (said silently before "Deus +# in adjutórium") is dropped, to keep the hour shorter. +# +# Real structure, confirmed directly against the engine: opening versicle, +# hymn (self-contained, no seasonal doxology swap — see terce-hymn.yml), +# one antiphon framing all three psalms, capitulum with its own embedded +# short versicle/response (*not* a separate elaborate responsory the way +# Prime's chapter-responsory.ts adds one — Monastic Terce doesn't have +# one), the day's own collect, and a closing that's identical across all +# three Little Hours (see little-hour-conclusio.yml). No Preces, no Creed — +# real Monastic Terce/Sext/None have neither. +id: terce +parts: + - kind: opening-versicle + - kind: hymn + textRef: { source: common, id: terce-hymn } + - kind: variable + resolve: by-weekday + - kind: chapter + textRef: { source: common, id: terce-capitulum-per-annum } + - kind: day-collect + - kind: preces + textRef: { source: common, id: little-hour-conclusio } + label: Conclusion diff --git a/src/data/propers/common/little-hour-conclusio.yml b/src/data/propers/common/little-hour-conclusio.yml new file mode 100644 index 0000000..b65b9c2 --- /dev/null +++ b/src/data/propers/common/little-hour-conclusio.yml @@ -0,0 +1,23 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), clean +# per-annum run (2026-08-31) — identical across Terce, Sext, and None +# (confirmed by comparing all three directly), unlike Prime's/Compline's +# own distinct conclusios. +id: little-hour-conclusio +text: + la: | + V. Dómine, exáudi oratiónem meam. + R. Et clamor meus ad te véniat. + V. Benedicámus Dómino. + R. Deo grátias. + V. Fidélium ánimæ per misericórdiam Dei requiéscant in pace. + R. Amen. + en: | + V. O Lord, hear my prayer. + R. And let my cry come unto thee. + V. Let us bless the Lord. + R. Thanks be to God. + V. May the souls of the faithful, through the mercy of God, rest in peace. + R. Amen. +status: + la: verified + en: verified diff --git a/src/data/propers/common/none-capitulum-per-annum.yml b/src/data/propers/common/none-capitulum-per-annum.yml new file mode 100644 index 0000000..f003027 --- /dev/null +++ b/src/data/propers/common/none-capitulum-per-annum.yml @@ -0,0 +1,13 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), clean +# per-annum run (2026-08-31). See terce-capitulum-per-annum.yml for the +# combined-block structure note and the deferred-seasonal-variant caveat. +id: none-capitulum-per-annum +text: + la: "Empti enim estis prétio magno. Glorificáte et portáte Deum in córpore vestro. R. Deo grátias. V. Ab occúltis meis munda me, Dómine. R. Et ab aliénis parce servo tuo." + en: "For you are bought with a great price. Glorify and bear God in your body. R. Thanks be to God. V. From my secret ones cleanse me, O Lord. R. And from those of others spare thy servant." +citation: + la: "1 Cor. 6:20" + en: "1 Cor. 6:20" +status: + la: verified + en: verified diff --git a/src/data/propers/common/sext-capitulum-per-annum.yml b/src/data/propers/common/sext-capitulum-per-annum.yml new file mode 100644 index 0000000..917c03c --- /dev/null +++ b/src/data/propers/common/sext-capitulum-per-annum.yml @@ -0,0 +1,13 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), clean +# per-annum run (2026-08-31). See terce-capitulum-per-annum.yml for the +# combined-block structure note and the deferred-seasonal-variant caveat. +id: sext-capitulum-per-annum +text: + la: "Alter altérius ónera portáte, et sic adimplébitis legem Christi. R. Deo grátias. V. Dóminus regit me, et nihil mihi déerit. R. In loco páscuæ ibi me collocávit." + en: "Bear ye one another's burdens; and so you shall fulfill the law of Christ. R. Thanks be to God. V. The Lord ruleth me: and I shall want nothing. R. He hath set me in a place of pasture." +citation: + la: "Gal. 6:2" + en: "Gal. 6:2" +status: + la: verified + en: verified diff --git a/src/data/propers/common/terce-capitulum-per-annum.yml b/src/data/propers/common/terce-capitulum-per-annum.yml new file mode 100644 index 0000000..d3a9c1d --- /dev/null +++ b/src/data/propers/common/terce-capitulum-per-annum.yml @@ -0,0 +1,20 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), clean +# per-annum runs (2026-08-31, a Monday with no sanctoral override — see +# hours/terce.ts). "Capitulum Versus" in the source is one combined block: +# the chapter, then R. Deo grátias, then a short versicle/response pair — +# unlike Prime, the Little Hours have no separate elaborate short +# responsory, so all of that is kept together here as one 'chapter' part. +# +# Per-annum only for now — real practice varies this by season (confirmed +# by comparing against a Lenten Ember Wednesday run, which showed different +# capitulum text entirely), but that seasonal table isn't built yet. +id: terce-capitulum-per-annum +text: + la: "Sana me, Dómine, et sanábor: salvum me fac, et salvus ero: quóniam laus mea tu es. R. Deo grátias. V. Adjútor meus, esto, ne derelínquas me. R. Neque despícias me, Deus, salutáris meus." + en: "Heal me, O Lord, and I shall be healed: save me, and I shall be saved, for thou art my praise. R. Thanks be to God. V. Be thou my helper, forsake me not. R. Do not thou despise me, O God my Saviour." +citation: + la: "Jer. 17:14" + en: "Jer. 17:14" +status: + la: verified + en: verified diff --git a/src/data/psalter-distribution.yml b/src/data/psalter-distribution.yml index 4664440..6293133 100644 --- a/src/data/psalter-distribution.yml +++ b/src/data/psalter-distribution.yml @@ -78,9 +78,9 @@ prime: # # Sunday, Monday, and Tuesday's Terce/Sext/None are untouched by any of # this (still whatever Divinum Officium gives natively — Ps 118 sections -# for Sunday/Monday, 119-127 for Tuesday) and so aren't recorded here; -# source them from the reference engine directly when those hours are -# built, the same as Wednesday/Thursday's unchanged 119-127 below. +# for Sunday/Monday, 119-127 for Tuesday) — now recorded below, verified +# against clean per-annum runs with no sanctoral override (2026-08-09 Sun, +# 2026-08-31 Mon, 2026-10-13 Tue). matins: monday: - { number: 32 } @@ -147,10 +147,19 @@ matins: # redistribution; source it directly from the reference engine. terce: - wednesday: &gradual-terce + sunday: + - { number: 118, verses: "33-40" } + - { number: 118, verses: "41-48" } + - { number: 118, verses: "49-56" } + monday: + - { number: 118, verses: "105-112" } + - { number: 118, verses: "113-120" } + - { number: 118, verses: "121-128" } + tuesday: &gradual-terce - { number: 119 } - { number: 120 } - { number: 121 } + wednesday: *gradual-terce thursday: *gradual-terce friday: - { number: 41 } @@ -162,10 +171,19 @@ terce: - { number: 81 } sext: - wednesday: &gradual-sext + sunday: + - { number: 118, verses: "57-64" } + - { number: 118, verses: "65-72" } + - { number: 118, verses: "73-80" } + monday: + - { number: 118, verses: "129-136" } + - { number: 118, verses: "137-144" } + - { number: 118, verses: "145-152" } + tuesday: &gradual-sext - { number: 122 } - { number: 123 } - { number: 124 } + wednesday: *gradual-sext thursday: *gradual-sext friday: - { number: 51 } @@ -177,10 +195,19 @@ sext: - { number: 97 } none: - wednesday: &gradual-none + sunday: + - { number: 118, verses: "81-88" } + - { number: 118, verses: "89-96" } + - { number: 118, verses: "97-104" } + monday: + - { number: 118, verses: "153-160" } + - { number: 118, verses: "161-168" } + - { number: 118, verses: "169-176" } + tuesday: &gradual-none - { number: 125 } - { number: 126 } - { number: 127 } + wednesday: *gradual-none thursday: *gradual-none friday: - { number: 57 } diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 0f40d84..77e686c 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -85,6 +85,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'closing-antiphon': case 'by-day-kind': case 'variable': + case 'day-collect': throw new Error(`Compline's ordo doesn't support a '${part.kind}' part`); } } diff --git a/src/hours/none.ts b/src/hours/none.ts index 45d7d79..7b5d77a 100644 --- a/src/hours/none.ts +++ b/src/hours/none.ts @@ -1,7 +1,65 @@ -import type { ResolvedOrdo } from './types'; +import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; +import type { LiturgicalDay, Weekday } from '../calendar/types'; +import { resolveDay } from '../calendar'; +import { getDayLabel } from '../calendar/day-label'; +import { getPsalmsFor } from '../psalter/distribution'; +import { getPsalmVerses } from '../psalter'; +import { getOpeningVersicleId } from './opening-versicle'; +import { isDoubleOrHigher } from './antiphon'; +import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common'; +import noneDefinitionData from '../data/hours/none.yml'; +import antiphonsData from '../data/hours/none-antiphons.yml'; -// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts -// alongside terce.ts and sext.ts once that's written. -export function resolveOrdo(date: string): ResolvedOrdo { - return { hourId: 'none', date, parts: [], notImplemented: true }; +const noneDefinition = noneDefinitionData as HourDefinition; +const antiphons = antiphonsData as Record>>; + +function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { + switch (part.kind) { + case 'opening-versicle': + return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; + case 'hymn': + // Self-contained, no appendDoxology — see none-hymn.yml. + return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }]; + case 'chapter': + return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }]; + case 'day-collect': + return [{ kind: 'prayer', text: getDayCollect(day) }]; + case 'preces': + return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; + case 'variable': { + // Only 'by-weekday' applies here — one antiphon framing all three + // psalms, no closing repeat (unlike Prime): confirmed directly + // against the engine, Capitulum follows the third psalm immediately. + const psalmRefs = getPsalmsFor('none', day.weekday); + const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]); + const opening = isDoubleOrHigher(day.winner) ? full : incipit; + return psalmRefs.map((ref, i) => ({ + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon: i === 0 ? opening : undefined, + verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), + })); + } + // Not used by None. + case 'prayer': + case 'responsory': + case 'versicle': + case 'psalm': + case 'canticle': + case 'lesson': + case 'martyrology': + case 'rule-reading': + case 'by-day-kind': + case 'creed': + case 'closing-antiphon': + case 'nunc-dimittis': + case 'marian-antiphon': + throw new Error(`None's ordo doesn't support a '${part.kind}' part`); + } +} + +export function resolveOrdo(date: string): ResolvedOrdo { + const day = resolveDay(date); + const parts = noneDefinition.parts.flatMap((part) => resolvePart(part, day)); + return { hourId: 'none', date, parts, dayLabel: getDayLabel(day) }; } diff --git a/src/hours/prime.ts b/src/hours/prime.ts index df995b8..5504eec 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -108,9 +108,10 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved { kind: 'preces', text: resolveCommon('lesson-close') }, ]; } - // Not used by Prime — Compline-only. + // Not used by Prime — Compline-only, or Little-Hours-only. case 'nunc-dimittis': case 'marian-antiphon': + case 'day-collect': throw new Error(`Prime's ordo doesn't support a '${part.kind}' part`); } } diff --git a/src/hours/sext.ts b/src/hours/sext.ts index 602b1b5..9d2bdc1 100644 --- a/src/hours/sext.ts +++ b/src/hours/sext.ts @@ -1,7 +1,65 @@ -import type { ResolvedOrdo } from './types'; +import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; +import type { LiturgicalDay, Weekday } from '../calendar/types'; +import { resolveDay } from '../calendar'; +import { getDayLabel } from '../calendar/day-label'; +import { getPsalmsFor } from '../psalter/distribution'; +import { getPsalmVerses } from '../psalter'; +import { getOpeningVersicleId } from './opening-versicle'; +import { isDoubleOrHigher } from './antiphon'; +import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common'; +import sextDefinitionData from '../data/hours/sext.yml'; +import antiphonsData from '../data/hours/sext-antiphons.yml'; -// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts -// alongside terce.ts and none.ts once that's written. -export function resolveOrdo(date: string): ResolvedOrdo { - return { hourId: 'sext', date, parts: [], notImplemented: true }; +const sextDefinition = sextDefinitionData as HourDefinition; +const antiphons = antiphonsData as Record>>; + +function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { + switch (part.kind) { + case 'opening-versicle': + return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; + case 'hymn': + // Self-contained, no appendDoxology — see sext-hymn.yml. + return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }]; + case 'chapter': + return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }]; + case 'day-collect': + return [{ kind: 'prayer', text: getDayCollect(day) }]; + case 'preces': + return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; + case 'variable': { + // Only 'by-weekday' applies here — one antiphon framing all three + // psalms, no closing repeat (unlike Prime): confirmed directly + // against the engine, Capitulum follows the third psalm immediately. + const psalmRefs = getPsalmsFor('sext', day.weekday); + const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]); + const opening = isDoubleOrHigher(day.winner) ? full : incipit; + return psalmRefs.map((ref, i) => ({ + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon: i === 0 ? opening : undefined, + verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), + })); + } + // Not used by Sext. + case 'prayer': + case 'responsory': + case 'versicle': + case 'psalm': + case 'canticle': + case 'lesson': + case 'martyrology': + case 'rule-reading': + case 'by-day-kind': + case 'creed': + case 'closing-antiphon': + case 'nunc-dimittis': + case 'marian-antiphon': + throw new Error(`Sext's ordo doesn't support a '${part.kind}' part`); + } +} + +export function resolveOrdo(date: string): ResolvedOrdo { + const day = resolveDay(date); + const parts = sextDefinition.parts.flatMap((part) => resolvePart(part, day)); + return { hourId: 'sext', date, parts, dayLabel: getDayLabel(day) }; } diff --git a/src/hours/terce.ts b/src/hours/terce.ts index 8641211..60563a3 100644 --- a/src/hours/terce.ts +++ b/src/hours/terce.ts @@ -1,7 +1,65 @@ -import type { ResolvedOrdo } from './types'; +import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; +import type { LiturgicalDay, Weekday } from '../calendar/types'; +import { resolveDay } from '../calendar'; +import { getDayLabel } from '../calendar/day-label'; +import { getPsalmsFor } from '../psalter/distribution'; +import { getPsalmVerses } from '../psalter'; +import { getOpeningVersicleId } from './opening-versicle'; +import { isDoubleOrHigher } from './antiphon'; +import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common'; +import terceDefinitionData from '../data/hours/terce.yml'; +import antiphonsData from '../data/hours/terce-antiphons.yml'; -// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts -// alongside sext.ts and none.ts once that's written. -export function resolveOrdo(date: string): ResolvedOrdo { - return { hourId: 'terce', date, parts: [], notImplemented: true }; +const terceDefinition = terceDefinitionData as HourDefinition; +const antiphons = antiphonsData as Record>>; + +function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { + switch (part.kind) { + case 'opening-versicle': + return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; + case 'hymn': + // Self-contained, no appendDoxology — see terce-hymn.yml. + return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }]; + case 'chapter': + return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }]; + case 'day-collect': + return [{ kind: 'prayer', text: getDayCollect(day) }]; + case 'preces': + return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; + case 'variable': { + // Only 'by-weekday' applies here — one antiphon framing all three + // psalms, no closing repeat (unlike Prime): confirmed directly + // against the engine, Capitulum follows the third psalm immediately. + const psalmRefs = getPsalmsFor('terce', day.weekday); + const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]); + const opening = isDoubleOrHigher(day.winner) ? full : incipit; + return psalmRefs.map((ref, i) => ({ + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon: i === 0 ? opening : undefined, + verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), + })); + } + // Not used by Terce. + case 'prayer': + case 'responsory': + case 'versicle': + case 'psalm': + case 'canticle': + case 'lesson': + case 'martyrology': + case 'rule-reading': + case 'by-day-kind': + case 'creed': + case 'closing-antiphon': + case 'nunc-dimittis': + case 'marian-antiphon': + throw new Error(`Terce's ordo doesn't support a '${part.kind}' part`); + } +} + +export function resolveOrdo(date: string): ResolvedOrdo { + const day = resolveDay(date); + const parts = terceDefinition.parts.flatMap((part) => resolvePart(part, day)); + return { hourId: 'terce', date, parts, dayLabel: getDayLabel(day) }; } diff --git a/src/hours/types.ts b/src/hours/types.ts index c3f3b01..2da2c19 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -87,7 +87,13 @@ export type HourPart = // Salve Regina. See hours/marian-antiphon.ts. | { kind: 'marian-antiphon' } // Unused by Prime/Compline/the little hours — exercised starting milestone 4. - | { kind: 'variable'; resolve: 'by-weekday' | 'by-season' | 'by-feast-rank' }; + | { kind: 'variable'; resolve: 'by-weekday' | 'by-season' | 'by-feast-rank' } + // The day's own Proper-of-season/Proper-of-saints collect — resolved by + // day, not by textRef. First used by the Little Hours' "Oratio {ex + // Proprio de Tempore}" via hours/resolve-common.ts's getDayCollect; + // Prime/Compline both still use a fixed collect (`prayer` + textRef). + // Renders as a plain 'prayer' ResolvedPart. + | { kind: 'day-collect' }; export interface HourDefinition { id: HourId; diff --git a/tests/hours/little-hours.test.ts b/tests/hours/little-hours.test.ts new file mode 100644 index 0000000..d7e7b53 --- /dev/null +++ b/tests/hours/little-hours.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { resolveOrdo } from '../../src/hours'; + +// Clean per-annum dates, verified against Divinum Officium (Monastic +// Tridentinum 1617) to have no sanctoral override — see +// data/psalter-distribution.yml and data/hours/*-antiphons.yml for how +// each was chosen. +const SUNDAY = '2026-08-09'; +const MONDAY = '2026-08-31'; +const TUESDAY = '2026-10-13'; + +const LITTLE_HOURS = ['terce', 'sext', 'none'] as const; +const PSALMS_BY_HOUR: Record<(typeof LITTLE_HOURS)[number], [number, number, number]> = { + terce: [119, 120, 121], + sext: [122, 123, 124], + none: [125, 126, 127], +}; + +describe.each(LITTLE_HOURS)('resolveOrdo(%s, ...)', (hourId) => { + it('is implemented', () => { + const ordo = resolveOrdo(hourId, TUESDAY); + expect(ordo.notImplemented).toBeUndefined(); + expect(ordo.hourId).toBe(hourId); + }); + + it('resolves three psalms from the gradual set on a plain weekday, framed by one antiphon', () => { + const ordo = resolveOrdo(hourId, TUESDAY); + const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm'); + expect(psalmParts.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined))).toEqual( + PSALMS_BY_HOUR[hourId], + ); + expect(psalmParts[0]?.kind === 'psalm' ? psalmParts[0].antiphon?.status.en : undefined).toBe('verified'); + // No closing repeat of the antiphon — confirmed against the real engine. + expect(psalmParts[1]?.kind === 'psalm' ? psalmParts[1].antiphon : undefined).toBeUndefined(); + expect(psalmParts[2]?.kind === 'psalm' ? psalmParts[2].antiphon : undefined).toBeUndefined(); + expect(ordo.parts.some((p) => p.kind === 'antiphon')).toBe(false); + }); + + it('slices Psalm 118 by section on Sunday and Monday instead of the gradual set', () => { + const sunday = resolveOrdo(hourId, SUNDAY); + const sundayPsalms = sunday.parts.filter((p) => p.kind === 'psalm'); + expect(sundayPsalms.every((p) => (p.kind === 'psalm' ? p.psalmNumber === 118 : false))).toBe(true); + + const monday = resolveOrdo(hourId, MONDAY); + const mondayPsalms = monday.parts.filter((p) => p.kind === 'psalm'); + expect(mondayPsalms.every((p) => (p.kind === 'psalm' ? p.psalmNumber === 118 : false))).toBe(true); + + // Different sections, not a repeat of Sunday's. + const sundayVerseNs = sundayPsalms[0]?.kind === 'psalm' ? sundayPsalms[0].verses.map((v) => v.n) : []; + const mondayVerseNs = mondayPsalms[0]?.kind === 'psalm' ? mondayPsalms[0].verses.map((v) => v.n) : []; + expect(sundayVerseNs).not.toEqual(mondayVerseNs); + }); + + it('resolves the hymn as fully self-contained, verified text (no seasonal doxology append)', () => { + const ordo = resolveOrdo(hourId, TUESDAY); + const hymn = ordo.parts.find((p) => p.kind === 'hymn'); + expect(hymn && hymn.kind === 'hymn' ? hymn.text.status.en : undefined).toBe('verified'); + expect(hymn && hymn.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Amen.'); + }); + + it('resolves the per-annum capitulum with its citation', () => { + const ordo = resolveOrdo(hourId, TUESDAY); + const chapter = ordo.parts.find((p) => p.kind === 'chapter'); + expect(chapter && chapter.kind === 'chapter' ? chapter.text.status.en : undefined).toBe('verified'); + expect(chapter && chapter.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBeDefined(); + }); + + it("resolves the day's own collect as the closing prayer", () => { + const ordo = resolveOrdo(hourId, SUNDAY); // post-pentecost-11 — real, verified collect + const prayer = ordo.parts.find((p) => p.kind === 'prayer'); + expect(prayer && prayer.kind === 'prayer' ? prayer.text.status.en : undefined).toBe('verified'); + expect(prayer && prayer.kind === 'prayer' ? prayer.text.text.en : undefined).toContain('Let us pray.'); + }); + + it('ends with the shared Little Hour conclusion, identical across all three hours', () => { + const ordo = resolveOrdo(hourId, TUESDAY); + const conclusion = ordo.parts.find((p) => p.kind === 'preces' && p.label === 'Conclusion'); + expect(conclusion && conclusion.kind === 'preces' ? conclusion.text.text.en : undefined).toContain( + 'Let us bless the Lord.', + ); + }); +}); diff --git a/tests/hours/prime.test.ts b/tests/hours/prime.test.ts index 6122204..ddf3787 100644 --- a/tests/hours/prime.test.ts +++ b/tests/hours/prime.test.ts @@ -188,8 +188,8 @@ describe('resolveOrdo("prime", ...)', () => { }); describe('resolveOrdo for not-yet-built hours', () => { - it('flags terce as not implemented rather than throwing', () => { - const ordo = resolveOrdo('terce', MONDAY); + it('flags lauds as not implemented rather than throwing', () => { + const ordo = resolveOrdo('lauds', MONDAY); expect(ordo.notImplemented).toBe(true); expect(ordo.parts).toEqual([]); }); diff --git a/tests/ui/shell.test.ts b/tests/ui/shell.test.ts index bd485dd..fa3ccbf 100644 --- a/tests/ui/shell.test.ts +++ b/tests/ui/shell.test.ts @@ -41,12 +41,12 @@ describe('shell', () => { const root = document.getElementById('app')!; mountShell(root); - const terceBtn = Array.from(root.querySelectorAll('[data-hour]')).find( - (btn) => btn.dataset.hour === 'terce', + const laudsBtn = Array.from(root.querySelectorAll('[data-hour]')).find( + (btn) => btn.dataset.hour === 'lauds', ); - terceBtn?.click(); + laudsBtn?.click(); - expect(root.querySelector('.hour-view h2')?.textContent).toBe('Terce'); + expect(root.querySelector('.hour-view h2')?.textContent).toBe('Lauds'); expect(root.querySelector('.hour-view-pending')).not.toBeNull(); });