From 2cc856ed9243f5d3d54d3b322ff75ec1bfe79047 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sun, 16 Aug 2026 07:06:23 -0400 Subject: [PATCH] Rework Matins readings into a pooled, distributed model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TODO.md | 92 +++++++++++++++++++++- src/hours/matins.ts | 155 +++++++++++++++++++++++--------------- src/propers/bible-plan.ts | 58 +++++++++----- 3 files changed, 223 insertions(+), 82 deletions(-) diff --git a/TODO.md b/TODO.md index 01d3a16..64ad5d3 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 4c569b6..3106e48 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -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(); if (day.winner.kind === 'sanctoral') ids.add(day.winner.id); @@ -186,44 +188,77 @@ 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) { - parts.push({ - kind: 'lesson', - text: { text: reading.text, status: reading.status }, - label: reading.source, - nocturn: 2, - isGospel: false, - responsory: reading.responsory - ? { text: reading.responsory, status: { la: 'verified', en: 'verified' } } - : undefined, - }); - } + for (const octave of activeOctavesFor(date)) { + const reading = getOctaveReading(octave.id, octave.dayNumber); + if (reading) { + parts.push({ + kind: 'lesson', + text: { text: reading.text, status: reading.status }, + label: reading.source, + isGospel: false, + responsory: reading.responsory + ? { text: reading.responsory, status: { la: 'verified', en: 'verified' } } + : undefined, + }); } } 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') }); } diff --git a/src/propers/bible-plan.ts b/src/propers/bible-plan.ts index 3521657..7898153 100644 --- a/src/propers/bible-plan.ts +++ b/src/propers/bible-plan.ts @@ -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(); +const readingsByTemporalKey = new Map(); +const readingsByCalendarDate = new Map(); 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]; }