Add per-feast overrides to Prime/Terce/Sext/None; fill Lauds' own Lawrence office bundle
Deploy / deploy (push) Successful in 51s
Deploy / deploy (push) Successful in 51s
Prime/Terce/Sext/None had no per-feast override mechanism at all before this -- their psalm antiphon always fell back to the plain weekday default and their chapter was always a single fixed per-annum/per-weekday text, regardless of what was actually being celebrated, even on a saint's own actual feast day (not just during an octave). Confirmed live (Monastic Tridentinum 1617) that St. Lawrence has his own real antiphon and chapter at every one of these hours, both on his own day and throughout his octave. New shared mechanism in hours/resolve-common.ts: getMinorHourOverrideId (duplex-majus+ threshold, kept in sync with Lauds' own so it's one shared backlog), resolveMinorHourAntiphon, resolveMinorHourChapter -- all resolveOfficeWinner-aware, so an octave day gets the same override as the feast's own actual day. Prime additionally needed isSundayOrFeastOffice (its capitulum/Preces Sunday-vs-ferial choice) made octave-aware -- live-verified Prime's capitulum stays the Sunday/feast form throughout the octave too, so no new file was needed there, just reaching the existing one correctly. Also fills in Lauds' own still-missing Lawrence office bundle (capitulum/responsory/hymn/versicle) -- flagged in TODO.md as worth fixing "even before tackling the other 32" and still not done until now. Authored real content for St. Lawrence across all five hours from live queries. The same ~31-saint backlog (TODO.md) now applies uniformly across Lauds and the four hours fixed here. Also documents a real, found-not-fixed gap: when two octaves overlap with no temporal standing to yield to (St. Lawrence's and the Assumption's genuinely overlap every Aug 16-17), this app currently picks whichever started first, which happens to match the live engine on Aug 17 (Lawrence's own elevated final "Octave Day" outranks the Assumption's ordinary octave-day strength) but only by coincidence, not a real rank comparison -- proposed design written up in TODO.md pending a decision on scope. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+11
-4
@@ -6,7 +6,14 @@ import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher } from './antiphon';
|
||||
import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common';
|
||||
import {
|
||||
resolveCommon,
|
||||
getDayCollect,
|
||||
splitNamedAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
resolveMinorHourChapter,
|
||||
} from './resolve-common';
|
||||
import noneDefinitionData from '../data/hours/none.yml';
|
||||
import antiphonsData from '../data/hours/none-antiphons.yml';
|
||||
|
||||
@@ -21,7 +28,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// Self-contained, no appendDoxology — see none-hymn.yml.
|
||||
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
|
||||
case 'chapter':
|
||||
return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }];
|
||||
return [{ kind: 'chapter', text: resolveMinorHourChapter('none', day, part.textRef.id) }];
|
||||
case 'day-collect':
|
||||
return [{ kind: 'prayer', text: getDayCollect(day) }];
|
||||
case 'preces':
|
||||
@@ -31,8 +38,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// psalms, no closing repeat (unlike Prime): confirmed directly
|
||||
// against the engine, Capitulum follows the third psalm immediately.
|
||||
const psalmRefs = getPsalmsFor('none', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]);
|
||||
const opening = isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
return psalmRefs.map((ref, i) => ({
|
||||
kind: 'psalm' as const,
|
||||
psalmNumber: ref.number,
|
||||
|
||||
+17
-12
@@ -1,6 +1,6 @@
|
||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
|
||||
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
||||
import { resolveDay, isSundayOrFeast } from '../calendar';
|
||||
import { resolveDay } from '../calendar';
|
||||
import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
@@ -10,7 +10,14 @@ import { getChapterResponsoryId } from './chapter-responsory';
|
||||
import { getHymnDoxologyId } from './hymn-doxology';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher } from './antiphon';
|
||||
import { resolveCommon, appendDoxology, splitNamedAntiphon } from './resolve-common';
|
||||
import {
|
||||
resolveCommon,
|
||||
appendDoxology,
|
||||
splitNamedAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
isSundayOrFeastOffice,
|
||||
} from './resolve-common';
|
||||
import primeDefinitionData from '../data/hours/prime.yml';
|
||||
import antiphonsData from '../data/hours/prime-antiphons.yml';
|
||||
|
||||
@@ -30,7 +37,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
case 'prayer':
|
||||
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
|
||||
case 'preces':
|
||||
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
|
||||
if (part.omitOnDouble && isDoubleOrHigher(resolveOfficeWinner(day))) {
|
||||
return [];
|
||||
}
|
||||
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
|
||||
@@ -42,15 +49,13 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
// Real Divinum Officium's actual inclusion rule tangles together
|
||||
// rank, commemorations, and version-specific rubrics in a way that
|
||||
// isn't worth replicating exactly — this is the honest simplification:
|
||||
// Sunday and not a Double-or-higher feast. Octaves aren't modeled at
|
||||
// all yet (milestone 4), so they can't suppress it either, same as
|
||||
// isDoubleOrHigher's own limitation.
|
||||
if (day.weekday !== 'sunday' || isDoubleOrHigher(day.winner)) {
|
||||
// Sunday and not a Double-or-higher feast.
|
||||
if (day.weekday !== 'sunday' || isDoubleOrHigher(resolveOfficeWinner(day))) {
|
||||
return [];
|
||||
}
|
||||
return [{ kind: 'lesson', text: resolveCommon('athanasian-creed'), label: 'Athanasian Creed' }];
|
||||
case 'closing-antiphon':
|
||||
return [{ kind: 'antiphon', text: splitNamedAntiphon(antiphons[day.weekday]).full }];
|
||||
return [{ kind: 'antiphon', text: splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday])).full }];
|
||||
case 'variable':
|
||||
if (part.resolve === 'by-season') {
|
||||
// Currently only the chapter responsory's verse uses this — see
|
||||
@@ -59,11 +64,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
}
|
||||
{
|
||||
const psalmRefs = getPsalmsFor('prime', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]);
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday]));
|
||||
// Full text on a Double-or-higher feast, otherwise just the incipit
|
||||
// — see hours/antiphon.ts. The full repeat comes later, after the
|
||||
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
|
||||
const opening = isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
return psalmRefs.map((ref, i) => ({
|
||||
kind: 'psalm' as const,
|
||||
psalmNumber: ref.number,
|
||||
@@ -86,10 +91,10 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
case 'canticle':
|
||||
return [{ kind: 'canticle', canticleId: part.canticleId, text: resolveCommon(part.textRef.id) }];
|
||||
case 'by-day-kind': {
|
||||
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
|
||||
if (part.omitOnDouble && isDoubleOrHigher(resolveOfficeWinner(day))) {
|
||||
return [];
|
||||
}
|
||||
const ref = isSundayOrFeast(day) ? part.sundayOrFeastRef : part.ferialRef;
|
||||
const ref = isSundayOrFeastOffice(day) ? part.sundayOrFeastRef : part.ferialRef;
|
||||
if (part.resolvedKind === 'preces') {
|
||||
return [{ kind: 'preces', text: resolveCommon(ref.id), label: part.label }];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types'
|
||||
import type { ProperText } from '../propers';
|
||||
import { getCommonProper, getTemporalProper } from '../propers';
|
||||
import { getSaintRecord } from '../calendar/feasts';
|
||||
import { activeOctavesFor } from '../calendar';
|
||||
import { activeOctavesFor, isAtLeast } from '../calendar';
|
||||
import { splitAntiphon } from './antiphon';
|
||||
|
||||
function toResolvedText(proper: ProperText): ResolvedText {
|
||||
@@ -69,6 +69,76 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner {
|
||||
return day.winner;
|
||||
}
|
||||
|
||||
/** Like calendar/index.ts's own isSundayOrFeast, but resolveOfficeWinner-
|
||||
* aware — an octave day counts as a "feast" for this purpose too.
|
||||
* Live-verified: Prime's capitulum/Preces stay in the Sunday/feast form
|
||||
* throughout St. Lawrence's octave (2026-08-12), not just his own actual
|
||||
* day (2026-08-10) — both show the identical "ex Psalterio secundum
|
||||
* diem" 1 Tim 1:17 text. Deliberately a separate function, not a
|
||||
* replacement for the plain isSundayOrFeast: Lauds' own Cross-suffrage
|
||||
* gate (hours/lauds.ts) needs the *narrower*, non-office-aware version,
|
||||
* since Marian Saturday and an octave day both need to land on the
|
||||
* ferial side there despite not being Sunday or a sanctoral `day.winner`
|
||||
* either. */
|
||||
export function isSundayOrFeastOffice(day: LiturgicalDay): boolean {
|
||||
return day.weekday === 'sunday' || resolveOfficeWinner(day).kind === 'sanctoral';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether today's office winner (resolveOfficeWinner) is a strong enough
|
||||
* feast to carry its own proper minor-hour content (antiphon + chapter)
|
||||
* — duplex-majus+, the same threshold Lauds' own psalmody override uses
|
||||
* (hours/lauds.ts's getPsalmodyOverrideFor), kept in sync deliberately:
|
||||
* as each remaining duplex-majus+ saint gets a Lauds override authored,
|
||||
* the same id is what Prime/Terce/Sext/None would look for too, so it's
|
||||
* one shared backlog (see TODO.md), not per-hour lists that can drift.
|
||||
* Returns the id to look up (`${hourId}-antiphon-${id}` etc.), or
|
||||
* `undefined` when nothing eligible is happening today.
|
||||
*/
|
||||
export function getMinorHourOverrideId(day: LiturgicalDay): string | undefined {
|
||||
const winner = resolveOfficeWinner(day);
|
||||
if (winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus')) {
|
||||
return winner.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A Little Hour's (or Prime's) plain per-weekday antiphon, overridden by
|
||||
* a duplex-majus+ feast's own proper (`${hourId}-antiphon-${id}`) when
|
||||
* authored — same honest "not gated behind whether content exists, just
|
||||
* eligible to override at all" fallback as every other override in this
|
||||
* codebase: an eligible feast without one authored yet just falls
|
||||
* through to the plain weekday default silently. */
|
||||
export function resolveMinorHourAntiphon(
|
||||
hourId: string,
|
||||
day: LiturgicalDay,
|
||||
weekdayDefault: Partial<Record<string, string>>,
|
||||
): Partial<Record<string, string>> {
|
||||
const overrideId = getMinorHourOverrideId(day);
|
||||
if (overrideId) {
|
||||
const proper = getCommonProper(`${hourId}-antiphon-${overrideId}`);
|
||||
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
|
||||
return proper.text;
|
||||
}
|
||||
}
|
||||
return weekdayDefault;
|
||||
}
|
||||
|
||||
/** Same idea as resolveMinorHourAntiphon, for the chapter
|
||||
* (`${hourId}-capitulum-${id}`) — falls back to `fallbackId` (the plain
|
||||
* per-annum/per-weekday one already in place for that hour) when no
|
||||
* override is eligible, or none has been authored yet for one that is. */
|
||||
export function resolveMinorHourChapter(hourId: string, day: LiturgicalDay, fallbackId: string): ResolvedText {
|
||||
const overrideId = getMinorHourOverrideId(day);
|
||||
if (overrideId) {
|
||||
const proper = resolveCommon(`${hourId}-capitulum-${overrideId}`);
|
||||
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
|
||||
return proper;
|
||||
}
|
||||
}
|
||||
return resolveCommon(fallbackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's own collect — real for the vast majority of days (a temporal
|
||||
* winner always resolves, since all 52 Sunday collects are authored and
|
||||
|
||||
+11
-4
@@ -6,7 +6,14 @@ import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher } from './antiphon';
|
||||
import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common';
|
||||
import {
|
||||
resolveCommon,
|
||||
getDayCollect,
|
||||
splitNamedAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
resolveMinorHourChapter,
|
||||
} from './resolve-common';
|
||||
import sextDefinitionData from '../data/hours/sext.yml';
|
||||
import antiphonsData from '../data/hours/sext-antiphons.yml';
|
||||
|
||||
@@ -21,7 +28,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// Self-contained, no appendDoxology — see sext-hymn.yml.
|
||||
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
|
||||
case 'chapter':
|
||||
return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }];
|
||||
return [{ kind: 'chapter', text: resolveMinorHourChapter('sext', day, part.textRef.id) }];
|
||||
case 'day-collect':
|
||||
return [{ kind: 'prayer', text: getDayCollect(day) }];
|
||||
case 'preces':
|
||||
@@ -31,8 +38,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// psalms, no closing repeat (unlike Prime): confirmed directly
|
||||
// against the engine, Capitulum follows the third psalm immediately.
|
||||
const psalmRefs = getPsalmsFor('sext', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]);
|
||||
const opening = isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
return psalmRefs.map((ref, i) => ({
|
||||
kind: 'psalm' as const,
|
||||
psalmNumber: ref.number,
|
||||
|
||||
+11
-4
@@ -6,7 +6,14 @@ import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher } from './antiphon';
|
||||
import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common';
|
||||
import {
|
||||
resolveCommon,
|
||||
getDayCollect,
|
||||
splitNamedAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
resolveMinorHourChapter,
|
||||
} from './resolve-common';
|
||||
import terceDefinitionData from '../data/hours/terce.yml';
|
||||
import antiphonsData from '../data/hours/terce-antiphons.yml';
|
||||
|
||||
@@ -21,7 +28,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// Self-contained, no appendDoxology — see terce-hymn.yml.
|
||||
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
|
||||
case 'chapter':
|
||||
return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }];
|
||||
return [{ kind: 'chapter', text: resolveMinorHourChapter('terce', day, part.textRef.id) }];
|
||||
case 'day-collect':
|
||||
return [{ kind: 'prayer', text: getDayCollect(day) }];
|
||||
case 'preces':
|
||||
@@ -31,8 +38,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// psalms, no closing repeat (unlike Prime): confirmed directly
|
||||
// against the engine, Capitulum follows the third psalm immediately.
|
||||
const psalmRefs = getPsalmsFor('terce', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]);
|
||||
const opening = isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
return psalmRefs.map((ref, i) => ({
|
||||
kind: 'psalm' as const,
|
||||
psalmNumber: ref.number,
|
||||
|
||||
Reference in New Issue
Block a user