hours: build Terce, Sext, and None
Deploy / deploy (push) Successful in 39s

Three separate resolver files (terce.ts/sext.ts/none.ts), not a shared
little-hour module — they're structurally identical but kept independent
by choice. Ordo verified against Divinum Officium (Monastic Tridentinum
1617): opening versicle, self-contained hymn, one antiphon framing three
psalms (no closing repeat, unlike Prime), a capitulum with its own
embedded short versicle/response, the day's own collect, and a closing
identical across all three hours.

Also fills in psalter-distribution.yml's previously-missing Sunday/
Monday/Tuesday psalm entries (Ps 118 sections for Sunday/Monday; Tuesday
turns out to share Wednesday/Thursday's native gradual-psalm set, not a
distinct one). Friday/Saturday's antiphons are derived from each day's
first psalm rather than reusing the native gradual antiphons, since
those two days already use a redistributed, non-native psalm set.

hours/types.ts gains a 'day-collect' part kind — the first ordo to use
the day's own Proper collect rather than a fixed text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:51:08 -04:00
parent 7a932b40be
commit 813d173f5c
20 changed files with 526 additions and 29 deletions
+1
View File
@@ -85,6 +85,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'closing-antiphon':
case 'by-day-kind':
case 'variable':
case 'day-collect':
throw new Error(`Compline's ordo doesn't support a '${part.kind}' part`);
}
}
+63 -5
View File
@@ -1,7 +1,65 @@
import type { ResolvedOrdo } from './types';
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveDay } from '../calendar';
import { getDayLabel } from '../calendar/day-label';
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 noneDefinitionData from '../data/hours/none.yml';
import antiphonsData from '../data/hours/none-antiphons.yml';
// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts
// alongside terce.ts and sext.ts once that's written.
export function resolveOrdo(date: string): ResolvedOrdo {
return { hourId: 'none', date, parts: [], notImplemented: true };
const noneDefinition = noneDefinitionData as HourDefinition;
const antiphons = antiphonsData as Record<Weekday, Partial<Record<string, string>>>;
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
switch (part.kind) {
case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'hymn':
// 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) }];
case 'day-collect':
return [{ kind: 'prayer', text: getDayCollect(day) }];
case 'preces':
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
case 'variable': {
// Only 'by-weekday' applies here — one antiphon framing all three
// 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;
return psalmRefs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
}
// Not used by None.
case 'prayer':
case 'responsory':
case 'versicle':
case 'psalm':
case 'canticle':
case 'lesson':
case 'martyrology':
case 'rule-reading':
case 'by-day-kind':
case 'creed':
case 'closing-antiphon':
case 'nunc-dimittis':
case 'marian-antiphon':
throw new Error(`None's ordo doesn't support a '${part.kind}' part`);
}
}
export function resolveOrdo(date: string): ResolvedOrdo {
const day = resolveDay(date);
const parts = noneDefinition.parts.flatMap((part) => resolvePart(part, day));
return { hourId: 'none', date, parts, dayLabel: getDayLabel(day) };
}
+2 -1
View File
@@ -108,9 +108,10 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
{ kind: 'preces', text: resolveCommon('lesson-close') },
];
}
// Not used by Prime — Compline-only.
// Not used by Prime — Compline-only, or Little-Hours-only.
case 'nunc-dimittis':
case 'marian-antiphon':
case 'day-collect':
throw new Error(`Prime's ordo doesn't support a '${part.kind}' part`);
}
}
+63 -5
View File
@@ -1,7 +1,65 @@
import type { ResolvedOrdo } from './types';
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveDay } from '../calendar';
import { getDayLabel } from '../calendar/day-label';
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 sextDefinitionData from '../data/hours/sext.yml';
import antiphonsData from '../data/hours/sext-antiphons.yml';
// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts
// alongside terce.ts and none.ts once that's written.
export function resolveOrdo(date: string): ResolvedOrdo {
return { hourId: 'sext', date, parts: [], notImplemented: true };
const sextDefinition = sextDefinitionData as HourDefinition;
const antiphons = antiphonsData as Record<Weekday, Partial<Record<string, string>>>;
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
switch (part.kind) {
case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'hymn':
// 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) }];
case 'day-collect':
return [{ kind: 'prayer', text: getDayCollect(day) }];
case 'preces':
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
case 'variable': {
// Only 'by-weekday' applies here — one antiphon framing all three
// 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;
return psalmRefs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
}
// Not used by Sext.
case 'prayer':
case 'responsory':
case 'versicle':
case 'psalm':
case 'canticle':
case 'lesson':
case 'martyrology':
case 'rule-reading':
case 'by-day-kind':
case 'creed':
case 'closing-antiphon':
case 'nunc-dimittis':
case 'marian-antiphon':
throw new Error(`Sext's ordo doesn't support a '${part.kind}' part`);
}
}
export function resolveOrdo(date: string): ResolvedOrdo {
const day = resolveDay(date);
const parts = sextDefinition.parts.flatMap((part) => resolvePart(part, day));
return { hourId: 'sext', date, parts, dayLabel: getDayLabel(day) };
}
+63 -5
View File
@@ -1,7 +1,65 @@
import type { ResolvedOrdo } from './types';
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveDay } from '../calendar';
import { getDayLabel } from '../calendar/day-label';
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 terceDefinitionData from '../data/hours/terce.yml';
import antiphonsData from '../data/hours/terce-antiphons.yml';
// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts
// alongside sext.ts and none.ts once that's written.
export function resolveOrdo(date: string): ResolvedOrdo {
return { hourId: 'terce', date, parts: [], notImplemented: true };
const terceDefinition = terceDefinitionData as HourDefinition;
const antiphons = antiphonsData as Record<Weekday, Partial<Record<string, string>>>;
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
switch (part.kind) {
case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'hymn':
// 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) }];
case 'day-collect':
return [{ kind: 'prayer', text: getDayCollect(day) }];
case 'preces':
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
case 'variable': {
// Only 'by-weekday' applies here — one antiphon framing all three
// 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;
return psalmRefs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
}
// Not used by Terce.
case 'prayer':
case 'responsory':
case 'versicle':
case 'psalm':
case 'canticle':
case 'lesson':
case 'martyrology':
case 'rule-reading':
case 'by-day-kind':
case 'creed':
case 'closing-antiphon':
case 'nunc-dimittis':
case 'marian-antiphon':
throw new Error(`Terce's ordo doesn't support a '${part.kind}' part`);
}
}
export function resolveOrdo(date: string): ResolvedOrdo {
const day = resolveDay(date);
const parts = terceDefinition.parts.flatMap((part) => resolvePart(part, day));
return { hourId: 'terce', date, parts, dayLabel: getDayLabel(day) };
}
+7 -1
View File
@@ -87,7 +87,13 @@ export type HourPart =
// Salve Regina. See hours/marian-antiphon.ts.
| { kind: 'marian-antiphon' }
// Unused by Prime/Compline/the little hours — exercised starting milestone 4.
| { kind: 'variable'; resolve: 'by-weekday' | 'by-season' | 'by-feast-rank' };
| { kind: 'variable'; resolve: 'by-weekday' | 'by-season' | 'by-feast-rank' }
// The day's own Proper-of-season/Proper-of-saints collect — resolved by
// day, not by textRef. First used by the Little Hours' "Oratio {ex
// Proprio de Tempore}" via hours/resolve-common.ts's getDayCollect;
// Prime/Compline both still use a fixed collect (`prayer` + textRef).
// Renders as a plain 'prayer' ResolvedPart.
| { kind: 'day-collect' };
export interface HourDefinition {
id: HourId;