Compare commits
3 Commits
ee3e155bfa
...
f550412c61
| Author | SHA1 | Date | |
|---|---|---|---|
| f550412c61 | |||
| 3e58f2258d | |||
| 1fc4bd7160 |
@@ -42,6 +42,11 @@ across every hour and content type in this app.
|
||||
- The bulk Vulgate (Latin) + Douay-Rheims (English) scripture import into
|
||||
`src/data/scripture/` — long-term project, explicitly deferred; Matins scripture
|
||||
citations resolve to `missing` text until it lands.
|
||||
- Matins psalmody overrides for a Duplex+ weekday feast (see "Matins psalmody overrides"
|
||||
below): mechanism built, keyed per-Common-category, one proof authored (St. Lawrence).
|
||||
Every other Common category (~75 saints across Apostle, Martyr(s), Confessor, Virgin,
|
||||
Doctor, etc.) still falls back to the plain ferial weekday table, redistributed into 3
|
||||
nocturns, until its own category's real psalmody is authored.
|
||||
- The four "Commemoratio Octavæ" Ant+V/R+collect blocks (Christmas Octave's own
|
||||
Nativity/Stephen/John/Holy-Innocents commemorations, e.g. on Dec 30) are now done —
|
||||
see "Octave content" below. St. Frances of Rome's/
|
||||
@@ -1743,6 +1748,96 @@ name instead of a ferial fallback label, the latter because Duplex Majus already
|
||||
app's own pre-existing privileged-Sunday rank threshold in `commemorations.ts`, not new
|
||||
behavior). `npm test` (365 tests) and `tsc --noEmit` both pass.
|
||||
|
||||
### Matins psalmody overrides — mechanism built, one proof (2026-08)
|
||||
|
||||
Real bug found and fixed: `hours/matins.ts`'s `threeNocturns` gate (Sunday, or a Duplex+
|
||||
sanctoral winner) correctly governed *nocturn count*, but the code also unconditionally reused
|
||||
the literal Sunday psalmody (`matins-sunday-antiphons.yml`, Ps 20-31) as *content* for every
|
||||
three-nocturn day — including a Duplex+ weekday feast, which is wrong. Confirmed live (Monastic
|
||||
Tridentinum 1617, `command=prayMatutinum`) against St. Lawrence's own octave closing day
|
||||
(2026-08-17, "In Octava S. Laurentii Martyris ~ Duplex"): the real psalmody is Nocturn 1 = Ps
|
||||
1,2,4,5,8,10, Nocturn 2 = Ps 14,16,20,23,63,91, each with its own antiphon, plus 3 OT canticles
|
||||
in Nocturn 3 (Sirach 14/15, Jeremiah 17, Sirach 31) — a genuinely different scheme from both
|
||||
the real-Sunday one and the plain ferial weekday table.
|
||||
|
||||
Cross-checked against a second Duplex saint (St. Ignatius of Antioch, Common of a
|
||||
Martyr-Bishop, no proper content of his own, 2029-02-01): Nocturn 1 identical to Lawrence's
|
||||
own; Nocturn 2 nearly identical (only one of six slots differs). So the psalm *numbers* are a
|
||||
shared per-Common-category pool, not unique per saint — only the antiphon text is proper to
|
||||
the individual saint when authored.
|
||||
|
||||
Mechanism: `hours/matins-psalmody-overrides.ts` (mirrors `hours/lauds-psalmody-overrides.ts`'s
|
||||
shape, but keyed differently — see its own doc comment for why per-category, not per-feast,
|
||||
is the right key here) + `data/hours/matins-psalmody-overrides/*.yml`. `matins.ts`'s
|
||||
three-nocturn branch now splits on `day.weekday === 'sunday'` first; a non-Sunday three-nocturn
|
||||
day looks up an override by the office winner's id, falling back to the plain ferial weekday
|
||||
table (redistributed into 3 nocturns instead of 1) when none is authored yet — never blank, but
|
||||
honest about not yet having that saint's real proper psalmody.
|
||||
|
||||
**Proof authored**: St. Lawrence only (`matins-psalmody-overrides/st-lawrence.yml`) — the live-
|
||||
verified content above. New scripture chapters added for the canticles:
|
||||
`data/scripture/{sir-14,sir-15,jer-17,sir-31}.yml` (sparse, only the cited verses, same
|
||||
convention as the pre-existing `sir-36.yml`). `matins.ts`'s canticle shape was generalized from
|
||||
one scripture ref per canticle to a list of refs (`canticles: { refs: [...] }[]`), since
|
||||
Lawrence's own first canticle cites two Sirach chapters under one heading in the source
|
||||
("Eccli 14:22;15:3-4;15:6") — kept as one canticle, not split; `matins-sunday-antiphons.yml`
|
||||
migrated to the same shape (each existing single ref wrapped in `refs: [...]`), no content
|
||||
change.
|
||||
|
||||
**Still open** (bulk-content work, same shape as the Lauds override sweep): every other
|
||||
Duplex+ Common category (Apostle, Martyr(s), Confessor, Virgin, Doctor, etc. — ~75 saints per
|
||||
the earlier Matins design-memory estimate) has no override authored yet and uses the ferial-
|
||||
table fallback in the meantime. A real Common-category key (vs. the per-saint key `st-lawrence`
|
||||
uses now, since only proper content has been authored so far) is the natural next step once a
|
||||
second category's content is authored — see `matins-psalmody-overrides.ts`'s own doc comment.
|
||||
|
||||
Live-verified end-to-end: `tests/hours/matins.test.ts`'s new "Duplex+ weekday-feast" describe
|
||||
block (Lawrence's own psalmody resolving `verified` in both languages across all 12 psalms +
|
||||
3 canticles, plus the ferial-fallback case via St. Ignatius). `npm test` (371 tests), `npm run
|
||||
build`, and `tsc --noEmit` all pass.
|
||||
|
||||
### Calendar precedence: a tie against an octave's own elevated closing day now favors the octave (2026-08)
|
||||
|
||||
Real bug found and fixed, same session as the Matins psalmody fix above, discovered while
|
||||
checking why 2026-08-17's Matins/label looked wrong: `calendar/index.ts`'s `applyOctaves` let a
|
||||
*tie* between an occurring saint's rank and an active octave's threshold go to the saint
|
||||
(`isAtLeast`, `>=`). Live-verified against the reference engine that this is wrong specifically
|
||||
for an octave's own elevated *closing* day: St. Hyacinth (plain Duplex, Aug 17) against St.
|
||||
Lawrence's own octave closing day (also Duplex, via `closingDayRank`'s default) — the engine's
|
||||
own alternate rubric block for that date is titled "Commemoratio S. Hyacinthi Confessoris",
|
||||
i.e. Hyacinth is the one merely commemorated, Lawrence's elevated closing day wins outright.
|
||||
Matches `collision.ts`'s own existing tie precedent for sanctoral-vs-sanctoral collisions
|
||||
("ties favor the incumbent").
|
||||
|
||||
Fix scoped precisely to closing days, not every octave tie: a tie against an octave's
|
||||
*ordinary* (non-closing) threshold still favors the occurring saint, unchanged — confirmed by
|
||||
two already-verified counterexamples this app's own tests already carried (St. Thomas of
|
||||
Canterbury, Dec 29, against the Christmas Octave's ordinary threshold; St. Nicholas of
|
||||
Tolentino, Sep 10, against the Nativity-BVM octave's ordinary threshold) — both would have
|
||||
broken under a blanket tie-flip, confirming the closing-day-specific scope is correct.
|
||||
`calendar/octaves.ts`'s `ActiveOctave` gained an `isClosingDay` field to make this
|
||||
distinguishable.
|
||||
|
||||
Two related display bugs fixed alongside it: `calendar/day-label.ts`'s `getDayLabel` dropped
|
||||
every commemoration (octave or sanctoral) whenever the day's winner was a plain sanctoral
|
||||
saint, and dropped every *other* active octave besides the one picked as headline even when an
|
||||
octave itself was the winner — both fixed (`otherActiveOctaveNames`), gated the same
|
||||
`ordinary-feria`/anchor-day rules the rest of the function already uses so Trinity Sunday's own
|
||||
technical overlap with Pentecost's octave still doesn't get named ("nobody calls it that", per
|
||||
the function's own pre-existing convention). Rank was also not displayed anywhere in this app's
|
||||
UI at all, for any saint — added (`formatRank`), shown after the day's own winner's name, e.g.
|
||||
"St. Hyacinth, Confessor (Duplex)".
|
||||
|
||||
Also closed a real content gap surfaced along the way: `assumption-octave-day-3.yml` (Aug 17,
|
||||
the Assumption's own day 3) had never been authored — days 2 and 4-8 existed, day 3 didn't —
|
||||
sourced from the reference engine's own recurring Nocturn 2 homily + the octave-wide "Vidi
|
||||
speciosam" responsory already used elsewhere in her octave.
|
||||
|
||||
`tests/calendar/octaves.test.ts` and `tests/calendar/day-label.test.ts` updated to the
|
||||
corrected behavior (not weakened) — 2026-08-17 is now a real, live demonstrable date for the
|
||||
octave-vs-octave priority pick this app's mechanism already implements, rather than the
|
||||
stand-in synthetic-data test that was the only option before.
|
||||
|
||||
### Bible-plan TSV — bulk conversion done (2026-08)
|
||||
|
||||
All ~369 non-blank rows of the user's own bible-plan TSV (`~/Downloads/bible-plan.tsv`, 390
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// become a configurable choice later (the same day->id indirection
|
||||
// philosophy already used for the sanctoral calendar), not hardcoded here
|
||||
// forever — just not built yet.
|
||||
import type { LiturgicalDay } from './types';
|
||||
import type { Commemoration, FeastClass, LiturgicalDay } from './types';
|
||||
import { easterSunday } from './easter';
|
||||
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
|
||||
import { addDays, daysBetween, toIsoDate } from './date-math';
|
||||
@@ -212,6 +212,43 @@ function octaveLabel(octave: ActiveOctave): string {
|
||||
return `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`;
|
||||
}
|
||||
|
||||
/** Every `kind: 'octave'` commemoration on `day` other than `excludeId` —
|
||||
* an octave already serving as the day's own headline (a sanctoral
|
||||
* winner sharing an octave's id, or the octave `resolveActiveOctave`
|
||||
* itself picked as primary below) would be redundant to list again.
|
||||
* Plain names, not `octaveLabel`'s "Nth Day within the Octave of ..."
|
||||
* phrasing — that fuller phrasing is reserved for an octave that's
|
||||
* actually the day's own primary identity, not a secondary mention
|
||||
* alongside it (same plain-name convention `commemoratedSaint` already
|
||||
* uses below). Real gap this closes: a *second*, non-winning active
|
||||
* octave (e.g. the Assumption's own day 3, alongside St. Lawrence's
|
||||
* winning closing day) was previously dropped from the label entirely,
|
||||
* regardless of which branch below actually renders the primary name. */
|
||||
function otherActiveOctaveNames(day: LiturgicalDay, excludeId: string | undefined): string[] {
|
||||
return day.commemorations
|
||||
.filter((c): c is Extract<Commemoration, { kind: 'octave' }> => c.kind === 'octave' && c.id !== excludeId)
|
||||
.map((c) => c.name);
|
||||
}
|
||||
|
||||
const RANK_LABELS: Record<FeastClass, string> = {
|
||||
simplex: 'Simplex',
|
||||
vigil: 'Vigil',
|
||||
semiduplex: 'Semiduplex',
|
||||
duplex: 'Duplex',
|
||||
'duplex-majus': 'Duplex Majus',
|
||||
'duplex-2-classis': 'Duplex II Class',
|
||||
'duplex-1-classis': 'Duplex I Class',
|
||||
};
|
||||
|
||||
/** The day's own winning saint's rank, parenthesized after their name —
|
||||
* rank was previously shown nowhere in this app's UI at all, for any
|
||||
* saint. Only the day's own *winner* gets this treatment, not every
|
||||
* commemoration, matching the "winner is primary, commemorations are
|
||||
* secondary" distinction this file already draws throughout. */
|
||||
function formatRank(rank: FeastClass): string {
|
||||
return RANK_LABELS[rank];
|
||||
}
|
||||
|
||||
/**
|
||||
* The full "day being celebrated" label: a feast name when
|
||||
* calendar/commemorations.ts says the day has one, combined with (or
|
||||
@@ -221,7 +258,19 @@ function octaveLabel(octave: ActiveOctave): string {
|
||||
*/
|
||||
export function getDayLabel(day: LiturgicalDay): string {
|
||||
if (day.winner.kind === 'sanctoral') {
|
||||
return day.winner.name;
|
||||
// A sanctoral winner can still share the day with an active octave
|
||||
// it didn't come from (e.g. winning a tie-break against one octave
|
||||
// while a second, unrelated octave is also active) — append those,
|
||||
// same "winner is primary, commemorations ride along" shape every
|
||||
// other branch below already uses. Gated on `ordinary-feria`, same
|
||||
// as the octave-headline branch further down: a day with real
|
||||
// standing of its own (e.g. Trinity Sunday, which is incidentally
|
||||
// also day 8 of Pentecost's own octave) never mentions an octave —
|
||||
// same "nobody calls it that" convention anchorDayName's own doc
|
||||
// comment already established for the anchor-day case.
|
||||
const otherOctaves = day.temporalCategory === 'ordinary-feria' ? otherActiveOctaveNames(day, undefined) : [];
|
||||
const winnerName = `${day.winner.name} (${formatRank(day.winner.rank)})`;
|
||||
return [winnerName, ...otherOctaves].join(' — ');
|
||||
}
|
||||
|
||||
// A named temporal feast (Christmas, Pentecost, Marian Saturday, ...)
|
||||
@@ -260,13 +309,18 @@ export function getDayLabel(day: LiturgicalDay): string {
|
||||
// "the label says Lawrence" consistent instead of two independent
|
||||
// guesses that can disagree. When more than one octave is active at
|
||||
// once (resolveActiveOctave), the highest-ranked wins the headline
|
||||
// (ties broken by whichever started more recently) — the others still
|
||||
// ride along as ordinary octave commemorations, just not separately
|
||||
// named here.
|
||||
// (ties broken by whichever started more recently) — every other
|
||||
// active octave still gets named too (otherActiveOctaveNames), not
|
||||
// dropped: live-verified real case, Aug 17 -- St. Lawrence's own
|
||||
// elevated closing day wins the headline, but the Assumption's own
|
||||
// day 3 (a real, distinct, simultaneously-active octave, not a
|
||||
// duplicate of Lawrence's) still belongs in the label alongside St.
|
||||
// Hyacinth's commemoration.
|
||||
const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined;
|
||||
if (activeOctave) {
|
||||
const primary = octaveLabel(activeOctave);
|
||||
return commemoratedSaint ? `${primary} — ${commemoratedSaint.name}` : primary;
|
||||
const rest = [...otherActiveOctaveNames(day, activeOctave.id), ...(commemoratedSaint ? [commemoratedSaint.name] : [])];
|
||||
return [primary, ...rest].join(' — ');
|
||||
}
|
||||
|
||||
const temporal = temporalLabel(day);
|
||||
|
||||
+40
-10
@@ -3,7 +3,7 @@ import { weekdayOf } from './weekday';
|
||||
import { resolveSeason, resolveTemporalCategory, sundayOnOrBefore } from './temporal';
|
||||
import { resolveTemporalId } from './temporal-id';
|
||||
import { getSanctoralCandidatesFor } from './feasts';
|
||||
import { decideOccurrence, isAtLeast, type OccurrenceResult } from './commemorations';
|
||||
import { decideOccurrence, compareFeastClass, type OccurrenceResult } from './commemorations';
|
||||
import { resolveCollision } from './collision';
|
||||
import { addDays, toIsoDate } from './date-math';
|
||||
import { easterSunday } from './easter';
|
||||
@@ -281,11 +281,36 @@ function applyMarianSaturday(
|
||||
* Layered on top of everything above, not part of it: an octave doesn't
|
||||
* change how a single day's own precedence contest is decided, it just
|
||||
* (a) adds a commemoration for every octave still active on this date, and
|
||||
* (b) occasionally overrides the winner when the occurring saint is too
|
||||
* minor to clear the strictest active octave's threshold, in which case
|
||||
* the day reverts to its own temporal identity and the saint is
|
||||
* commemorated instead — same "demoted, not dropped" shape as every other
|
||||
* (b) occasionally overrides the winner when the occurring saint doesn't
|
||||
* outrank the strictest active octave's threshold, in which case the day
|
||||
* reverts to its own temporal identity and the saint is commemorated
|
||||
* instead — same "demoted, not dropped" shape as every other
|
||||
* commemoration rule in this file.
|
||||
*
|
||||
* A *tie against an octave's own elevated closing day* goes to the
|
||||
* octave, not the occurring saint — live-verified counterexample: St.
|
||||
* Hyacinth (plain Duplex, Aug 17) against St. Lawrence's own octave
|
||||
* closing day that same date (also Duplex, via `closingDayRank`'s
|
||||
* default) — the reference engine's own alternate block for that date is
|
||||
* titled "Commemoratio S. Hyacinthi Confessoris", i.e. Hyacinth is the
|
||||
* one merely commemorated there, Lawrence's own elevated closing day
|
||||
* keeps the office. Matches this file's own `collision.ts` precedent for
|
||||
* the analogous sanctoral-vs-sanctoral tie ("Ties favor `native` — the
|
||||
* incoming feast is the guest here"): the closing day is the
|
||||
* already-running incumbent's own elevated day, an occurring saint is
|
||||
* the guest, and a guest needs to actually outrank it to displace it,
|
||||
* not just match it.
|
||||
*
|
||||
* A tie against an *ordinary* (non-closing) octave day's threshold still
|
||||
* favors the occurring saint, unchanged from the original behavior —
|
||||
* confirmed by two already-verified, live-sourced counterexamples this
|
||||
* file's own tests carry: St. Thomas of Canterbury (plain Semiduplex,
|
||||
* Dec 29) wins outright against the Christmas Octave's own ordinary
|
||||
* `wins: semiduplex` default that day (not its closing day, Jan 1), and
|
||||
* St. Nicholas of Tolentino (plain Semiduplex, Sep 10) likewise against
|
||||
* the Nativity of the BVM's octave (day 3 of 8, not closing). Only a
|
||||
* closing day's own elevated rank carries the "already the incumbent"
|
||||
* weight that breaks a tie in the octave's favor.
|
||||
*/
|
||||
function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner {
|
||||
const octaves = activeOctavesFor(isoDate);
|
||||
@@ -294,11 +319,16 @@ function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commem
|
||||
}
|
||||
|
||||
let resolvedWinner = winner;
|
||||
if (winner.kind === 'sanctoral' && !isAtLeast(winner.rank, strictestThreshold(octaves))) {
|
||||
const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
|
||||
if (!isOneOfTheseOctaves) {
|
||||
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
|
||||
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
|
||||
if (winner.kind === 'sanctoral') {
|
||||
const threshold = strictestThreshold(octaves);
|
||||
const cmp = compareFeastClass(winner.rank, threshold);
|
||||
const tiedAgainstClosingDay = cmp === 0 && octaves.some((o) => o.isClosingDay && compareFeastClass(o.wins, threshold) === 0);
|
||||
if (cmp < 0 || tiedAgainstClosingDay) {
|
||||
const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
|
||||
if (!isOneOfTheseOctaves) {
|
||||
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
|
||||
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface ActiveOctave {
|
||||
wins: FeastClass;
|
||||
/** 1 on the feast's own day, counting up from there. */
|
||||
dayNumber: number;
|
||||
/** Whether `dayNumber` is this octave's own final ("in Octava") day —
|
||||
* i.e. whether `wins` above came from `closingDayRank` rather than the
|
||||
* ordinary `wins` config. Used by calendar/index.ts's applyOctaves to
|
||||
* decide which side a *tied* rank favors — see its own doc comment. */
|
||||
isClosingDay: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 8;
|
||||
@@ -58,7 +63,7 @@ function considerCandidate(
|
||||
const dayNumber = offset + 1;
|
||||
const isClosingDay = dayNumber === days;
|
||||
const wins = isClosingDay ? (octave.closingDayRank ?? DEFAULT_CLOSING_DAY_RANK) : (octave.wins ?? DEFAULT_WINS);
|
||||
active.push({ id, name, wins, dayNumber });
|
||||
active.push({ id, name, wins, dayNumber, isClosingDay });
|
||||
}
|
||||
|
||||
/** Every octave (sanctoral or temporal) whose window covers `isoDate`,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617), live
|
||||
# prayMatutinum query (2026-08-17, St. Lawrence's own octave closing day
|
||||
# -- "In Octava S. Laurentii Martyris ~ Duplex"). This is the live-
|
||||
# verified proof for the Matins-psalmody-override mechanism
|
||||
# (hours/matins-psalmody-overrides.ts) -- the general mechanism gap this
|
||||
# closes: a Duplex+ weekday feast previously fell through to reusing the
|
||||
# literal *Sunday* psalmody (data/hours/matins-sunday-antiphons.yml, Ps
|
||||
# 20-31) unconditionally, which is wrong -- a Duplex+ weekday feast has
|
||||
# its own real, different psalmody (confirmed against the live engine
|
||||
# directly), not the Sunday's.
|
||||
#
|
||||
# Cross-checked against a second Duplex saint (St. Ignatius of Antioch,
|
||||
# Common of a Martyr-Bishop, no proper content of his own, live-queried
|
||||
# 2029-02-01): Nocturn 1 is *identical* (Ps 1,2,4,5,8,10); Nocturn 2 is
|
||||
# nearly identical (Ps 14,20,23,63,91 match -- only one slot differs,
|
||||
# Lawrence has 16 where Ignatius has 64). So the psalm *numbers* are a
|
||||
# shared per-Common-category pool, not unique to Lawrence -- only the
|
||||
# antiphon *text* is proper to him (each one narrates an episode of his
|
||||
# own martyrdom, e.g. "Quo progréderis sine fílio, pater?"). This file is
|
||||
# keyed by his own saint id (not yet a Common-category id) since only his
|
||||
# own proper antiphons have been authored so far -- see this module's own
|
||||
# doc comment for what extending this to a real Common-category key would
|
||||
# need.
|
||||
#
|
||||
# Nocturn 3's first canticle spans two Sirach chapters under one heading
|
||||
# in the source ("Eccli 14:22;15:3-4;15:6") -- kept as one canticle with
|
||||
# two refs (matins.ts's own `canticles: { refs: [...] }[]` shape), not
|
||||
# split into two, matching how the live engine presents it. New scripture
|
||||
# chapters authored for this: data/scripture/{sir-14,sir-15,jer-17,sir-31}.yml
|
||||
# (all sparse, only the cited verses -- same convention as sir-36.yml's
|
||||
# own Sunday-canticle transcription).
|
||||
id: st-lawrence
|
||||
nocturn1:
|
||||
groups:
|
||||
- psalms: [1]
|
||||
antiphon:
|
||||
la: "Quo progréderis * sine fílio, pater? quo, sacérdos sancte, sine minístro próperas?"
|
||||
en: "Father, whither goest thou * without thy son? Holy Priest, dost thou fare hence without a Deacon?"
|
||||
- psalms: [2]
|
||||
antiphon:
|
||||
la: "Noli me derelínquere, * pater sancte, quia thesáuros tuos jam expéndi, quos tradidísti mihi."
|
||||
en: "Leave me not, holy father, * for I have spent already thy treasures, which thou gavest unto me."
|
||||
- psalms: [4]
|
||||
antiphon:
|
||||
la: "Non ego te désero, * fili, neque derelínquo; sed majóra tibi debéntur pro Christi fide certámina."
|
||||
en: "I leave thee not, my son, neither forsake thee; * there awaiteth thee for Christ's truth a sterner wrestling than mine."
|
||||
- psalms: [5]
|
||||
antiphon:
|
||||
la: "Beátus Lauréntius * orábat, dicens: Dómine Jesu Christe, Deus de Deo, miserére mihi servo tuo."
|
||||
en: "The blessed Lawrence prayed * and said: O Lord Jesus Christ, God of God, take pity upon me thy servant."
|
||||
- psalms: [8]
|
||||
antiphon:
|
||||
la: "Dixit Románus * ad beátum Lauréntium: Vídeo ante te júvenem pulchérrimum, festína me baptizáre."
|
||||
en: "Romanus said * to Blessed Lawrence: I see in front of thee a very well-favoured young man; make haste to baptize me."
|
||||
- psalms: [10]
|
||||
antiphon:
|
||||
la: "Beátus Lauréntius * dixit: Mea nox obscúrum non habet, sed ómnia in luce claréscunt."
|
||||
en: "The blessed Lawrence said: The darkness is no darkness to me, * but the night is all as clear as morning, that shineth more and more unto the perfect day."
|
||||
versicle:
|
||||
v:
|
||||
la: "Glória et honóre coronásti eum, Dómine."
|
||||
en: "Thou hast crowned him with glory and honour, O Lord."
|
||||
r:
|
||||
la: "Et constituísti eum super ópera mánuum tuárum."
|
||||
en: "And madest him to have dominion over the works of thy hands."
|
||||
nocturn2:
|
||||
groups:
|
||||
- psalms: [14]
|
||||
antiphon:
|
||||
la: "Strinxérunt * córporis membra pósita super cratículam: subiciéntibus prunas insúltat Levíta Christi."
|
||||
en: "They laid him upon the grating, and stretched out his limbs; * Christ's Levite held in derision them that brought the live coals."
|
||||
- psalms: [16]
|
||||
antiphon:
|
||||
la: "Igne me examinásti, * et non est invénta in me iníquitas."
|
||||
en: "Thou hast tried me with fire, * and found no wickedness in me."
|
||||
- psalms: [20]
|
||||
antiphon:
|
||||
la: "Interrogátus * te Dóminum conféssus sum, assátus grátias ago."
|
||||
en: "When I was asked, I acknowledged thee to be the Lord; now that I am roasted, I give thee thanks."
|
||||
- psalms: [23]
|
||||
antiphon:
|
||||
la: "Beátus Lauréntius * clamávit et dixit: Deum meum colo, et illi solo sérvio."
|
||||
en: "Blessed Lawrence * cried out and said: I care for my God, and serve him alone."
|
||||
- psalms: [63]
|
||||
antiphon:
|
||||
la: "Dispérsit * dedit paupéribus: justítia ejus manet in séculum sǽculi."
|
||||
en: "He hath dispersed and gave to the poor; * his justice shall remain in ages of ages."
|
||||
- psalms: [91]
|
||||
antiphon:
|
||||
la: "Protéctor tuus * ego sum noli timére: si transíeris per ignem, flamma non nocébit tibi, et odor ignis non erit in te."
|
||||
en: "I am thy protector, do not fear; * if you pass through fire, the flame will not harm thee, and the scent of fire will not be upon thee."
|
||||
versicle:
|
||||
v:
|
||||
la: "Posuísti, Dómine, super caput ejus."
|
||||
en: "O Lord, Thou hast set a crown of precious stones."
|
||||
r:
|
||||
la: "Corónam de lápide pretióso."
|
||||
en: "Upon his head."
|
||||
nocturn3:
|
||||
antiphon:
|
||||
la: "Nisi granum fruménti * cadens, in terram mórtuum fúerit, ipsum solum manet."
|
||||
en: "Unless the grain of wheat * falls to the ground and dies, it only remains as such."
|
||||
canticles:
|
||||
- refs:
|
||||
- { book: sir, chapter: 14, verses: "22" }
|
||||
- { book: sir, chapter: 15, verses: "3-4" }
|
||||
- { book: sir, chapter: 15, verses: "6" }
|
||||
- refs:
|
||||
- { book: jer, chapter: 17, verses: "7-8" }
|
||||
- refs:
|
||||
- { book: sir, chapter: 31, verses: "8-11" }
|
||||
versicle:
|
||||
v:
|
||||
la: "Magna est glória ejus in salutári tuo."
|
||||
en: "His glory is great in thy salvation."
|
||||
r:
|
||||
la: "Glóriam et magnum decórem impónes super eum."
|
||||
en: "Honour and great majesty shalt Thou lay upon him."
|
||||
@@ -62,9 +62,9 @@ nocturn2:
|
||||
|
||||
nocturn3:
|
||||
canticles:
|
||||
- { book: isa, chapter: 33, verses: "2-10" }
|
||||
- { book: isa, chapter: 33, verses: "13-18" }
|
||||
- { book: sir, chapter: 36, verses: "14-19" }
|
||||
- refs: [{ book: isa, chapter: 33, verses: "2-10" }]
|
||||
- refs: [{ book: isa, chapter: 33, verses: "13-18" }]
|
||||
- refs: [{ book: sir, chapter: 36, verses: "14-19" }]
|
||||
antiphon:
|
||||
la: "Allelúja, allelúja, allelúja."
|
||||
en: "Alleluia, alleluia, alleluia."
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617) --
|
||||
# read directly from the reference engine's own SanctiM/08-17bmv.txt
|
||||
# ("Tertia die infra Octavam S. Assumptionis Beatæ Mariæ Virginis," per
|
||||
# Sancti/08-17bmv.txt's own [Rank] line). Same recurring "Sermo sancti
|
||||
# Joannis Damasceni" Nocturn 2 homily excerpt already used on day 2
|
||||
# (assumption-octave-day-2.yml) and days 4-5 -- confirmed a standing,
|
||||
# day-independent reading reused across the octave, not a copy-paste
|
||||
# mistake -- collapsed into one continuous reading per project
|
||||
# convention. No English translation exists in the source at all for
|
||||
# this reading (English/Sancti/08-17bmv.txt has only the day's own
|
||||
# [Rank] line, no lesson text), so en is left honestly missing rather
|
||||
# than duplicated or translated here, same as day 2. Responsory is the
|
||||
# same recurring "Vidi speciosam" already used on days 2, 4, and 5 --
|
||||
# a standing octave-wide responsory, not day-specific.
|
||||
id: assumption-octave-day-3
|
||||
source: "Sermo sancti Joannis Damasceni"
|
||||
text:
|
||||
la: |
|
||||
Sermo sancti Joánnis Damascéni Orat. 2 de Dormit. Deíparæ Jubilémus in arca Dómini Dei toto ánimo, et muri cadent Jerichuntíni, contrariárum, inquam, potestátum inféstæ munitiónes. Cum David exsultémus spíritu: Arca enim Dómini hódie requiévit. Clamémus cum Gabriéle, qui primum locum óbtinet inter Angelos: Ave, grátia plena; Dóminus tecum. Ave, gáudii pélagus inexháustum: ave, únicum molestiárum levámen: ave, ómnium cordis dolórum medicaméntum. Ave, sancta Virgo, per quam mors quidem fuit expúlsa, vita autem introdúcta.
|
||||
|
||||
Tu vero, o sacrórum sepulcrórum sacratíssimum post Dómini quidem sepúlcrum, quod vitæ dedit princípium, quod fuit fons resurrectiónis (tecum enim loquar, tamquam cum animáto): úbinam est aurum illud purum, quod manus Apostolórum in te condidérunt? Ubi divítiæ, quæ consúmi néqueunt? Ubi pretiósus ille thesáurus, qui vitam suscépit? Ubi novum volúmen, in quo ineffabíliter Dei Verbum sine manu inscríptum fuit? Ubi abýssus grátiæ, ubi pélagus curatiónum? Ubi est desiderábile illud Deíparæ Vírginis corpus?
|
||||
|
||||
Quid quǽritis in sepúlcro eam, quæ ad cæléstia transláta est tabernácula? Cur a me custódiæ ratiónem expóscitis? Non possum ego divínis jussis resístere. Sacrosánctum illud corpus, quod mihi quoque sanctitátem impertívit, ac pretiosíssimi unguénti fragrántia me replévit, et divínum templum effécit, relíctis sindónibus, ábiit raptum sublíme, comitántibus Angelis, Archángelis et ómnibus cæléstibus poténtiis. Nunc me circúmdant Angeli, nunc divína in me hábitat grátia. Ego evási officína medicínæ ægrotántibus: ego fons perénnis curatiónum: ego remédium advérsus dæmones: ego cívitas refúgii ómnibus ad me confugiéntibus.
|
||||
responsory:
|
||||
la: |
|
||||
℟. Vidi speciósam sicut colúmbam, ascendéntem désuper rivos aquárum, cujus inæstimábilis odor erat nimis in vestiméntis ejus; * Et sicut dies verni circúmdabant eam flores rosárum et lília convállium. ℣. Quæ est ista quæ ascéndit per desértum sicut vírgula fumi ex aromátibus myrrhæ et thuris? ℟. Et sicut dies verni circúmdabant eam flores rosárum et lília convállium.
|
||||
en: |
|
||||
℟. I saw her, when, fair like a dove, she winged her flight above the rivers of waters. The priceless savour of her perfumes hung heavy in her garments. * And about her it was as the flower of roses in the spring of the year, and lilies of the valleys. ℣. Who is this that cometh out of the wilderness like a pillar of smoke, perfumed with myrrh and frankincense? ℟. And about her it was as the flower of roses in the spring of the year, and lilies of the valleys.
|
||||
status:
|
||||
la: verified
|
||||
en: missing
|
||||
@@ -0,0 +1,26 @@
|
||||
# Jeremiah 17:7-8, transcribed from a live Divinum Officium query
|
||||
# (Monastic Tridentinum 1617, command=prayMatutinum, 2026-08-17 — St.
|
||||
# Lawrence's own octave closing day, Matins Nocturn III's second
|
||||
# canticle). Verse 8 is split into two half-verses by the source itself
|
||||
# (each half its own antiphon-verse line); transcribed as two separate
|
||||
# entries under the same verse number, matching how the source renders
|
||||
# it, not merged into one. Only 7-8 are authored; the rest of the
|
||||
# chapter is unauthored.
|
||||
book: jer
|
||||
chapter: 17
|
||||
verses:
|
||||
- n: 7
|
||||
text:
|
||||
la: "Benedíctus vir qui confídit in Dómino, * et erit Dóminus fidúcia ejus."
|
||||
en: "Blessed be the man that trusteth in the Lord, * and the Lord shall be his confidence."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 8
|
||||
text:
|
||||
la: "Et erit quasi lignum quod transplantátur super aquas, quod ad humórem mittit radíces suas, * et non timébit cum vénerit æstus."
|
||||
en: "And he shall be as a tree that is planted by the waters, that spreadeth out its roots towards moisture: * and it shall not fear when the heat cometh."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 8
|
||||
text:
|
||||
la: "Et erit fólium ejus víride, et in témpore siccitátis non erit sollícitum, * nec aliquándo désinet fácere fructum."
|
||||
en: "And the leaf thereof shall be green, and in the time of drought it shall not be solicitous, * neither shall it cease at any time to bring forth fruit."
|
||||
status: { la: verified, en: verified }
|
||||
@@ -0,0 +1,14 @@
|
||||
# Sirach (Ecclesiasticus) 14:22, transcribed from a live Divinum Officium
|
||||
# query (Monastic Tridentinum 1617, command=prayMatutinum, 2026-08-17 —
|
||||
# St. Lawrence's own octave closing day, Matins Nocturn III's first
|
||||
# canticle, "Eccli 14:22;15:3-4;15:6" — see
|
||||
# data/hours/matins-psalmody-overrides/st-lawrence.yml). Only this verse
|
||||
# is authored; the rest of the chapter is unauthored.
|
||||
book: sir
|
||||
chapter: 14
|
||||
verses:
|
||||
- n: 22
|
||||
text:
|
||||
la: "Beátus vir qui in sapiéntia morábitur, et qui in justítia sua meditábitur, * et in sensu cogitábit circumspectiónem Dei."
|
||||
en: "Blessed is the man that shall continue in wisdom, and that shall meditate in his justice, * and in his mind shall think of the all-seeing eye of God."
|
||||
status: { la: verified, en: verified }
|
||||
@@ -0,0 +1,25 @@
|
||||
# Sirach (Ecclesiasticus) 15:3-4,6, transcribed from a live Divinum
|
||||
# Officium query (Monastic Tridentinum 1617, command=prayMatutinum,
|
||||
# 2026-08-17 — St. Lawrence's own octave closing day, Matins Nocturn
|
||||
# III's first canticle, "Eccli 14:22;15:3-4;15:6" — see
|
||||
# data/hours/matins-psalmody-overrides/st-lawrence.yml). Verse 5 is
|
||||
# skipped by the citation itself, not an authoring gap; only 3-4 and 6
|
||||
# are authored, the rest of the chapter is unauthored.
|
||||
book: sir
|
||||
chapter: 15
|
||||
verses:
|
||||
- n: 3
|
||||
text:
|
||||
la: "Cibábit illum pane vitæ et intelléctus, * et aqua sapiéntiæ salutáris potábit illum:"
|
||||
en: "With the bread of life and understanding, * she shall feed him, and give him the water of wholesome wisdom to drink."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 4
|
||||
text:
|
||||
la: "Et firmábitur in illo, et non flectétur: et continébit illum, et non confundétur: * et exaltábit illum apud próximos suos."
|
||||
en: "And she shall be made strong in him, and he shall not be moved: And she shall hold him fast, and he shall not be confounded: * and she shall exalt him among his neighbours."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 6
|
||||
text:
|
||||
la: "Et nómine ætérno hæreditábit illum * Dóminus Deus noster."
|
||||
en: "And shall cause him to inherit an everlasting name * the Lord our God."
|
||||
status: { la: verified, en: verified }
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sirach (Ecclesiasticus) 31:8-11, transcribed from a live Divinum
|
||||
# Officium query (Monastic Tridentinum 1617, command=prayMatutinum,
|
||||
# 2026-08-17 — St. Lawrence's own octave closing day, Matins Nocturn
|
||||
# III's third canticle). Verse 10 is split into two half-verses by the
|
||||
# source itself, transcribed as two separate entries under the same
|
||||
# verse number, matching how the source renders it. Only 8-11 are
|
||||
# authored; the rest of the chapter is unauthored.
|
||||
book: sir
|
||||
chapter: 31
|
||||
verses:
|
||||
- n: 8
|
||||
text:
|
||||
la: "Beátus vir, qui invéntus est sine mácula, et qui post aurum non ábiit, * nec sperávit in pecúnia et thesáuris."
|
||||
en: "Blessed is the man that is found without blemish: and that hath not gone after gold, * nor put his trust in money nor in treasures."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 9
|
||||
text:
|
||||
la: "Quis est hic, et laudábimus eum? * fecit enim mirabília in vita sua."
|
||||
en: "Who is he, and we will praise him? * for he hath done wonderful things in his life."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 10
|
||||
text:
|
||||
la: "Qui probátus est in illo, et perféctus est, * erit illi glória ætérna:"
|
||||
en: "Who hath been tried thereby, and made perfect, * he shall have glory everlasting."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 10
|
||||
text:
|
||||
la: "Qui pótuit tránsgredi, et non est transgréssus; * fácere mala, et non fecit."
|
||||
en: "He that could have transgressed, and hath not transgressed: * and could do evil things, and hath not done them."
|
||||
status: { la: verified, en: verified }
|
||||
- n: 11
|
||||
text:
|
||||
la: "Ideo stabilíta sunt bona illíus in Dómino, * et eleemósynas illíus enarrábit omnis ecclésia sanctórum."
|
||||
en: "Therefore are his goods established in the Lord, * and all the church of the saints shall declare his alms."
|
||||
status: { la: verified, en: verified }
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Matins' own analogue of hours/lauds-psalmody-overrides.ts, but keyed
|
||||
* by Common category rather than per-saint: live-verified (see
|
||||
* data/hours/matins-psalmody-overrides/st-lawrence.yml's own header)
|
||||
* that the psalm *numbers* for Nocturns 1-2 are shared across saints of
|
||||
* the same Common category (a plain-Duplex Martyr and a plain-Duplex
|
||||
* Martyr-Bishop share all but one of the same twelve psalm numbers) —
|
||||
* only the antiphon text varies, proper to the individual saint when
|
||||
* authored, generic Common-of-* text otherwise. Deliberately the
|
||||
* opposite indexing choice from Lauds' own per-feast override (see that
|
||||
* file's own doc comment) — Lauds' 5-antiphon pool is shared across
|
||||
* every Duplex-majus+ feast regardless of category, so per-feast is the
|
||||
* natural key there; Matins' pool varies *by* category, so per-category
|
||||
* is the natural key here.
|
||||
*
|
||||
* `id` is looked up against `hours/resolve-common.ts`'s
|
||||
* `resolveOfficeWinner(day).id` when it's sanctoral, exactly like every
|
||||
* other per-feast/per-category content lookup in this app — but the
|
||||
* `id` values populated so far are individual saint ids (St. Lawrence
|
||||
* proper), not Common-category ids, since only the live-verified proof
|
||||
* has been authored; extending this to a real Common-category key
|
||||
* (`common-of-a-martyr`, etc., matching `SaintRecord.common`) with
|
||||
* per-saint antiphon overrides layered on top is the deferred bulk-
|
||||
* content work (see TODO.md) — not built yet, so a saint without their
|
||||
* own entry here falls back to the plain ferial weekday table
|
||||
* (matins.ts's own fallback), not to some other saint's proper
|
||||
* antiphons. */
|
||||
export interface MatinsPsalmodyGroup {
|
||||
psalms: number[];
|
||||
antiphon: Partial<Record<string, string>>;
|
||||
}
|
||||
export interface ScriptureCanticleRef {
|
||||
book: string;
|
||||
chapter: number;
|
||||
verses?: string;
|
||||
}
|
||||
export interface MatinsPsalmodyNocturn {
|
||||
groups?: MatinsPsalmodyGroup[];
|
||||
canticles?: { refs: ScriptureCanticleRef[] }[];
|
||||
antiphon?: Partial<Record<string, string>>;
|
||||
versicle: { v: Partial<Record<string, string>>; r: Partial<Record<string, string>> };
|
||||
}
|
||||
export interface MatinsPsalmodyOverride {
|
||||
id: string;
|
||||
nocturn1: MatinsPsalmodyNocturn;
|
||||
nocturn2: MatinsPsalmodyNocturn;
|
||||
nocturn3: MatinsPsalmodyNocturn;
|
||||
}
|
||||
|
||||
const modules = import.meta.glob<{ default: MatinsPsalmodyOverride }>(
|
||||
'../data/hours/matins-psalmody-overrides/*.yml',
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const overrides = new Map<string, MatinsPsalmodyOverride>();
|
||||
for (const mod of Object.values(modules)) {
|
||||
overrides.set(mod.default.id, mod.default);
|
||||
}
|
||||
|
||||
export function getMatinsPsalmodyOverride(id: string | undefined): MatinsPsalmodyOverride | undefined {
|
||||
return id ? overrides.get(id) : undefined;
|
||||
}
|
||||
+54
-4
@@ -69,6 +69,7 @@ import { resolveCommon, getDayCollect, resolveOfficeWinner, verifiedText, splitN
|
||||
import { getBiblePlanReadings } from '../propers/bible-plan';
|
||||
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
|
||||
import { getOctaveReading } from '../propers/octave-readings';
|
||||
import { getMatinsPsalmodyOverride } from './matins-psalmody-overrides';
|
||||
import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml';
|
||||
|
||||
type BilingualText = Partial<Record<string, string>>;
|
||||
@@ -76,9 +77,20 @@ interface SundayGroup {
|
||||
psalms: number[];
|
||||
antiphon: BilingualText;
|
||||
}
|
||||
interface ScriptureRef {
|
||||
book: string;
|
||||
chapter: number;
|
||||
verses?: string;
|
||||
}
|
||||
interface SundayNocturn {
|
||||
groups?: SundayGroup[];
|
||||
canticles?: { book: string; chapter: number; verses?: string }[];
|
||||
// Usually one ref per canticle; more than one when the source cites a
|
||||
// single canticle across a chapter boundary (e.g. St. Lawrence's own
|
||||
// Nocturn 3, "Eccli 14:22;15:3-4;15:6" — one canticle, two Sirach
|
||||
// chapters) — concatenated in order, not rendered as separate
|
||||
// canticles, matching how the source itself presents it as one entry
|
||||
// under one heading.
|
||||
canticles?: { refs: ScriptureRef[] }[];
|
||||
antiphon?: BilingualText;
|
||||
versicle: { v: BilingualText; r: BilingualText };
|
||||
}
|
||||
@@ -144,14 +156,14 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
|
||||
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 verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses));
|
||||
const text: BilingualText = {
|
||||
la: verses.map((v) => v.text.la).filter(Boolean).join(' '),
|
||||
en: verses.map((v) => v.text.en).filter(Boolean).join(' '),
|
||||
};
|
||||
return {
|
||||
kind: 'canticle' as const,
|
||||
canticleId: `${c.book}-${c.chapter}${c.verses ? `-${c.verses}` : ''}`,
|
||||
canticleId: c.refs.map((ref) => `${ref.book}-${ref.chapter}${ref.verses ? `-${ref.verses}` : ''}`).join('_'),
|
||||
text: verifiedText(text),
|
||||
antiphon: i === 0 ? opening : undefined,
|
||||
};
|
||||
@@ -165,6 +177,20 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
|
||||
return psalmRefParts(getPsalmsFor('matins', day.weekday));
|
||||
}
|
||||
|
||||
/** Fallback for a Duplex+ weekday feast with no matins-psalmody-overrides
|
||||
* entry authored for its winner yet (most of them, until the bulk-content
|
||||
* pass — see hours/matins-psalmody-overrides.ts's own doc comment): the
|
||||
* plain ferial weekday table, chunked into 3 nocturns instead of 1 (no
|
||||
* antiphons, no canticles — that content doesn't exist for the ferial
|
||||
* table at all, unlike a real per-category override). Direct instruction:
|
||||
* never blank, but honestly not that saint's own real proper psalmody
|
||||
* until it's authored. */
|
||||
function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], ResolvedPart[], ResolvedPart[]] {
|
||||
const psalms = ferialPsalmody(day);
|
||||
const size = Math.ceil(psalms.length / 3);
|
||||
return [psalms.slice(0, size), psalms.slice(size, size * 2), psalms.slice(size * 2)];
|
||||
}
|
||||
|
||||
/** Every id whose own patristic/hagiographic/Gospel content should be
|
||||
* gathered for `day` — the office winner (if sanctoral), every
|
||||
* commemorated saint (a transferred-in feast already appears as `day.winner`
|
||||
@@ -279,7 +305,7 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
|
||||
];
|
||||
|
||||
if (threeNocturns) {
|
||||
if (threeNocturns && day.weekday === 'sunday') {
|
||||
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
|
||||
parts.push(...(nocturn1Readings ?? []));
|
||||
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
|
||||
@@ -287,6 +313,30 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
|
||||
parts.push(...(nocturn3Readings ?? []));
|
||||
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
||||
} else if (threeNocturns) {
|
||||
// A Duplex+ weekday feast — genuinely different psalmody from a real
|
||||
// Sunday's (see hours/matins-psalmody-overrides.ts's own doc
|
||||
// comment), not the same content reused. Uses that saint's own
|
||||
// Common-category override when authored, falling back to the plain
|
||||
// ferial weekday table (redistributed into 3 nocturns) otherwise.
|
||||
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
|
||||
if (override) {
|
||||
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
|
||||
parts.push(...(nocturn1Readings ?? []));
|
||||
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
|
||||
parts.push(...(nocturn2Readings ?? []));
|
||||
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
|
||||
parts.push(...(nocturn3Readings ?? []));
|
||||
} else {
|
||||
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
|
||||
parts.push(...n1);
|
||||
parts.push(...(nocturn1Readings ?? []));
|
||||
parts.push(...n2);
|
||||
parts.push(...(nocturn2Readings ?? []));
|
||||
parts.push(...n3);
|
||||
parts.push(...(nocturn3Readings ?? []));
|
||||
}
|
||||
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
||||
} else {
|
||||
parts.push(...ferialPsalmody(day));
|
||||
parts.push(...(nocturn1Readings ?? []));
|
||||
|
||||
+20
-15
@@ -493,29 +493,34 @@ function octaveCommemorationPart(commemoration: Extract<Commemoration, { kind: '
|
||||
* text surgery this doesn't attempt — so on a commemorated day, each
|
||||
* collect here renders as its own complete, separate block instead.
|
||||
*
|
||||
* An octave commemoration only renders its own block here when
|
||||
* `day.temporalCategory !== 'ordinary-feria'` — the same gate
|
||||
* resolveOfficeWinner uses (see its own doc comment). On an *ordinary*
|
||||
* octave day (e.g. an in-between day of St. Lawrence's own octave),
|
||||
* resolveOfficeWinner has already substituted that octave's content as
|
||||
* the primary collect above, so rendering it again here would be a
|
||||
* redundant repeat (or worse, an honestly-"missing" duplicate for the
|
||||
* many octaves without their own `-octave-commemoration.yml` authored).
|
||||
* On a day the temporal identity itself keeps real standing (the
|
||||
* Christmas Octave's own stacking days, e.g. Dec 26-31, where Christmas
|
||||
* + Stephen + John + Holy Innocents are each merely commemorated
|
||||
* alongside the day's own temporal office), resolveOfficeWinner does
|
||||
* *not* touch the primary collect at all, so this is the only place
|
||||
* these four octaves' real "Commemoratio Octavæ ..." content surfaces.
|
||||
* An octave commemoration skips rendering its own block here only when
|
||||
* it's the *specific* octave whose content resolveOfficeWinner already
|
||||
* substituted as the primary collect above (same
|
||||
* `day.temporalCategory === 'ordinary-feria'` gate, same
|
||||
* `resolveActiveOctave` pick — see resolveOfficeWinner's own doc
|
||||
* comment) — rendering it again would be a redundant repeat. Every
|
||||
* *other* simultaneously-active octave still gets its own block: e.g.
|
||||
* Aug 17, St. Lawrence's own elevated closing day and the Assumption's
|
||||
* ordinary day 3 genuinely overlap — Lawrence's own content governs the
|
||||
* primary collect (the higher-ranked of the two, resolveActiveOctave's
|
||||
* own pick), but the Assumption's octave is still real and distinct, not
|
||||
* a duplicate of Lawrence's, so it still renders its own commemoration
|
||||
* block here (once authored). On a day the temporal identity itself
|
||||
* keeps real standing (the Christmas Octave's own stacking days, e.g.
|
||||
* Dec 26-31), resolveOfficeWinner never substitutes anything, so no
|
||||
* octave is skipped and every active one renders here — the only place
|
||||
* those four octaves' real "Commemoratio Octavæ ..." content surfaces.
|
||||
*/
|
||||
export function getDayCollects(day: LiturgicalDay): ResolvedPart[] {
|
||||
const parts: ResolvedPart[] = [{ kind: 'prayer', text: getDayCollect(day) }];
|
||||
const substitutedOctaveId =
|
||||
day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date)?.id : undefined;
|
||||
for (const commemoration of day.commemorations) {
|
||||
if (commemoration.kind === 'temporal') {
|
||||
parts.push({ kind: 'prayer', text: toResolvedText(getTemporalProper(`${commemoration.id}-collect`)) });
|
||||
} else if (commemoration.kind === 'sanctoral') {
|
||||
parts.push(sanctoralCommemorationPart(commemoration));
|
||||
} else if (commemoration.kind === 'octave' && day.temporalCategory !== 'ordinary-feria') {
|
||||
} else if (commemoration.kind === 'octave' && commemoration.id !== substitutedOctaveId) {
|
||||
parts.push(octaveCommemorationPart(commemoration));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('getDayLabel — ordinal temporal label', () => {
|
||||
// tests/calendar/transfer.test.ts's own May 31 case for the full
|
||||
// winner/commemorations breakdown), so the label now reflects her,
|
||||
// not Trinity Sunday itself.
|
||||
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('The Queenship of the Blessed Virgin Mary');
|
||||
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('The Queenship of the Blessed Virgin Mary (Duplex II Class)');
|
||||
expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter');
|
||||
expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday');
|
||||
});
|
||||
@@ -82,12 +82,12 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
|
||||
// genuinely saint-free overflow-week Monday instead (no single
|
||||
// Nov-dated Monday near this Sunday pair is clean any more, now that
|
||||
// Nov 4/8/15/16/20 are all fixed real content).
|
||||
expect(getDayLabel(resolveDay('2024-11-11'))).toBe('St. Martin of Tours, Bishop and Confessor');
|
||||
expect(getDayLabel(resolveDay('2024-11-11'))).toBe('St. Martin of Tours, Bishop and Confessor (Duplex Majus)');
|
||||
expect(getDayLabel(resolveDay('2024-11-17'))).toBe('The 6th Sunday after Epiphany');
|
||||
// 2024-11-18 is the Dedication of the Basilicas of Ss. Peter and Paul
|
||||
// (pre-existing fixed content) -- again a real winning saint, not the
|
||||
// ferial fallback.
|
||||
expect(getDayLabel(resolveDay('2024-11-18'))).toBe('Dedication of the Basilicas of Ss. Peter and Paul');
|
||||
expect(getDayLabel(resolveDay('2024-11-18'))).toBe('Dedication of the Basilicas of Ss. Peter and Paul (Duplex)');
|
||||
// 2035-10-29, a Monday in the 3rd week of a different overflow
|
||||
// stretch, is genuinely clean (no sanctoral entry, no commemoration)
|
||||
// -- covers the ferial "Monday in the Nth week after Epiphany" format
|
||||
@@ -110,7 +110,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
|
||||
// outright rather than being commemorated — a pre-existing rule, not
|
||||
// new behavior from adding her. 2026-11-22 above (no saint collision)
|
||||
// still covers the fixed-ordinal label itself.
|
||||
expect(getDayLabel(resolveDay('1943-11-21'))).toBe('The Presentation of the Blessed Virgin Mary');
|
||||
expect(getDayLabel(resolveDay('1943-11-21'))).toBe('The Presentation of the Blessed Virgin Mary (Duplex Majus)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,8 +125,10 @@ describe('getDayLabel — active octave', () => {
|
||||
|
||||
it('shows the octave day alone when nothing is separately commemorated that day', () => {
|
||||
// Not Aug 17 (day 8, the last day, of St. Lawrence's octave): that
|
||||
// date is now St. Hyacinth's own fixed day (Duplex, added per the
|
||||
// full-year sanctoral import), which wins outright. Not Nov 8 either
|
||||
// date is also within the Assumption's own overlapping octave (day 3)
|
||||
// and St. Hyacinth's own fixed day (Duplex) is commemorated there too
|
||||
// — see the "picks the higher-ranked octave" case below for that
|
||||
// three-way combination. Not Nov 8 either
|
||||
// (day 8, the last day, of All Saints' own octave): the November
|
||||
// sanctoral pull gave that day its own real feast, the Octave Day of
|
||||
// All Saints itself (Duplex majus, own proper Nocturn 2-3 content --
|
||||
@@ -155,23 +157,22 @@ describe('getDayLabel — active octave', () => {
|
||||
expect(getDayLabel(day)).toBe('Friday in Christmastide');
|
||||
});
|
||||
|
||||
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall", () => {
|
||||
// Aug 17 itself is no longer available to demonstrate the
|
||||
// octave-vs-octave priority pick through a real date: it's now St.
|
||||
// Hyacinth's own fixed day (Duplex, added per the full-year
|
||||
// sanctoral import), which wins outright over both octaves. In
|
||||
// fact every day where St. Lawrence's (Aug 10-17) and the
|
||||
// Assumption's (Aug 15-22) octaves genuinely overlap (Aug 15-17) is
|
||||
// now occupied by real sanctoral content (the Assumption herself,
|
||||
// St. Joachim, St. Hyacinth) -- there's no remaining real calendar
|
||||
// date left in this app where that specific priority comparison is
|
||||
// observable. The underlying mechanism itself (`pickWinningOctave`)
|
||||
// is still directly unit-tested with synthetic data in
|
||||
// tests/calendar/octaves.test.ts's own "picks the higher-ranked
|
||||
// octave regardless of order or start date" case. Aug 18 (day 4 of
|
||||
// the Assumption's octave alone, Lawrence's already over) is still
|
||||
// a valid, uncontested check.
|
||||
expect(getDayLabel(resolveDay('2026-08-17'))).toBe('St. Hyacinth, Confessor');
|
||||
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => {
|
||||
// Live-verified against the reference engine directly (Monastic
|
||||
// Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S.
|
||||
// Laurentii Martyris ~ Duplex" -- Lawrence's own elevated closing day
|
||||
// wins outright, with Hyacinth's own alternate-rubric block titled
|
||||
// "Commemoratio S. Hyacinthi Confessoris" confirming he's the one
|
||||
// merely commemorated there. A tie against an octave's own elevated
|
||||
// closing day favors the octave (calendar/index.ts's applyOctaves),
|
||||
// unlike an ordinary mid-octave day's tie, which still favors an
|
||||
// occurring saint (see octaves.test.ts and the December/September
|
||||
// sanctoral tests for those counterexamples). The Assumption's own
|
||||
// day 3 (semiduplex, genuinely active but outranked by Lawrence's
|
||||
// elevated duplex) still gets named, not dropped, alongside it.
|
||||
expect(getDayLabel(resolveDay('2026-08-17'))).toBe(
|
||||
'8th Day within the Octave of St. Lawrence, Martyr — The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor',
|
||||
);
|
||||
expect(getDayLabel(resolveDay('2026-08-18'))).toBe('4th Day within the Octave of The Assumption of the Blessed Virgin Mary');
|
||||
});
|
||||
|
||||
@@ -199,7 +200,7 @@ describe('getDayLabel — feast name combination', () => {
|
||||
...base,
|
||||
winner: { kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' },
|
||||
};
|
||||
expect(getDayLabel(day)).toBe('St. Ereden');
|
||||
expect(getDayLabel(day)).toBe('St. Ereden (Duplex)');
|
||||
});
|
||||
|
||||
it('shows both, feast first, when the feast is merely commemorated', () => {
|
||||
|
||||
@@ -9,7 +9,9 @@ describe('activeOctavesFor', () => {
|
||||
|
||||
it("finds a saint's own octave on its own day (day 1) and through day 8, not on day 9", () => {
|
||||
const day1 = activeOctavesFor('2026-08-10'); // St. Lawrence's own day
|
||||
expect(day1).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1 }]);
|
||||
expect(day1).toEqual([
|
||||
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1, isClosingDay: false },
|
||||
]);
|
||||
|
||||
// Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) --
|
||||
// the two run concurrently this time of year. Day 8 is Lawrence's own
|
||||
@@ -17,8 +19,14 @@ describe('activeOctavesFor', () => {
|
||||
// pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig).
|
||||
const day8 = activeOctavesFor('2026-08-17');
|
||||
expect(day8).toEqual([
|
||||
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8 },
|
||||
{ id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary', wins: 'semiduplex', dayNumber: 3 },
|
||||
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8, isClosingDay: true },
|
||||
{
|
||||
id: 'assumption',
|
||||
name: 'The Assumption of the Blessed Virgin Mary',
|
||||
wins: 'semiduplex',
|
||||
dayNumber: 3,
|
||||
isClosingDay: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Aug 23: past both Lawrence's (ended Aug 17) and the Assumption's
|
||||
@@ -28,7 +36,9 @@ describe('activeOctavesFor', () => {
|
||||
|
||||
it("Pentecost's octave uses its own configured threshold (duplex), not the default (semiduplex)", () => {
|
||||
const octaves = activeOctavesFor('2026-05-29'); // within Pentecost's octave, 2026
|
||||
expect(octaves).toEqual([{ id: 'pentecost-sunday', name: 'Pentecost', wins: 'duplex', dayNumber: 6 }]);
|
||||
expect(octaves).toEqual([
|
||||
{ id: 'pentecost-sunday', name: 'Pentecost', wins: 'duplex', dayNumber: 6, isClosingDay: false },
|
||||
]);
|
||||
expect(strictestThreshold(octaves)).toBe('duplex');
|
||||
});
|
||||
|
||||
@@ -62,10 +72,34 @@ describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave preceden
|
||||
|
||||
// pickWinningOctave directly, since no real equal-rank overlap exists yet
|
||||
// in this app's own calendar to exercise the tie-break against live data.
|
||||
const higher = (dayNumber: number): ActiveOctave => ({ id: 'higher', name: 'Higher', wins: 'duplex', dayNumber });
|
||||
const lower = (dayNumber: number): ActiveOctave => ({ id: 'lower', name: 'Lower', wins: 'semiduplex', dayNumber });
|
||||
const equalA = (dayNumber: number): ActiveOctave => ({ id: 'equal-a', name: 'Equal A', wins: 'duplex', dayNumber });
|
||||
const equalB = (dayNumber: number): ActiveOctave => ({ id: 'equal-b', name: 'Equal B', wins: 'duplex', dayNumber });
|
||||
const higher = (dayNumber: number): ActiveOctave => ({
|
||||
id: 'higher',
|
||||
name: 'Higher',
|
||||
wins: 'duplex',
|
||||
dayNumber,
|
||||
isClosingDay: false,
|
||||
});
|
||||
const lower = (dayNumber: number): ActiveOctave => ({
|
||||
id: 'lower',
|
||||
name: 'Lower',
|
||||
wins: 'semiduplex',
|
||||
dayNumber,
|
||||
isClosingDay: false,
|
||||
});
|
||||
const equalA = (dayNumber: number): ActiveOctave => ({
|
||||
id: 'equal-a',
|
||||
name: 'Equal A',
|
||||
wins: 'duplex',
|
||||
dayNumber,
|
||||
isClosingDay: false,
|
||||
});
|
||||
const equalB = (dayNumber: number): ActiveOctave => ({
|
||||
id: 'equal-b',
|
||||
name: 'Equal B',
|
||||
wins: 'duplex',
|
||||
dayNumber,
|
||||
isClosingDay: false,
|
||||
});
|
||||
|
||||
it('picks the higher-ranked octave regardless of order or start date', () => {
|
||||
expect(pickWinningOctave([lower(2), higher(6)])?.id).toBe('higher');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOrdo } from '../../src/hours';
|
||||
import { getPsalmsFor } from '../../src/psalter/distribution';
|
||||
|
||||
// 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,
|
||||
@@ -120,3 +121,65 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => {
|
||||
expect(prayerIndex).toBeGreaterThan(teDeumIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrdo("matins", ...) Duplex+ weekday-feast (3-nocturn, non-Sunday) branch', () => {
|
||||
// 2026-08-17 -- St. Lawrence's own octave closing day ("In Octava S.
|
||||
// Laurentii Martyris ~ Duplex"), live-verified directly against the
|
||||
// reference engine (Monastic Tridentinum 1617, command=prayMatutinum).
|
||||
// The mechanism bug this proves fixed: a Duplex+ weekday feast used to
|
||||
// fall through to reusing the literal *Sunday* psalmody (Ps 20-31)
|
||||
// unconditionally -- confirmed wrong, since a real Duplex+ weekday
|
||||
// feast has its own genuinely different psalmody (see
|
||||
// hours/matins-psalmody-overrides.ts and its own st-lawrence.yml proof
|
||||
// data for the full sourcing detail).
|
||||
const ordo = resolveOrdo('matins', '2026-08-17');
|
||||
|
||||
it("uses St. Lawrence's own proper psalmody, not the Sunday scheme, across 3 nocturns plus a Te Deum", () => {
|
||||
const psalms = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
|
||||
// Ps 3 + 94 (opening/invitatory), then his own Nocturn 1 (1,2,4,5,8,10)
|
||||
// and Nocturn 2 (14,16,20,23,63,91) -- confirmed live, not the Sunday
|
||||
// scheme's Ps 20-31 at all.
|
||||
expect(psalms).toEqual([3, 94, 1, 2, 4, 5, 8, 10, 14, 16, 20, 23, 63, 91]);
|
||||
expect(ordo.parts.some((p) => p.kind === 'te-deum')).toBe(true);
|
||||
});
|
||||
|
||||
it("every Nocturn 1/2 psalm carries St. Lawrence's own proper antiphon, verified in both languages", () => {
|
||||
const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { status: Record<string, string> } }[];
|
||||
// Skip Ps 3 (opening, no antiphon) and Ps 94 (the Invitatory, its own
|
||||
// separate antiphon convention) -- the remaining 12 are his own.
|
||||
const nocturnPsalms = psalmParts.filter((p) => ![3, 94].includes(p.psalmNumber));
|
||||
expect(nocturnPsalms).toHaveLength(12);
|
||||
for (const p of nocturnPsalms) {
|
||||
expect(p.antiphon?.status.la).toBe('verified');
|
||||
expect(p.antiphon?.status.en).toBe('verified');
|
||||
}
|
||||
});
|
||||
|
||||
it('has 3 real OT canticles in Nocturn 3, verified in both languages, sourced from the newly-authored Sirach/Jeremiah chapters', () => {
|
||||
const canticles = ordo.parts.filter((p) => p.kind === 'canticle') as { canticleId: string; text: { status: Record<string, string> } }[];
|
||||
expect(canticles).toHaveLength(3);
|
||||
// The first canticle spans two Sirach chapters under one heading in
|
||||
// the source -- kept as one canticle with two refs, not split.
|
||||
expect(canticles[0]?.canticleId).toBe('sir-14-22_sir-15-3-4_sir-15-6');
|
||||
for (const c of canticles) {
|
||||
expect(c.text.status.la).toBe('verified');
|
||||
expect(c.text.status.en).toBe('verified');
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the plain ferial weekday table, redistributed into 3 nocturns, for a Duplex+ weekday winner with no override authored", () => {
|
||||
// St. Ignatius of Antioch (Duplex, Common of a Martyr-Bishop, no
|
||||
// matins-psalmody-overrides entry authored) -- Feb 1 2029, confirmed
|
||||
// clean (no Sunday collision) per his own saint-file comment.
|
||||
const fallback = resolveOrdo('matins', '2029-02-01');
|
||||
const psalms = fallback.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
|
||||
// Ps 3 + 94, then Thursday's own 9-psalm ferial table (psalter-
|
||||
// distribution.yml), not St. Lawrence's proper psalmody and not the
|
||||
// Sunday scheme either.
|
||||
expect(psalms[0]).toBe(3);
|
||||
expect(psalms[1]).toBe(94);
|
||||
expect(psalms.slice(2)).toEqual(getPsalmsFor('matins', 'thursday').map((r) => r.number));
|
||||
expect(fallback.parts.some((p) => p.kind === 'te-deum')).toBe(true);
|
||||
expect(fallback.parts.some((p) => p.kind === 'canticle')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user