Compare commits
2 Commits
4fdddbf4a2
...
25934865b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 25934865b1 | |||
| 6a17a74b9b |
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
+18
-6
@@ -1,6 +1,7 @@
|
|||||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
|
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
|
||||||
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
||||||
import { resolveEveningDay } from '../calendar/vespers';
|
import { resolveEveningDay } from '../calendar/vespers';
|
||||||
|
import { weekdayOf } from '../calendar/weekday';
|
||||||
import { getDayLabel } from '../calendar/day-label';
|
import { getDayLabel } from '../calendar/day-label';
|
||||||
import { getPsalmVerses } from '../psalter';
|
import { getPsalmVerses } from '../psalter';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
@@ -70,9 +71,16 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
|
|||||||
/** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm
|
/** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm
|
||||||
* the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4
|
* the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4
|
||||||
* groups in sequence. No per-feast override mechanism yet — see
|
* groups in sequence. No per-feast override mechanism yet — see
|
||||||
* hours/types.ts's 'vespers-psalmody' doc comment. */
|
* hours/types.ts's 'vespers-psalmody' doc comment. Deliberately keyed off
|
||||||
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
|
* `weekday` (today's actual calendar weekday), not `day.weekday` — `day`
|
||||||
const wd = vespersAntiphons[day.weekday];
|
* may be tomorrow's identity when tonight anticipates First Vespers (see
|
||||||
|
* resolveEveningDay), but the psalm cycle has no per-feast override and
|
||||||
|
* must stay on today's place in the fixed 6-day rotation regardless of
|
||||||
|
* whose office governs the evening's propers. `day` is still threaded
|
||||||
|
* into psalmParts for antiphon-fullness resolution, which does care who
|
||||||
|
* governs tonight. */
|
||||||
|
function resolvePsalmody(day: LiturgicalDay, weekday: Weekday): ResolvedPart[] {
|
||||||
|
const wd = vespersAntiphons[weekday];
|
||||||
return wd.groups.flatMap((group) => psalmParts(group, day));
|
return wd.groups.flatMap((group) => psalmParts(group, day));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,12 +162,12 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
function resolvePart(part: HourPart, day: LiturgicalDay, weekday: Weekday): ResolvedPart[] {
|
||||||
switch (part.kind) {
|
switch (part.kind) {
|
||||||
case 'opening-versicle':
|
case 'opening-versicle':
|
||||||
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
||||||
case 'vespers-psalmody':
|
case 'vespers-psalmody':
|
||||||
return resolvePsalmody(day);
|
return resolvePsalmody(day, weekday);
|
||||||
case 'vespers-office':
|
case 'vespers-office':
|
||||||
return resolveOffice(day);
|
return resolveOffice(day);
|
||||||
case 'magnificat': {
|
case 'magnificat': {
|
||||||
@@ -216,6 +224,10 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
|||||||
// vespers.ts) — getDayCollects picks that up for free, no special
|
// vespers.ts) — getDayCollects picks that up for free, no special
|
||||||
// handling needed here.
|
// handling needed here.
|
||||||
const day = resolveEveningDay(date);
|
const day = resolveEveningDay(date);
|
||||||
const parts = vespersDefinition.parts.flatMap((part) => resolvePart(part, day));
|
// Today's own weekday, independent of the possibly-anticipated `day`
|
||||||
|
// above — see resolvePsalmody's doc comment for why psalm selection
|
||||||
|
// must not follow the evening's borrowed identity.
|
||||||
|
const weekday = weekdayOf(date);
|
||||||
|
const parts = vespersDefinition.parts.flatMap((part) => resolvePart(part, day, weekday));
|
||||||
return { hourId: 'vespers', date, parts, dayLabel: getDayLabel(day) };
|
return { hourId: 'vespers', date, parts, dayLabel: getDayLabel(day) };
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-2
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -235,6 +235,20 @@ describe('resolveOrdo("vespers", ...)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps ferial psalmody on the actual calendar weekday even while the office anticipates tomorrow\'s First Vespers', () => {
|
||||||
|
// 2026-08-25 is a Tuesday, but its evening anticipates St. Zephyrinus's
|
||||||
|
// First Vespers (Aug 26) per the test just above -- the psalm cycle has
|
||||||
|
// no per-feast override (see hours/vespers.ts's resolvePsalmody), so it
|
||||||
|
// must stay on Tuesday's own group [129, 130, 131, 132], not slip to
|
||||||
|
// Wednesday's [134, 135, 136, 137] just because the office borrowed
|
||||||
|
// Wednesday's identity.
|
||||||
|
const ordo = resolveOrdo('vespers', '2026-08-25');
|
||||||
|
const psalmNumbers = ordo.parts
|
||||||
|
.filter((p) => p.kind === 'psalm')
|
||||||
|
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||||
|
expect(psalmNumbers).toEqual([129, 130, 131, 132]);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses Common-of-Several-Martyrs' real office bundle on Ss. Placid and Companions' own day (Duplex II. classis, keeps own Vespers)", () => {
|
it("uses Common-of-Several-Martyrs' real office bundle on Ss. Placid and Companions' own day (Duplex II. classis, keeps own Vespers)", () => {
|
||||||
// Live-verified against Divinum Officium (Monastic Tridentinum 1617)
|
// Live-verified against Divinum Officium (Monastic Tridentinum 1617)
|
||||||
// votive=C3 -- see vespers-hymn-common-of-several-martyrs.yml's header.
|
// votive=C3 -- see vespers-hymn-common-of-several-martyrs.yml's header.
|
||||||
|
|||||||
Reference in New Issue
Block a user