Model a Matins Gospel pericope and its paired homily as one atomic part
Deploy / deploy (push) Successful in 1m25s

Replaces the lesson+isGospel flag with a dedicated 'gospel' ResolvedPart
carrying an optional nested homily. Previously a Gospel pericope and its
patristic homily were two separate pool entries, which the front-light
1/1/rest nocturn slicer could split into different nocturns purely by
index. buildReadingPool now folds an isGospel:true reading and its
immediately-following same-nocturn homily into a single pool entry, so
the pairing is structural rather than a convention the slicer has to
honor. hour-view.ts renders the pair as one visually grouped section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1LqodVbQdQVoTGPz9sSw4
This commit is contained in:
2026-08-28 19:46:48 -04:00
parent 6a17a74b9b
commit 25934865b1
6 changed files with 173 additions and 49 deletions
+35 -7
View File
@@ -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 1. **Mechanism gaps** — a real design/code piece not built yet, blocking any content pass on
it from starting: it from starting:
- Easter's own octave — tabled, "a whole different discussion." - 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 - 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 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. 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 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 file) — Matins hymns are a different mechanism from the Lauds/Vespers bundle and needed their
own pass. 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.
+48 -10
View File
@@ -515,11 +515,30 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart {
kind: 'lesson', kind: 'lesson',
text: { text: r.text, status: r.status, citation: r.citation }, text: { text: r.text, status: r.status, citation: r.citation },
label: r.source, label: r.source,
isGospel: r.isGospel,
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, 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 /** The full pool of readings available for `day` — every source that can
* contribute (see this file's header): the user's own scripture-plan * contribute (see this file's header): the user's own scripture-plan
* readings, the office winner's and every commemorated saint's own * 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[] { function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] {
const parts: ResolvedPart[] = []; const parts: ResolvedPart[] = [];
for (const r of getBiblePlanReadings(temporalId, day.weekday, date)) { for (const r of getBiblePlanReadings(temporalId, day.weekday, date)) {
parts.push({ const responsory = r.responsory ? { text: r.responsory, status: { la: 'verified' as const, en: 'verified' as const } } : undefined;
kind: 'lesson', // The user's own reading plan never pairs a Gospel with a homily (see
text: { text: r.text, status: r.status, citation: r.citation }, // this file's own header) — a bare pericope, still its own 'gospel'
isGospel: r.isGospel, // kind so it gets the same distinguishing UI treatment as a proper
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, // 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 // 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 // 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 ids = nocturnReadingIds(day, temporalId, date);
const byNocturn = new Map<number, ResolvedPart[]>(); const byNocturn = new Map<number, ResolvedPart[]>();
for (const id of ids) { 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) ?? []; 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); byNocturn.set(reading.nocturn, bucket);
} }
} }
@@ -574,7 +613,6 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string):
kind: 'lesson', kind: 'lesson',
text: { text: reading.text, status: reading.status }, text: { text: reading.text, status: reading.status },
label: reading.source, label: reading.source,
isGospel: false,
responsory: reading.responsory responsory: reading.responsory
? { text: reading.responsory, status: { la: 'verified', en: 'verified' } } ? { text: reading.responsory, status: { la: 'verified', en: 'verified' } }
: undefined, : undefined,
+32 -11
View File
@@ -247,24 +247,45 @@ export type ResolvedPart =
// readings' own `source` is a genuine bilingual attribution (their // readings' own `source` is a genuine bilingual attribution (their
// English title is a real translation of a Latin incipit, not an // English title is a real translation of a Latin incipit, not an
// editorial description written once) so `label` accepts either shape — // editorial description written once) so `label` accepts either shape —
// see hour-view.ts's renderLessonLabel. `isGospel` flags a reading // see hour-view.ts's renderLessonLabel. `responsory` is the short
// as a Gospel pericope (from either the user's own continuous-reading // responsory said after the reading, when one was sourced (matched
// plan or the day's own proper Gospel+homily — never a Common-of-Saints // loosely by scriptural book for the user's own plan, live-queried
// fallback Gospel, which this app deliberately excludes for Matins, see // directly for patristic/hagiographic readings) — absent, not
// hours/matins.ts) rather than a distinct part kind, since a Gospel // fabricated, when none could be sourced. A Gospel pericope is never
// reading is still fundamentally a lesson — just one worth marking. // rendered with this kind — see 'gospel' below.
// `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.
| { | {
kind: 'lesson'; kind: 'lesson';
text: ResolvedText; text: ResolvedText;
label?: string | Partial<Record<string, string>>; label?: string | Partial<Record<string, string>>;
nocturn?: number; nocturn?: number;
source?: string; source?: string;
isGospel?: boolean;
responsory?: ResolvedText; 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 { export interface ResolvedVerse {
+30 -2
View File
@@ -91,13 +91,41 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
`; `;
case 'lesson': case 'lesson':
return ` return `
<section class="ordo-part ordo-part-lesson${part.isGospel ? ' ordo-part-lesson-gospel' : ''}"> <section class="ordo-part ordo-part-lesson">
${part.isGospel ? '<h3 class="ordo-part-label">Gospel</h3>' : renderLessonLabel(part.label, languages)} ${renderLessonLabel(part.label, languages)}
${renderCitation(part.text)} ${renderCitation(part.text)}
${renderColumns(part.text, languages)} ${renderColumns(part.text, languages)}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''} ${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
</section> </section>
`; `;
// 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 `
<section class="ordo-part ordo-part-lesson ordo-part-gospel">
<h3 class="ordo-part-label">Gospel</h3>
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
${part.source ? `<p class="ordo-part-citation">${escapeHtml(part.source)}</p>` : ''}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
${
part.homily
? `
<div class="ordo-part-homily">
${renderLessonLabel(part.homily.source, languages)}
${renderColumns(part.homily.text, languages)}
</div>
`
: ''
}
</section>
`;
case 'psalm': { case 'psalm': {
// Always cite the full range, not just the psalm number — a 4-verse // 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. // slice of Psalm 118 is a different citation from the whole psalm.
+11
View File
@@ -307,6 +307,17 @@ button:focus-visible {
margin: 0 0 var(--space-1); 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 { .psalm-verses {
list-style: none; list-style: none;
margin: 0; margin: 0;
+17 -19
View File
@@ -146,35 +146,33 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => {
expect(citations).toContain('Sir 45'); expect(citations).toContain('Sir 45');
}); });
it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily, both flagged correctly', () => { 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( const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { label?: string }[];
(p) => p.kind === 'lesson',
) as { label?: string; isGospel?: boolean; text: { text: { la?: string } } }[];
const gregory = lessons.find((l) => l.label?.includes('Gregory')); const gregory = lessons.find((l) => l.label?.includes('Gregory'));
expect(gregory).toBeDefined(); expect(gregory).toBeDefined();
expect(gregory?.isGospel).toBe(false);
const gospel = lessons.find((l) => l.isGospel); const gospels = ordo.parts.filter((p) => p.kind === 'gospel') as {
expect(gospel).toBeDefined(); text: { text: { la?: string } };
expect(gospel?.text.text.la).toContain('Naim'); homily?: { source?: string; text: { text: { la?: string } } };
}[];
const homily = lessons.find((l) => l.label?.includes('Augustine')); expect(gospels).toHaveLength(1);
expect(homily).toBeDefined(); const gospel = gospels[0]!;
expect(homily?.isGospel).toBe(false); 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', () => { 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 lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { isGospel?: boolean; label?: string }[]; const gospels = ordo.parts.filter((p) => p.kind === 'gospel');
const gospels = lessons.filter((l) => l.isGospel);
// This Sunday's own TSV row deliberately has no Gospel (confirmed // This Sunday's own TSV row deliberately has no Gospel (confirmed
// editorial choice) — the only Gospel-flagged reading should be the // editorial choice) — the only gospel part should be the day's own
// day's own proper Nocturn 3 Gospel, not a substituted Common one. // proper Nocturn 3 Gospel, not a substituted Common one.
expect(gospels).toHaveLength(1); 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 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'); const prayerIndex = ordo.parts.findIndex((p) => p.kind === 'prayer');
expect(teDeumIndex).toBeGreaterThan(lastLessonIndex); expect(teDeumIndex).toBeGreaterThan(lastLessonIndex);
expect(prayerIndex).toBeGreaterThan(teDeumIndex); expect(prayerIndex).toBeGreaterThan(teDeumIndex);