Rework Matins readings into a pooled, distributed model

Replaces the fixed Nocturn 1 = plan / Nocturns 2-3 = patristic slotting
with one ordered pool (scripture plan, winner/commemorations/temporal id,
active octaves) sliced evenly across however many nocturns the day has.
Also makes the bible-plan store dual-keyed so the Dec 25 - Jan 13 stretch
can key off a fixed calendar date alongside the usual (temporalId,
weekday) key. Mechanism only — content authoring is still 2 proof dates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 07:06:23 -04:00
parent 915c441170
commit 2cc856ed92
3 changed files with 223 additions and 82 deletions
+89 -3
View File
@@ -4,6 +4,62 @@ A real backlog, not scattered code comments. Update this file when something
here gets done, and add to it when new gaps turn up — don't let it drift back
into "only findable by reading commit messages."
## Priority
Work in this file follows one methodology, repeated every time a new hour or content type
gets built: **mechanism first, then a minimal live-verified proof (1-2 clean dates/entries
authored fully, end-to-end) to confirm the mechanism is right, then bulk content
authoring/import against the whole calendar once it's trusted.** A section marked "mechanism
built" but not yet "full sweep" or "all N done" is mid-pipeline, not stalled — its next work
is more content in the same already-proven shape, not a design change. This file's dated
build log (below "Open work, in priority order") is the record of that pipeline running
across every hour and content type in this app.
## Open work, in priority order
1. **Mechanism gaps** — a real design/code piece not built yet, blocking any content pass on
it from starting:
- Pre-1955-but-post-1617 saints' differently-structured Matins readings — source confirmed
(Tridentine/Divino Afflatu, combine-where-sensible applies the same way), but the actual
sourcing/modeling pass hasn't started — see "Matins ordo" below.
- The "which Sunday governs this date" temporal-id algorithm (23rd-Sunday-before-Advent
rule, Epiphany-Sunday transfer to the end of the liturgical year, the
Saturday-before-Septuagesima 6th-Epiphany-Sunday commemoration edge case) — tabled, not
started.
- Easter's own octave — tabled, "a whole different discussion."
- What "a Gospel reading is its own kind" should mean structurally/in the UI, beyond the
`isGospel` flag on a plain Matins `lesson` — open; deferred until there's a UI to
evaluate it against.
2. **Minimal-proof-done, bulk content pending** — mechanism is built and trusted (a proof
date/entry already resolves correctly end-to-end); what's left is authoring/importing more
content in that same shape across the rest of the calendar:
- Matins: convert the rest of the user's ~390-row bible-plan TSV (2 proof dates converted
so far); author patristic/hagiographic readings for every saint where one can be
sourced — no longer Duplex+-gated, content is pooled and shown regardless of rank (see
"Reading-pool redesign"); expand the per-book Matins responsory pool beyond its one
seeded book (`isa`); once the pre-1955 mechanism gap above is resolved, source those
saints' readings too.
- 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.
- A handful of individually-flagged content gaps, each already flagged in place rather than
silently dropped: St. Frances of Rome's and St. Bibiana's Benedictus antiphons
(structurally can't win their own day under this app's calendar in any near-term year,
needs a very late test year to check); Ss. Ursula and Companions' Benedictus antiphon
(a category-of-one, no live rendering to verify against); "Our Lady of the Snows" (not
modeled at all); the four "…benedícta…" octave-day Ant+V/R+collect blocks (not yet
modeled).
3. **Known, deliberate simplifications** — flagged as intentional, not a bug, no plan to
revisit unless priorities change: see "Known, deliberate simplifications" at the end of
this file, plus the Matins "Duo Seraphim" closing-responsory gap and the 5 Vespers hymns
whose seasonal doxology substitution isn't modeled (both flagged in their own sections
below, not repeated here).
Everything from here down is the dated build log this priority list is drawn from — kept for
the sourcing/verification detail behind each decision, not itself in priority order.
## Content gaps — mechanism built, data incomplete
### Lauds psalmody overrides + office-bundle overrides — done (2026-08)
@@ -1231,11 +1287,41 @@ counts, Te Deum presence, reading citations and statuses, Gospel flagging
and its Common-fallback exclusion, responsory matching) — not just checking
the YAML files exist. `npm test` (359 tests) and `npm run build` both pass.
### Reading-pool redesign — done (2026-08)
**Corrected a miscommunication in the original mechanism build above**: readings are now
pool-assembled, not fixed-slotted. Every source that can contribute for a day — the office
winner, every commemorated saint, a transferred-in feast (already folded into `day.winner`
by `resolveDay`, no separate lookup needed), every active octave, the plain temporal id, and
the user's own scripture-plan reading — is gathered into one ordered pool
(`buildReadingPool` in `src/hours/matins.ts`), then sliced into however many nocturns the
day's psalmody has (`distributeIntoNocturns`, ceiling division, pool order preserved). No
reading kind is pinned to a specific nocturn anymore — scripture is no longer reserved for
Nocturn 1, patristic content no longer reserved for Nocturns 2-3.
The 1-vs-3-nocturn psalmody gate (Duplex+/Sunday) is unchanged — it governs psalm/canticle/
Te Deum structure only. It no longer limits which days get patristic content authored or
shown: a sub-Duplex ferial day's single nocturn now pools in patristic/hagiographic content
whenever it's been sourced, alongside the scripture-plan reading — direct instruction, "we
want patristic readings for every saint where we can source one, not just duplex+."
`src/propers/bible-plan.ts`'s `getBiblePlanReadings` is now dual-keyed: most rows still key
off `(temporalId, weekday)`, but the source TSV's Dec 25 - Jan 13 stretch keys off a fixed
`MM-DD` calendar date instead (read straight through regardless of which temporal Sunday
governs the day) — a date can match both keys at once (e.g. a Sunday after Epiphany landing
inside Jan 1-13), and both are pooled rather than one overriding the other. No calendar-date
rows are authored yet (mechanism only); see memory `vu-matins-design` for the full design
writeup. `npm test` (359 tests, unchanged — the two proof dates still resolve the same
content, just via the new pool/distribute path) and `npm run build` both pass.
**Not done in this pass — explicitly deferred, not silently dropped:**
- All ~390 rows of the user's own bible-plan TSV — only the two proof
dates' rows are converted so far.
- Full Sunday/feast-calendar patristic-reading authoring (the ~75 Duplex+
saints eligible for Nocturn 3, per the user's own threshold).
dates' rows are converted so far (both temporal-id-keyed; no
calendar-date-keyed rows for the Dec 25 - Jan 13 stretch yet).
- Full Sunday/feast-calendar patristic-reading authoring — now framed as
"every saint where a reading can be sourced," not just the ~75 Duplex+
saints that get their own Nocturn 3 slot (see the reading-pool redesign
above: content is no longer rank-gated, only nocturn *count* is).
- Pre-1955-but-post-1617 saints' differently-structured readings (source
confirmed as Tridentine/Divino Afflatu rather than Monastic 1617,
combine-where-sensible rule confirmed to apply the same way — the actual
+84 -47
View File
@@ -11,19 +11,33 @@
// reconstruction. The reference engine's Monastic 1617 data is a content
// and structure source, not a spec to reproduce — several real, deliberate
// departures from it are built in here:
// - Nocturn 1 is always the user's own continuous scripture-reading plan
// (src/propers/bible-plan.ts), never the historical per-day lectionary
// — a *variable* number of readings, not the historical fixed 3 (or 1,
// in the source's own "summer" contraction — deliberately not
// reproduced here; this app reads in full year-round).
// - Readings are pool-assembled, not fixed-slotted (user, 2026-08,
// superseding this file's original "Nocturn 1 = plan, Nocturns 2-3 =
// patristic" design): every source that can contribute for the day —
// the user's own scripture-reading plan (src/propers/bible-plan.ts,
// a *variable* number of readings, not the historical fixed 3 or the
// Rule's own "summer" contraction, deliberately not reproduced —
// this app reads in full year-round), the office winner's and every
// commemorated saint's own patristic/hagiographic/Gospel content, the
// plain temporal day's own content, and every active octave's own
// reading — is gathered into one ordered pool (`buildReadingPool`),
// then sliced into however many nocturns the day's psalmody has
// (`distributeIntoNocturns`), with no reading kind pinned to a
// particular nocturn number.
// - Where the historical office splits one continuous source across
// several numbered lessons, this app recombines them into one reading
// (see src/propers/octave-readings.ts's resolvePassages / src/propers/
// nocturn-readings.ts) — split only where the underlying source
// genuinely changes (e.g. a Gospel pericope vs. the homily on it).
// - Nocturn 3 is gated at Duplex-and-higher (plus every Sunday,
// unconditionally) — the user's own choice (2026-08), not the
// historical Rule's own more permissive threshold.
// - The *number* of nocturns (1 vs. 3) is still gated at Duplex-and-higher
// (plus every Sunday, unconditionally) — the user's own choice
// (2026-08), not the historical Rule's own more permissive threshold.
// This is a psalmody-structure decision only; it no longer limits which
// days get patristic reading content authored — a sub-Duplex day's
// single nocturn can and should include patristic/hagiographic content
// from the pool whenever it's been sourced for that day (user, 2026-08:
// "patristic readings for every saint where we can source one, not
// just duplex+").
// - A Gospel reading is sourced from exactly two places: the user's own
// plan (flagged via BiblePlanReading.isGospel — never present on a
// Sunday, a deliberate editorial choice in the user's own plan, not a
@@ -34,10 +48,10 @@
// benedictusCommon elsewhere), so this exclusion falls out of the
// store's own shape rather than needing special-case code.
// - Every commemorated saint (not just the office winner) and every
// active octave gets its own Nocturn 2/3 contribution, when authored —
// "be generous, not winner-takes-all" (user, 2026-08) — mirroring
// getDayCollects's own "one collect per commemoration" pattern, applied
// to readings instead.
// active octave contributes its own reading to the pool, when
// authored — "be generous, not winner-takes-all" (user, 2026-08) —
// mirroring getDayCollects's own "one collect per commemoration"
// pattern, applied to readings instead.
//
// Only a small, growable slice of content is authored so far (one clean
// ferial day, one clean Sunday) — this is the mechanism build, not the
@@ -151,26 +165,14 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
return psalmRefParts(getPsalmsFor('matins', day.weekday));
}
/** Nocturn 1 always the user's own reading plan, on every kind of day
* (see this file's header). Empty when nothing's authored for this
* (temporalId, weekday) yet an honest absence, not a placeholder. */
function nocturn1ReadingParts(temporalId: string, day: LiturgicalDay): ResolvedPart[] {
const readings = getBiblePlanReadings(temporalId, day.weekday);
return readings.map((r) => ({
kind: 'lesson',
text: { text: r.text, status: r.status, citation: r.citation },
nocturn: 1,
isGospel: r.isGospel,
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
}));
}
/** Every id whose own Nocturn 2/3 content should be gathered for `day`
* the office winner (if sanctoral), every commemorated saint, and the
* plain temporal id itself (for an ordinary day's own patristic content,
* e.g. a plain Sunday's Moralia-in-Job-style commentary) deliberately
* inclusive, not just the winner, per the user's own "be generous, not
* winner-takes-all" instruction (2026-08). */
/** 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`
* once `resolveDay` has applied the transfer, so it needs no separate
* lookup here), and the plain temporal id itself (for an ordinary day's own
* patristic content, e.g. a plain Sunday's Moralia-in-Job-style
* commentary) deliberately inclusive, not just the winner, per the
* user's own "be generous, not winner-takes-all" instruction (2026-08). */
function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] {
const ids = new Set<string>();
if (day.winner.kind === 'sanctoral') ids.add(day.winner.id);
@@ -186,25 +188,41 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart {
kind: 'lesson',
text: { text: r.text, status: r.status, citation: r.citation },
label: r.source,
nocturn: r.nocturn,
isGospel: r.isGospel,
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
};
}
/** Nocturns 2-3's patristic/hagiographic/Gospel content for a 3-nocturn
* day gathered from every relevant id (see nocturnReadingIds) plus every
* currently active octave's own already-authored octave-day reading (see
* src/propers/octave-readings.ts built well ahead of this file, never
* wired to anything until now). Filtered to the requested nocturn number. */
function nocturnReadingParts(nocturn: number, day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] {
/** The full pool of readings available for `day` every source that can
* contribute (see this file's header): the user's own scripture-plan
* readings, the office winner's and every commemorated saint's own
* patristic/hagiographic/Gospel content, the plain temporal id's own
* content (e.g. an ordinary Sunday's patristic commentary), and every
* currently active octave's own reading. No source is pinned to a
* particular nocturn `distributeIntoNocturns` slots the whole pool
* across however many nocturns the day's psalmody has, per the user's own
* "assemble everything, then slot it in" instruction (2026-08), a
* deliberate departure from this file's earlier "Nocturn 1 = plan,
* Nocturns 2-3 = patristic" design. Order here is preserved by
* `distributeIntoNocturns`, so it doubles as reading priority: the user's
* own scripture reading first, then each id's authored content in its own
* file order (patristic commentary typically precedes a Gospel+homily
* see data/propers/nocturn-readings/*.yml), then active octaves. */
function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] {
const parts: ResolvedPart[] = [];
for (const r of getBiblePlanReadings(temporalId, day.weekday, date)) {
parts.push({
kind: 'lesson',
text: { text: r.text, status: r.status, citation: r.citation },
isGospel: r.isGospel,
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
});
}
for (const id of nocturnReadingIds(day, temporalId)) {
for (const reading of getNocturnReadings(id)) {
if (reading.nocturn === nocturn) parts.push(nocturnReadingPart(reading));
parts.push(nocturnReadingPart(reading));
}
}
if (nocturn === 2) {
for (const octave of activeOctavesFor(date)) {
const reading = getOctaveReading(octave.id, octave.dayNumber);
if (reading) {
@@ -212,7 +230,6 @@ function nocturnReadingParts(nocturn: number, day: LiturgicalDay, temporalId: st
kind: 'lesson',
text: { text: reading.text, status: reading.status },
label: reading.source,
nocturn: 2,
isGospel: false,
responsory: reading.responsory
? { text: reading.responsory, status: { la: 'verified', en: 'verified' } }
@@ -220,10 +237,28 @@ function nocturnReadingParts(nocturn: number, day: LiturgicalDay, temporalId: st
});
}
}
}
return parts;
}
/** Splits `pool` into `nocturnCount` contiguous, as-even-as-possible
* chunks (ceiling division), preserving pool order within each chunk
* "depending on how many we have" (user, 2026-08), not a fixed count per
* nocturn. A pool of 5 across 3 nocturns lands 2/2/1, not the historical
* fixed lesson-count-per-nocturn scheme. */
function distributeIntoNocturns(pool: ResolvedPart[], nocturnCount: number): ResolvedPart[][] {
const chunks: ResolvedPart[][] = [];
const size = Math.ceil(pool.length / nocturnCount);
for (let i = 0; i < nocturnCount; i++) {
const chunk = pool.slice(i * size, (i + 1) * size);
// Stamp each reading with the nocturn slot it actually landed in —
// only meaningful once distribution has happened, not while the pool
// is still unordered-by-nocturn (see ResolvedPart's own 'lesson'
// variant, `nocturn?: number`).
chunks.push(nocturnCount > 1 ? chunk.map((part) => ({ ...part, nocturn: i + 1 })) : chunk);
}
return chunks;
}
export function resolveOrdo(date: string): ResolvedOrdo {
const day = resolveDay(date);
const winner = resolveOfficeWinner(day);
@@ -234,6 +269,8 @@ export function resolveOrdo(date: string): ResolvedOrdo {
// convention every other per-feast override in this app already uses
// (see hours/resolve-common.ts's getOfficeOverrideId).
const threeNocturns = day.weekday === 'sunday' || isDoubleOrHigher(winner);
const pool = buildReadingPool(day, temporalId, date);
const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1);
const parts: ResolvedPart[] = [
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
@@ -244,15 +281,15 @@ export function resolveOrdo(date: string): ResolvedOrdo {
if (threeNocturns) {
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
parts.push(...nocturn1ReadingParts(temporalId, day));
parts.push(...(nocturn1Readings ?? []));
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
parts.push(...nocturnReadingParts(2, day, temporalId, date));
parts.push(...(nocturn2Readings ?? []));
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
parts.push(...nocturnReadingParts(3, day, temporalId, date));
parts.push(...(nocturn3Readings ?? []));
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
} else {
parts.push(...ferialPsalmody(day));
parts.push(...nocturn1ReadingParts(temporalId, day));
parts.push(...(nocturn1Readings ?? []));
parts.push({ kind: 'chapter', text: resolveCommon('matins-capitulum-ferial') });
}
+38 -20
View File
@@ -1,20 +1,22 @@
// The user's own continuous scripture-reading plan for Matins Nocturn 1 —
// deliberately not the historical per-day lectionary (see hours/matins.ts's
// header and TODO.md's Matins section): a personal year-round reading plan,
// keyed by the same (temporalId, weekday) pair every other content store in
// this project already uses to identify a day, sourced from a TSV the user
// maintains outside this repo. Replaces the historical Nocturn-1 lesson
// count entirely — a *variable* number of readings per day, no RB summer
// contraction (see calendar/temporal-id.ts's resolveTemporalId for the
// id scheme this keys off).
// The user's own continuous scripture-reading plan, one contributor among
// several to Matins' pooled reading list (see hours/matins.ts's header and
// TODO.md's Matins section) — deliberately not the historical per-day
// lectionary. Most rows key off the same (temporalId, weekday) pair every
// other content store in this project already uses to identify a day, but
// the source TSV's Christmastide/Epiphanytide stretch (Dec 25 - Jan 13)
// keys off a fixed calendar date (MM-DD) instead, since that stretch is
// read straight through regardless of which temporal Sunday governs the
// day. The two key types can both apply to the same date (e.g. a Sunday
// after Epiphany landing inside Jan 1-13) — `getBiblePlanReadings` checks
// both and pools whatever each produces, rather than one overriding the
// other.
//
// Only a small, growable subset is authored so far — this is the mechanism
// build, not the full-calendar content pass (~390 rows total in the
// source TSV); see TODO.md for what's deferred. Every entry not yet
// authored here simply resolves to no readings (honest absence, not a
// placeholder) — matins.ts falls back to whatever nocturn-2/3 content
// exists for the day, or renders nothing for Nocturn 1 on a day with
// neither.
// placeholder) — matins.ts's reading pool just has nothing from this
// source that day.
import type { LanguageCode, TranslationStatus } from '../psalter/types';
import { resolvePassages, type ScriptureCitation } from './octave-readings';
import { getResponsoryForBook } from './matins-responsories';
@@ -49,8 +51,13 @@ interface BiblePlanReadingRecord {
}
interface BiblePlanDayRecord {
temporalId: string;
weekday: string;
/** Mutually exclusive with `calendarDate` a row keys off one or the
* other, never both (see this file's header). */
temporalId?: string;
weekday?: string;
/** "MM-DD", for the Dec 25 - Jan 13 stretch that reads straight through
* regardless of which temporal Sunday governs the day. */
calendarDate?: string;
readings: BiblePlanReadingRecord[];
}
@@ -85,9 +92,10 @@ const modules = import.meta.glob<{ default: BiblePlanDayRecord }>('../data/hours
eager: true,
});
const readingsByKey = new Map<string, BiblePlanReading[]>();
const readingsByTemporalKey = new Map<string, BiblePlanReading[]>();
const readingsByCalendarDate = new Map<string, BiblePlanReading[]>();
for (const mod of Object.values(modules)) {
const { temporalId, weekday, readings } = mod.default;
const { temporalId, weekday, calendarDate, readings } = mod.default;
// Cycles independently per book (not per reading) so two same-day
// readings from the same book don't collide on the pool's first entry —
// see matins-responsories.ts's own cycling doc comment.
@@ -98,13 +106,23 @@ for (const mod of Object.values(modules)) {
seenPerBook.set(book, index + 1);
return resolveReading(record, index);
});
readingsByKey.set(`${temporalId}-${weekday}`, resolved);
if (calendarDate) {
readingsByCalendarDate.set(calendarDate, resolved);
} else {
readingsByTemporalKey.set(`${temporalId}-${weekday}`, resolved);
}
}
/** Empty array, not undefined, when nothing's authored for this day yet
/** Every reading this plan contributes to Matins' pooled reading list for
* `date` both the (temporalId, weekday) row and the fixed-calendar-date
* row (if any), pooled together rather than one overriding the other, since
* both can genuinely apply to the same date (see this file's header).
* Empty array, not undefined, when nothing's authored for this day yet
* every caller already treats "no readings" as a valid, renderable state
* (see hours/matins.ts), so there's no separate "missing entirely" signal
* to preserve here the way getOctaveReading needs `undefined` for. */
export function getBiblePlanReadings(temporalId: string, weekday: string): BiblePlanReading[] {
return readingsByKey.get(`${temporalId}-${weekday}`) ?? [];
export function getBiblePlanReadings(temporalId: string, weekday: string, date: string): BiblePlanReading[] {
const byTemporal = readingsByTemporalKey.get(`${temporalId}-${weekday}`) ?? [];
const byCalendarDate = readingsByCalendarDate.get(date.slice(5)) ?? [];
return [...byTemporal, ...byCalendarDate];
}