Label unattributed hagiographic Matins readings by saint, not "Reading"
Deploy / deploy (push) Successful in 1m54s

A saint's vita reading (Nocturn 2, no patristic `source` attribution)
fell through to the UI's generic "Reading" heading. Add
fallbackHagiographicLabel, which looks up the contributing id's own
saint/temporal-feast record and produces "On St. Pius X, Pope and
Confessor" style labels instead — same plain-string convention as an
authored `source`.

Updates two matins.test.ts assertions that used label shape (string
vs object) as a proxy for "not a bible-plan reading" — no longer
reliable now that vita readings also carry a string label, so they
key off citation presence instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGjUyhUZJaSjiniEmnLdak
This commit is contained in:
2026-09-03 16:16:46 -04:00
parent 34c585726d
commit d8bb32bf45
2 changed files with 52 additions and 23 deletions
+20 -3
View File
@@ -91,6 +91,7 @@ import {
substituteName, substituteName,
} from './resolve-common'; } from './resolve-common';
import { getSaintRecord } from '../calendar/feasts'; import { getSaintRecord } from '../calendar/feasts';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { getBiblePlanReadings } from '../propers/bible-plan'; import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings'; import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
import { getOctaveReading } from '../propers/octave-readings'; import { getOctaveReading } from '../propers/octave-readings';
@@ -675,11 +676,27 @@ function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string,
return [...ids]; return [...ids];
} }
function nocturnReadingPart(r: NocturnReading): ResolvedPart { /** Fallback label for a hagiographic/vita reading that carries no authored
* `source` attribution (unlike a patristic homily, a saint's vita rarely
* has one in the reference engine) — "On St. Pius X, Pope and Confessor",
* from the same saint/temporal-feast record that supplies the day's own
* display name. Same plain-string, non-bilingual convention as an authored
* `source` (see NocturnReading.source's own doc comment): displayed as-is
* in both language columns rather than split into {la, en}, since neither
* the record's `name` nor `nameLa` reliably declines into the "on ___"
* phrasing this needs. Undefined when `id` matches neither record (e.g. a
* plain temporal id with no TemporalFeastRecord of its own). */
function fallbackHagiographicLabel(id: string): string | undefined {
const name = getSaintRecord(id)?.name ?? getTemporalFeastRecord(id)?.name;
if (!name) return undefined;
return name.startsWith('The ') ? `On the ${name.slice(4)}` : `On ${name}`;
}
function nocturnReadingPart(r: NocturnReading, id: string): ResolvedPart {
return { return {
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 ?? fallbackHagiographicLabel(id),
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
}; };
} }
@@ -771,7 +788,7 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string,
bucket.push(gospelReadingPart(reading, homily)); bucket.push(gospelReadingPart(reading, homily));
if (homily) i++; if (homily) i++;
} else { } else {
bucket.push(nocturnReadingPart(reading)); bucket.push(nocturnReadingPart(reading, id));
} }
byNocturn.set(reading.nocturn, bucket); byNocturn.set(reading.nocturn, bucket);
} }
+32 -20
View File
@@ -73,25 +73,27 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
}); });
it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => { it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => {
// Filtered to the user's own bible-plan lessons only: since the // Filtered to the user's own bible-plan lessons only, by the one thing
// temporal-cycle nocturn-readings sweep authored advent-1.yml // that's unique to them: a real citation. Since the temporal-cycle
// (2026-08), this same date's Nocturn 2/3 pool also gains two labeled // nocturn-readings sweep authored advent-1.yml (2026-08), this same
// (patristic) lesson entries alongside the user's own bible-plan // date's Nocturn 2/3 pool also gains two labeled (patristic) lesson
// readings — a real, separate content source this test isn't about. // entries alongside the user's own bible-plan readings — a real,
// Bible-plan lessons carry a generated bilingual incipit `label` // separate content source this test isn't about. (A label-shape filter
// object (bible-book-incipits.ts); patristic ones carry a plain-string // no longer distinguishes them: since 2026-09-03 an uncited
// `label` (nocturnReadingPart's own `r.source` attribution) — that // hagiographic/vita reading also gets a plain-string fallback label —
// shape difference, not label presence/absence, is what distinguishes // see matins.ts's fallbackHagiographicLabel — same shape as a
// them since 2026-09-03. // patristic reading's own `source` attribution.)
const lessons = ordo.parts.filter((p) => p.kind === 'lesson' && typeof p.label !== 'string'); const lessons = ordo.parts.filter(
(p) => p.kind === 'lesson' && (p as { text: { citation?: { en?: string } } }).text.citation?.en !== undefined,
) as { text: { citation?: { en?: string }; status: { en?: string } } }[];
// Plain 2 bible-plan readings on this particular clean ferial (no // Plain 2 bible-plan readings on this particular clean ferial (no
// commemorated saint here) — see the next test for the 3-reading case. // commemorated saint here) — see the next test for the 3-reading case.
expect(lessons).toHaveLength(2); expect(lessons).toHaveLength(2);
const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en); const citations = lessons.map((l) => l.text.citation?.en);
expect(citations).toEqual(['Isa 6-7', 'Sap 2']); expect(citations).toEqual(['Isa 6-7', 'Sap 2']);
// The Vulgate/Douay-Rheims bulk import (2026-09-03) landed the real // The Vulgate/Douay-Rheims bulk import (2026-09-03) landed the real
// text for these passages. // text for these passages.
expect((lessons[0] as { text: { status: { en?: string } } }).text.status.en).toBe('verified'); expect(lessons[0]?.text.status.en).toBe('verified');
}); });
it("adds a 3rd reading for a commemorated saint, generously surfaced per the 'commemorations get their own reading' design (see hours/matins.ts's own header)", () => { it("adds a 3rd reading for a commemorated saint, generously surfaced per the 'commemorations get their own reading' design (see hours/matins.ts's own header)", () => {
@@ -103,13 +105,23 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
// Semiduplex threshold). 2025-12-02, a Tuesday, keeps the same 9-psalm // Semiduplex threshold). 2025-12-02, a Tuesday, keeps the same 9-psalm
// ferial shape as the other tests in this block. // ferial shape as the other tests in this block.
const commemoratedOrdo = resolveOrdo('matins', '2025-12-02'); const commemoratedOrdo = resolveOrdo('matins', '2025-12-02');
// Same bible-plan-lesson filter as the test above — advent-1.yml's const lessons = commemoratedOrdo.parts.filter((p) => p.kind === 'lesson') as {
// patristic content also pools into this date. text: { citation?: { en?: string }; status: { en?: string } };
const lessons = commemoratedOrdo.parts.filter((p) => p.kind === 'lesson' && typeof p.label !== 'string'); label?: string | Record<string, string>;
expect(lessons).toHaveLength(3); }[];
const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en); // Same citation-based bible-plan filter as the test above —
expect(citations).toEqual(['Isa 6-7', 'Sap 2', undefined]); // advent-1.yml's patristic content also pools into this date.
expect((lessons[0] as { text: { status: { en?: string } } }).text.status.en).toBe('verified'); const bookLessons = lessons.filter((l) => l.text.citation?.en !== undefined);
expect(bookLessons).toHaveLength(2);
expect(bookLessons.map((l) => l.text.citation?.en)).toEqual(['Isa 6-7', 'Sap 2']);
expect(bookLessons[0]?.text.status.en).toBe('verified');
// St. Bibiana's own proper reading (no citation of her own — it's a
// continuous vita, not a scripture pericope) — the actual 3rd reading
// this test is about. She carries no authored `source` either, so she
// gets the "On St. Bibiana..." fallback label.
const bibiana = lessons.find((l) => typeof l.label === 'string' && l.label.includes('Bibiana'));
expect(bibiana).toBeDefined();
expect(bibiana?.text.citation).toBeUndefined();
}); });
it("matches the first reading (Isaiah) against the seeded per-book responsory pool", () => { it("matches the first reading (Isaiah) against the seeded per-book responsory pool", () => {