397f31f4fc
Deploy / deploy (push) Successful in 52s
When more than one octave is active on a day with no temporal standing of
its own (St. Lawrence's and the Assumption's genuinely overlap every Aug
16-17), which one governs the day's content/label is now decided by rank
comparison instead of "whichever started first" (an accident of
insertion order). Per direct instruction:
- Highest effective rank wins outright; every other active octave still
gets commemorated, same as a single octave already outranking a weak
rival saint.
- A rank tie goes to whichever octave started more recently -- day 1 of
a new octave needs to be fully present, the whole point of it
starting. No real tied-rank case exists yet to verify this
empirically, unlike everything else here -- documented in TODO.md as
a stated decision, not a live finding.
New OctaveConfig.closingDayRank (default duplex): an octave's own final
day ("in Octava") is elevated above its ordinary in-between rank --
live-verified as a real, general pattern (both St. Lawrence's Aug 17 and
the Assumption's Aug 22 show as Duplex, above their otherwise-Semiduplex
ordinary days), not a one-off. This elevation is *why* Lawrence's octave
beats the Assumption's on their one real overlap day despite the
Assumption being the far higher-ranked feast overall. Feeds both the
existing rival-saint threshold and the new octave-vs-octave comparison.
calendar/octaves.ts gains resolveActiveOctave (+ pickWinningOctave, the
comparison itself factored out for direct unit testing against synthetic
data, since no real tied-rank overlap exists to test against yet).
hours/resolve-common.ts's resolveOfficeWinner and calendar/day-label.ts
both now call it instead of each keeping their own "activeOctavesFor(...)
[0]" logic.
That consolidation surfaced a real, independent bug: getDayLabel never
checked temporalCategory at all before choosing an octave name, unlike
resolveOfficeWinner -- found while testing the real Aug 16 overlap (a
Sunday that year, where the temporal Sunday has standing and should win
outright). Live-verified counterexample: the Christmas Octave's own
stack (Dec 30) was wrongly labeled "3rd Day within the Octave of The Holy
Innocents" instead of the correct plain temporal label -- the real title
never names any of the four stacked octaves there. Fixed by sharing the
exact same ordinary-feria gate resolveOfficeWinner already had, so the
two can no longer disagree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
import type { ResolvedPart, ResolvedText } from './types';
|
|
import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types';
|
|
import type { ProperText } from '../propers';
|
|
import { getCommonProper, getTemporalProper } from '../propers';
|
|
import { getSaintRecord } from '../calendar/feasts';
|
|
import { resolveActiveOctave, isAtLeast } from '../calendar';
|
|
import { splitAntiphon } from './antiphon';
|
|
|
|
function toResolvedText(proper: ProperText): ResolvedText {
|
|
return { text: proper.text, status: proper.status, citation: proper.citation };
|
|
}
|
|
|
|
/** Shared by every hour's resolver — looks up a common proper by id and
|
|
* shapes it as a ResolvedText. */
|
|
export function resolveCommon(id: string): ResolvedText {
|
|
return toResolvedText(getCommonProper(id));
|
|
}
|
|
|
|
// Named temporal winners with real standing of their own, unconditionally
|
|
// override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which
|
|
// shares this set (imported from here, not duplicated) since it's the
|
|
// same "does this temporal identity carry its own real office" question.
|
|
export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ-the-king']);
|
|
|
|
/**
|
|
* Which identity's own propers actually supply the office's content
|
|
* (collect, Benedictus antiphon, psalmody override) — usually just
|
|
* `day.winner`, but *not* on a day within an active octave where the
|
|
* temporal day itself has no real standing of its own (`ordinary-feria`)
|
|
* and the winner stayed temporal — an ordinary day within St. Lawrence's
|
|
* own octave, e.g.: live-verified (Tridentine 1910, 2026-08-12) that the
|
|
* chapter/responsory/hymn/versicle, psalms' antiphons, Benedictus
|
|
* antiphon, and day collect all come from the octave's own feast
|
|
* ("{ex Commune aut Festo}" / "{ex Proprio Sanctorum}"), not from the
|
|
* plain temporal day underneath it.
|
|
*
|
|
* Deliberately gated on `ordinary-feria` specifically, not just "an
|
|
* octave is active": the Christmas Octave's own stacked octaves
|
|
* (Christmas + Stephen + John + Holy Innocents, e.g. on Dec 30) are the
|
|
* live-verified counterexample — that day's own temporal identity
|
|
* ("Dominica Infra Octavam Nativitatis", `privileged-feria-minor`) is
|
|
* itself a real, named standing, and *keeps* the office
|
|
* ("{ex Proprio de Tempore}"); the four octaves there each become their
|
|
* own separate "Commemoratio Octavæ ..." block instead (not yet modeled
|
|
* — see TODO.md), rather than any one of them taking over content the
|
|
* way Lawrence's octave does. The dividing line is real standing, not
|
|
* merely "is an octave active."
|
|
*
|
|
* `day.winner`/`day.commemorations` themselves stay exactly as
|
|
* calendar/index.ts computed them either way — this is purely a
|
|
* content-lookup detail, not a recomputation of who "wins." Picks the
|
|
* oldest-started active octave with an authored saint record
|
|
* (activeOctavesFor's own ordering) — matches calendar/day-label.ts's
|
|
* same choice for the display label, for the same reason.
|
|
*/
|
|
export function resolveOfficeWinner(day: LiturgicalDay): DayWinner {
|
|
if (day.winner.kind === 'sanctoral' || ALWAYS_OVERRIDE_TEMPORAL_IDS.has(day.winner.id)) {
|
|
return day.winner;
|
|
}
|
|
if (day.temporalCategory !== 'ordinary-feria') {
|
|
return day.winner;
|
|
}
|
|
// When more than one octave is active at once (St. Lawrence's and the
|
|
// Assumption's genuinely overlap every Aug 16-17), resolveActiveOctave
|
|
// picks the one that actually governs the day — see its own doc
|
|
// comment for the precedence rule. Falls through to the plain temporal
|
|
// default if that octave turns out to be a temporal-only one with no
|
|
// saint record (e.g. Christmas's or Pentecost's own octave id) — no
|
|
// sanctoral content to synthesize from those.
|
|
const activeOctave = resolveActiveOctave(day.date);
|
|
if (activeOctave) {
|
|
const saint = getSaintRecord(activeOctave.id);
|
|
if (saint) {
|
|
return { kind: 'sanctoral', id: saint.id, name: saint.name, rank: saint.rank };
|
|
}
|
|
}
|
|
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
|
|
* ferias inherit the governing Sunday's via calendar/temporal-id.ts), and
|
|
* honestly "missing" for the handful of days a saint wins outright until
|
|
* that saint's own propers are authored (`saints/<id>.yml`'s `propers`
|
|
* field is still `null` for all of them today) — same "resolve as
|
|
* pending" convention as everywhere else, not a special case to handle.
|
|
* Uses resolveOfficeWinner, not the raw `day.winner` — see its own doc
|
|
* comment for why those two differ on an octave day.
|
|
*/
|
|
export function getDayCollect(day: LiturgicalDay): ResolvedText {
|
|
const winner = resolveOfficeWinner(day);
|
|
if (winner.kind === 'temporal') {
|
|
return toResolvedText(getTemporalProper(`${winner.id}-collect`));
|
|
}
|
|
const saint = getSaintRecord(winner.id);
|
|
if (saint?.propers) {
|
|
return resolveCommon(`${saint.propers}-collect`);
|
|
}
|
|
return { text: {}, status: { la: 'missing', en: 'missing' } };
|
|
}
|
|
|
|
/** A sanctoral commemoration's own rendering: the fuller Ant+V/R+collect
|
|
* bundle (`${propers}-commemoration`, e.g. st-clare-commemoration.yml)
|
|
* when authored — live-verified this is the real shape a commemoration
|
|
* takes, not a bare collect — falling back to just the collect alone
|
|
* (still labeled, unlike the old unlabeled bare-collect rendering this
|
|
* replaces) when only that's been authored, and to an honestly labeled
|
|
* "missing" block when neither has. Always labeled ("Commemoration of St.
|
|
* X") so an unauthored one reads as "this saint's commemoration isn't
|
|
* written up yet," not as a mystery blank prayer. */
|
|
function sanctoralCommemorationPart(commemoration: Extract<Commemoration, { kind: 'sanctoral' }>): ResolvedPart {
|
|
const label = `Commemoration of ${commemoration.name}`;
|
|
const saint = getSaintRecord(commemoration.id);
|
|
if (saint?.propers) {
|
|
const combined = resolveCommon(`${saint.propers}-commemoration`);
|
|
if (combined.status.la !== 'missing' || combined.status.en !== 'missing') {
|
|
return { kind: 'preces', text: combined, label };
|
|
}
|
|
return { kind: 'preces', text: resolveCommon(`${saint.propers}-collect`), label };
|
|
}
|
|
return { kind: 'preces', text: { text: {}, status: { la: 'missing', en: 'missing' } }, label };
|
|
}
|
|
|
|
/**
|
|
* The day's own collect, plus one more per commemoration (calendar/
|
|
* types.ts's LiturgicalDay.commemorations) — Lauds/Vespers say all of
|
|
* these in sequence, unlike the Little Hours' single getDayCollect.
|
|
*
|
|
* Simplification, not yet corrected: real practice compresses this into
|
|
* one "Orémus" and lets only the *last* collect close with the full
|
|
* doxology, with earlier ones trailing straight into the next ("And:").
|
|
* Each collect file (data/propers/temporal/*-collect.yml, the per-saint
|
|
* *-collect.yml files) already bakes in its own "Orémus."/"Per Dóminum...
|
|
* Amen." for the single-collect case every other hour uses today, and
|
|
* stripping that back out per-collect to chain them properly would need
|
|
* text surgery this doesn't attempt — so on a commemorated day, each
|
|
* collect here renders as its own complete, separate block instead. An
|
|
* octave commemoration never contributes anything of its own here — once
|
|
* resolveOfficeWinner is in play, its content is already the primary
|
|
* collect above, so a separate entry would just be a redundant repeat.
|
|
*/
|
|
export function getDayCollects(day: LiturgicalDay): ResolvedPart[] {
|
|
const parts: ResolvedPart[] = [{ kind: 'prayer', text: getDayCollect(day) }];
|
|
for (const commemoration of day.commemorations) {
|
|
if (commemoration.kind === 'temporal') {
|
|
parts.push({ kind: 'prayer', text: toResolvedText(getTemporalProper(`${commemoration.id}-collect`)) });
|
|
} else if (commemoration.kind === 'sanctoral') {
|
|
parts.push(sanctoralCommemorationPart(commemoration));
|
|
}
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
/**
|
|
* Lauds' Benedictus antiphon, resolved the same way as getDayCollect: a
|
|
* per-saint `${propers}-antiphon` (already authored during the sanctoral
|
|
* pull, sourced from each saint's own raw [Ant 1] — see e.g.
|
|
* st-lawrence-antiphon.yml) for a sanctoral winner, `${id}-benedictus-
|
|
* antiphon` (not authored yet for any temporal id — resolves "missing",
|
|
* same pending convention as everywhere else) for a temporal one. Uses
|
|
* resolveOfficeWinner, not the raw `day.winner` — on an octave day this
|
|
* is the octave's own feast's antiphon (live-verified: St. Lawrence's
|
|
* "In cratícula", not the commemorated St. Clare's, and not the plain
|
|
* temporal day's), not a *commemorated* saint's, which is a separate,
|
|
* weaker standing (see getDayCollects/sanctoralCommemorationPart).
|
|
*/
|
|
export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText {
|
|
const winner = resolveOfficeWinner(day);
|
|
if (winner.kind === 'temporal') {
|
|
return toResolvedText(getTemporalProper(`${winner.id}-benedictus-antiphon`));
|
|
}
|
|
const saint = getSaintRecord(winner.id);
|
|
if (saint?.propers) {
|
|
return resolveCommon(`${saint.propers}-antiphon`);
|
|
}
|
|
return { text: {}, status: { la: 'missing', en: 'missing' } };
|
|
}
|
|
|
|
export function verifiedText(text: Partial<Record<string, string>>): ResolvedText {
|
|
const status: Partial<Record<string, 'verified'>> = {};
|
|
for (const lang of Object.keys(text)) {
|
|
status[lang] = 'verified';
|
|
}
|
|
return { text, status };
|
|
}
|
|
|
|
const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const;
|
|
|
|
/** Joins a hymn's body with its (seasonally-variable) final doxology
|
|
* stanza, per language — status is the worse of the two per language. */
|
|
export function appendDoxology(body: ResolvedText, doxology: ResolvedText): ResolvedText {
|
|
const text: Partial<Record<string, string>> = { ...body.text };
|
|
const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = { ...body.status };
|
|
for (const lang of Object.keys(doxology.text)) {
|
|
const doxText = doxology.text[lang];
|
|
if (doxText) {
|
|
text[lang] = text[lang] ? `${text[lang]}\n\n${doxText}` : doxText;
|
|
}
|
|
const bodyStatus = status[lang] ?? 'missing';
|
|
const doxStatus = doxology.status[lang] ?? 'missing';
|
|
status[lang] = STATUS_RANK[doxStatus] > STATUS_RANK[bodyStatus] ? doxStatus : bodyStatus;
|
|
}
|
|
return { text, status };
|
|
}
|
|
|
|
/** Splits a bilingual antiphon (one string per language, each with an
|
|
* embedded "*") into its incipit and full forms, per language — each
|
|
* prefixed "Ant. " inline, the same way "V."/"R." are baked directly into
|
|
* versicle text rather than rendered as a separate UI marker. */
|
|
export function splitNamedAntiphon(text: Partial<Record<string, string>>): {
|
|
incipit: ResolvedText;
|
|
full: ResolvedText;
|
|
} {
|
|
const incipitText: Partial<Record<string, string>> = {};
|
|
const fullText: Partial<Record<string, string>> = {};
|
|
for (const [lang, t] of Object.entries(text)) {
|
|
if (!t) {
|
|
continue;
|
|
}
|
|
const split = splitAntiphon(t);
|
|
incipitText[lang] = `Ant. ${split.incipit}`;
|
|
fullText[lang] = `Ant. ${split.full}`;
|
|
}
|
|
return { incipit: verifiedText(incipitText), full: verifiedText(fullText) };
|
|
}
|