diff --git a/TODO.md b/TODO.md index 8d664ee..b3e1a2b 100644 --- a/TODO.md +++ b/TODO.md @@ -20,13 +20,6 @@ across every hour and content type in this app. 1. **Mechanism gaps** — a real design/code piece not built yet, blocking any content pass on it from starting: - Easter's own octave — tabled, "a whole different discussion." - - Gospel pericope + paired homily rendered as one part: decided, not yet built. When a - Matins Nocturn 3 reading pairs a Gospel pericope with a patristic homily on it, render them - as two distinct parts, one after the other, with markup that visibly distinguishes pericope - from homily (not concatenated into a single `lesson` text as now). A bible-plan Gospel - reading has no paired homily — it renders as just the pericope, which is correct as-is. - Needs a schema change (splitting `lesson`+`isGospel` into a Gospel part with an optional - homily part) and matching UI treatment in `hour-view.ts`. - Date picker: `day-nav.ts` currently only steps a day at a time (prev/next); there's no way to jump to an arbitrary date without walking there one day at a time or editing the URL. Needs a calendar-grid picker UI — open, not started. @@ -3887,3 +3880,38 @@ Transfiguration, All Saints of the Benedictine Order). The same session also clo pass touched (see "Matins hymn gap closed for the same categories/saints" elsewhere in this file) — Matins hymns are a different mechanism from the Lauds/Vespers bundle and needed their own pass. + +### Gospel pericope + paired homily rendered as one part — done (2026-08-28) + +Closed the last open Matins mechanism gap. `ResolvedPart` gained its own `'gospel'` kind +(`src/hours/types.ts`), replacing the old `lesson`+`isGospel` flag entirely — a Gospel pericope +now carries an optional nested `homily` (`{ source?, text }`) instead of being a same-kind +sibling `lesson` part next to it. + +The reason for nesting rather than keeping two adjacent pool entries: `distributeIntoNocturns`'s +front-light 1/1/rest slicing (see "Reading distribution" elsewhere in this file) splits the +reading pool by raw index, with no notion that two adjacent entries belong together — a Gospel +pericope and its own homily, sourced as two separate array entries in +`data/propers/nocturn-readings/*.yml`, could land in different nocturns purely by where they +fell in the pool. Fixed at the pool-assembly step instead of patching the slicer: +`buildReadingPool` (`src/hours/matins.ts`) now detects an `isGospel: true` NocturnReading +immediately followed by a same-nocturn `isGospel: false` one and folds the pair into a single +`gospel` pool entry before it ever reaches `distributeIntoNocturns` — the pairing is structural +(one array element), not a convention the slicer has to know about. A bible-plan Gospel reading +(never paired with a homily, per the user's own plan) becomes a bare `gospel` part with no +`homily`, so it still gets the same "Gospel" UI treatment. + +One real data wrinkle surfaced, not touched: `chair-of-st-peter-at-rome.yml`'s Nocturn 3 has the +homily prose already folded into the same `isGospel: true` entry's own `text` (an earlier, less +clean authoring convention flagged in this file's own SanctiM-pass notes) rather than as a +separate following reading — that file is left as-is per the earlier decision not to "fix" it; +the new `'gospel'` type's `source` field (distinct from `homily`) exists specifically to carry +that entry's own attribution without misrepresenting it as a separate homily reading. + +`hour-view.ts` renders the pericope with a "Gospel" heading, then the nested homily (when +present) directly underneath in the same section, visually set off by a top border +(`.ordo-part-homily` in `styles.css`) — one section, pericope then homily, rather than two +separate `ordo-part` blocks that could be visually or structurally pulled apart. +`tests/hours/matins.test.ts`'s Sunday proof (2026-09-06, Luke 7:11-16 + St. Augustine's homily) +updated to assert against the new `gospel`/`homily` shape instead of two `isGospel`-flagged +lessons. `npm test`/`npm run build` both pass. diff --git a/src/hours/matins.ts b/src/hours/matins.ts index a49ae87..e328c4d 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -515,11 +515,30 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, label: r.source, - isGospel: r.isGospel, responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, }; } +/** Builds a single atomic 'gospel' pool entry from a Gospel-flagged + * NocturnReading, folding in `homily` (the immediately following reading in + * the same array, when it's a genuine separate homily on this pericope — + * see `buildReadingPool`'s own pairing check) as a nested field rather than + * a second pool entry. This is what makes the pairing structural: a single + * ResolvedPart can't be split across two nocturns by `distributeIntoNocturns`, + * whereas two adjacent pool entries could be (and, before this, sometimes + * were). */ +function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined): ResolvedPart { + return { + kind: 'gospel', + text: { text: r.text, status: r.status, citation: r.citation }, + source: r.source, + responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, + homily: homily + ? { source: homily.source, text: { text: homily.text, status: homily.status, citation: homily.citation } } + : undefined, + }; +} + /** 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 @@ -538,12 +557,16 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart { 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, - }); + const responsory = r.responsory ? { text: r.responsory, status: { la: 'verified' as const, en: 'verified' as const } } : undefined; + // The user's own reading plan never pairs a Gospel with a homily (see + // this file's own header) — a bare pericope, still its own 'gospel' + // kind so it gets the same distinguishing UI treatment as a proper + // Gospel+homily. + parts.push( + r.isGospel + ? { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, responsory } + : { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory }, + ); } // Grouped by the reading's own `nocturn` tag (ascending), not by which id // contributed it — a later post-Pentecost Sunday's Nocturn 2 now comes @@ -558,9 +581,25 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): const ids = nocturnReadingIds(day, temporalId, date); const byNocturn = new Map(); for (const id of ids) { - for (const reading of getNocturnReadings(id)) { + const readings = getNocturnReadings(id); + for (let i = 0; i < readings.length; i++) { + const reading = readings[i]!; const bucket = byNocturn.get(reading.nocturn) ?? []; - bucket.push(nocturnReadingPart(reading)); + if (reading.isGospel) { + // A genuine separate homily on this pericope is the very next + // reading in the same file, in the same nocturn, not itself flagged + // as a Gospel — the established authoring convention (see + // src/propers/nocturn-readings.ts's own header and this file's + // 'gospel' ResolvedPart doc comment). When found, fold it in and + // skip it as its own pool entry so the pair can never be split + // apart by distributeIntoNocturns. + const next = readings[i + 1]; + const homily = next && !next.isGospel && next.nocturn === reading.nocturn ? next : undefined; + bucket.push(gospelReadingPart(reading, homily)); + if (homily) i++; + } else { + bucket.push(nocturnReadingPart(reading)); + } byNocturn.set(reading.nocturn, bucket); } } @@ -574,7 +613,6 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): 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, diff --git a/src/hours/types.ts b/src/hours/types.ts index 1abe45b..f2bb2e0 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -247,24 +247,45 @@ export type ResolvedPart = // readings' own `source` is a genuine bilingual attribution (their // English title is a real translation of a Latin incipit, not an // editorial description written once) so `label` accepts either shape — - // see hour-view.ts's renderLessonLabel. `isGospel` flags a reading - // as a Gospel pericope (from either the user's own continuous-reading - // plan or the day's own proper Gospel+homily — never a Common-of-Saints - // fallback Gospel, which this app deliberately excludes for Matins, see - // hours/matins.ts) rather than a distinct part kind, since a Gospel - // reading is still fundamentally a lesson — just one worth marking. - // `responsory` is the short responsory said after the reading, when one - // was sourced (matched loosely by scriptural book for the user's own - // plan, live-queried directly for patristic/hagiographic readings) — - // absent, not fabricated, when none could be sourced. + // see hour-view.ts's renderLessonLabel. `responsory` is the short + // responsory said after the reading, when one was sourced (matched + // loosely by scriptural book for the user's own plan, live-queried + // directly for patristic/hagiographic readings) — absent, not + // fabricated, when none could be sourced. A Gospel pericope is never + // rendered with this kind — see 'gospel' below. | { kind: 'lesson'; text: ResolvedText; label?: string | Partial>; nocturn?: number; source?: string; - isGospel?: boolean; responsory?: ResolvedText; + } + // Matins only. A Gospel pericope, from either the user's own continuous + // reading plan (no paired homily) or the day's own genuine proper + // Gospel+homily (src/propers/nocturn-readings.ts — never a + // Common-of-Saints fallback Gospel, which this app deliberately excludes + // for Matins, see hours/matins.ts). Split out from the generic 'lesson' + // kind (2026-08-28) so a Gospel and its paired homily can be modeled as + // one atomic part: when the source pairs a pericope with a patristic + // homily on it as two adjacent readings, hours/matins.ts's + // buildReadingPool combines them into a single 'gospel' part with + // `homily` nested, rather than two separate pool entries — this is what + // guarantees distributeIntoNocturns can never split a Gospel from its + // own homily across two different nocturns; the pool's earlier + // front-light 1/1/rest slicing could otherwise do exactly that. `source` + // is set only for the rare case where the pericope's own text already + // has homily prose folded into it at the data layer (an earlier, less + // clean authoring convention — see chair-of-st-peter-at-rome.yml's own + // header) — a genuinely separate homily reading, when authored, is + // `homily` instead. + | { + kind: 'gospel'; + text: ResolvedText; + nocturn?: number; + source?: string; + responsory?: ResolvedText; + homily?: { source?: string; text: ResolvedText }; }; export interface ResolvedVerse { diff --git a/src/ui/hour-view.ts b/src/ui/hour-view.ts index 082670f..5e6df00 100644 --- a/src/ui/hour-view.ts +++ b/src/ui/hour-view.ts @@ -91,13 +91,41 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string { `; case 'lesson': return ` -
- ${part.isGospel ? '

Gospel

' : renderLessonLabel(part.label, languages)} +
+ ${renderLessonLabel(part.label, languages)} ${renderCitation(part.text)} ${renderColumns(part.text, languages)} ${part.responsory ? `
${renderColumns(part.responsory, languages)}
` : ''}
`; + // A Gospel pericope, with its paired patristic homily (when authored) + // nested right after it in the same section rather than as a separate + // pool entry — see hours/matins.ts's buildReadingPool, which combines + // the two into one atomic part specifically so they can never end up in + // different nocturns. `part.source` (rare — see the 'gospel' type's own + // doc comment) is homily attribution already folded into the pericope's + // own text at the data layer; `part.homily`, when present, is a + // genuinely separate reading, rendered with its own label underneath. + case 'gospel': + return ` +
+

Gospel

+ ${renderCitation(part.text)} + ${renderColumns(part.text, languages)} + ${part.source ? `

${escapeHtml(part.source)}

` : ''} + ${part.responsory ? `
${renderColumns(part.responsory, languages)}
` : ''} + ${ + part.homily + ? ` +
+ ${renderLessonLabel(part.homily.source, languages)} + ${renderColumns(part.homily.text, languages)} +
+ ` + : '' + } +
+ `; case 'psalm': { // Always cite the full range, not just the psalm number — a 4-verse // slice of Psalm 118 is a different citation from the whole psalm. diff --git a/src/ui/styles.css b/src/ui/styles.css index 8bb1054..2d9d539 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -307,6 +307,17 @@ button:focus-visible { margin: 0 0 var(--space-1); } +.ordo-part-gospel { + border-left: 2px solid var(--color-brass); + padding-left: var(--space-2); +} + +.ordo-part-homily { + margin-top: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--color-brass); +} + .psalm-verses { list-style: none; margin: 0; diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index a69e458..d4d49a3 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -146,35 +146,33 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => { expect(citations).toContain('Sir 45'); }); - it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily, both flagged correctly', () => { - const lessons = ordo.parts.filter( - (p) => p.kind === 'lesson', - ) as { label?: string; isGospel?: boolean; text: { text: { la?: string } } }[]; + it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily as one atomic gospel part', () => { + const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { label?: string }[]; const gregory = lessons.find((l) => l.label?.includes('Gregory')); expect(gregory).toBeDefined(); - expect(gregory?.isGospel).toBe(false); - const gospel = lessons.find((l) => l.isGospel); - expect(gospel).toBeDefined(); - expect(gospel?.text.text.la).toContain('Naim'); - - const homily = lessons.find((l) => l.label?.includes('Augustine')); - expect(homily).toBeDefined(); - expect(homily?.isGospel).toBe(false); + const gospels = ordo.parts.filter((p) => p.kind === 'gospel') as { + text: { text: { la?: string } }; + homily?: { source?: string; text: { text: { la?: string } } }; + }[]; + expect(gospels).toHaveLength(1); + const gospel = gospels[0]!; + expect(gospel.text.text.la).toContain('Naim'); + expect(gospel.homily?.source).toContain('Augustine'); + expect(gospel.homily?.text.text.la).toBeTruthy(); }); - it('never sources a Gospel from a Common-of-Saints fallback — every Gospel-flagged reading traces to the bible-plan or a real proper', () => { - const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { isGospel?: boolean; label?: string }[]; - const gospels = lessons.filter((l) => l.isGospel); + it('never sources a Gospel from a Common-of-Saints fallback — the only gospel part traces to the bible-plan or a real proper', () => { + const gospels = ordo.parts.filter((p) => p.kind === 'gospel'); // This Sunday's own TSV row deliberately has no Gospel (confirmed - // editorial choice) — the only Gospel-flagged reading should be the - // day's own proper Nocturn 3 Gospel, not a substituted Common one. + // editorial choice) — the only gospel part should be the day's own + // proper Nocturn 3 Gospel, not a substituted Common one. expect(gospels).toHaveLength(1); }); - it('orders Te Deum after every reading, and the day collect last', () => { + it('orders Te Deum after every reading (lesson or gospel), and the day collect last', () => { const teDeumIndex = ordo.parts.findIndex((p) => p.kind === 'te-deum'); - const lastLessonIndex = ordo.parts.map((p) => p.kind).lastIndexOf('lesson'); + const lastLessonIndex = ordo.parts.map((p) => p.kind).map((k, i) => ((k === 'lesson' || k === 'gospel') ? i : -1)).reduce((a, b) => Math.max(a, b), -1); const prayerIndex = ordo.parts.findIndex((p) => p.kind === 'prayer'); expect(teDeumIndex).toBeGreaterThan(lastLessonIndex); expect(prayerIndex).toBeGreaterThan(teDeumIndex);