Files
vu/src/hours/types.ts
T
will a2d082e608
Deploy / deploy (push) Successful in 1m28s
Keep a short litany + Our Father at Compline on a Double, don't omit
compline-preces.yml was already the secular Tridentine 1906/1910 text
word-for-word (verified live against both tracks), so no text change
was needed there. The actual gap: both Monastic and secular tracks drop
Compline's Preces to nothing on a Double-or-higher, unlike Vespers/Lauds
which fall back to a short form. Per explicit request, this is now a
deliberate vu-specific deviation from both tracks: on a Double, Compline
substitutes the same short Kyrie/Pater-Noster litany Vespers already
falls back to on Sundays/feasts, instead of showing nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHXJAWVAabCKX1JgmDh4Rn
2026-08-29 05:29:56 -04:00

333 lines
18 KiB
TypeScript

export type HourId =
| 'prime'
| 'compline'
| 'terce'
| 'sext'
| 'none'
| 'lauds'
| 'vespers'
| 'matins';
export const HOUR_IDS: readonly HourId[] = [
'matins',
'lauds',
'prime',
'terce',
'sext',
'none',
'vespers',
'compline',
];
/** Reference into one of the proper-text stores (data/propers/**). Not built yet — placeholder shape. */
export interface PropersRef {
source: 'sanctoral' | 'temporal' | 'common';
id: string;
}
export type HourPart =
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; textRef: PropersRef }
// A fixed multi-line cluster of versicles/responses/prayers (Preces, the
// monastic "De Officio Capituli" mini-office, the dead-commemoration
// responsory/collect) — these don't decompose usefully into individual
// hymn/chapter/versicle parts, so they're stored and rendered as one block.
// `label`, when given, is shown as a heading (e.g. "Preces", "Pretiosa") —
// omitted for parts that are just connective tissue around a reading
// (a blessing, a closing dialogue) that don't read as their own section.
// `omitOnDouble` marks a part that's suppressed above a certain rank on
// real Double-or-higher feasts (see calendar/isDoubleOrHigher) — its own
// resolver decides what (if anything) replaces it. Compline's own
// 'preces' part is the only current user: it substitutes the short
// Kyrie/Pater litany (see hours/compline.ts's 'preces' case) rather than
// dropping to nothing, a deliberate vu-specific choice (real practice, in
// both Monastic and secular tracks, drops Compline's Preces to nothing
// on a Double, with no substitute) — the flag still means "not the full
// text here," just not "nothing here."
| { kind: 'preces'; textRef: PropersRef; label?: string; omitOnDouble?: true }
| { kind: 'psalm'; psalmNumber: number; verses?: string; antiphonRef?: PropersRef }
| { kind: 'canticle'; canticleId: string; textRef: PropersRef; antiphonRef?: PropersRef }
| {
kind: 'lesson';
textRef: PropersRef;
label?: string;
nocturn?: number;
lessonSource?: 'historical' | 'continuous-plan';
}
// Resolved by date, not by textRef — see src/martyrology. Monastic Prime
// reads *tomorrow's* entry (the announcement of the next day's saints).
| { kind: 'martyrology' }
// Resolved by date via the day->canonical-reading table (RB is read
// through 3x/year) — see src/regula.
| { kind: 'rule-reading' }
// Chooses between two fixed texts by calendar/isSundayOrFeast — e.g.
// Prime's capitulum (1 Tim on Sunday/feasts, Zach on ferias). Renders as
// a plain 'chapter' or 'preces' ResolvedPart per resolvedKind.
| {
kind: 'by-day-kind';
resolvedKind: 'chapter' | 'preces';
label?: string;
omitOnDouble?: true;
// When true, a Vigil-ranked sanctoral winner takes the ferialRef
// instead of the sundayOrFeastRef isSundayOrFeastOffice would
// otherwise give it (that function treats any sanctoral winner,
// Vigils included, as a "feast"). Prime's Preces use this; its
// capitulum doesn't.
vigilIsFerial?: true;
sundayOrFeastRef: PropersRef;
ferialRef: PropersRef;
}
// The Athanasian Creed — included on Sundays that aren't a Double-or-higher
// feast (see calendar/isDoubleOrHigher and hours/prime.ts's 'creed' case
// for how far that's actually implemented today).
| { kind: 'creed' }
// Resolved by season — Allelúja normally, Laus tibi Septuagesima through
// Holy Saturday. See hours/opening-versicle.ts.
| { kind: 'opening-versicle' }
// The full antiphon, repeated after the whole psalm group (and the
// Athanasian Creed, on days it's said) — real practice says it once more
// in full here, distinct from the incipit-or-full opening attached to the
// first psalm. See hours/antiphon.ts.
| { kind: 'closing-antiphon' }
// Compline's canticle — fixed year-round (not weekday-variable the way
// Prime's psalm antiphon is), framed by "Salva nos" incipit-or-full
// before and full after, same rank rule as any other antiphon. Resolves
// to a 'canticle' ResolvedPart (with the opening antiphon inline) plus a
// standalone 'antiphon' ResolvedPart for the closing repeat.
| { kind: 'nunc-dimittis' }
// Lauds only. Everything from Ps 66 through the Laudate psalms (148-150)
// inclusive, resolved by weekday — see hours/lauds.ts's resolvePsalmody
// and data/hours/lauds-antiphons.yml. Opaque here on purpose: the real
// shape (a fixed Ps 66 with no antiphon at all, then a weekday-variable
// number of antiphoned psalm groups, then a weekday-variable OT canticle,
// then the Laudate psalms under one more shared antiphon) doesn't fit any
// existing part kind, and hardcoding psalm numbers in lauds.yml can't
// work since they differ by weekday. A duplex-majus-or-higher sanctoral
// winner, or Marian Saturday, substitutes its own proper psalmody
// instead — see hours/lauds-psalmody-overrides.ts's
// getPsalmodyOverrideFor, gated by direct instruction at duplex-majus+
// rather than every rank real historical practice would (most saints
// were added in ways that override the daily psalms — not wanted here).
// Authored per-feast, not per-Common (explicit choice) — known,
// deliberate gap: most duplex-majus+ saints across the year don't have
// one of these authored yet and fall back to the plain weekday default
// until they do (same incremental-content pattern as everywhere else).
| { kind: 'lauds-psalmody' }
// Lauds only. The weekday-resolved chapter/short-responsory/hymn/closing
// versicle bundle that follows the psalmody — see hours/lauds.ts and
// data/propers/common/lauds-{capitulum,responsory,hymn,versicle}-*.yml.
// Expands into 4 ResolvedParts (chapter, responsory, hymn, versicle).
| { kind: 'lauds-office' }
// Vespers only. The weekday-resolved 4-psalm-group psalmody — see
// hours/vespers.ts and data/hours/vespers-antiphons.yml. Opaque for the
// same reason as 'lauds-psalmody': each group's antiphon covers either
// one whole psalm, two whole psalms said together under one shared
// antiphon (Ps 115+116, Monday), or two verse-ranges of one psalm split
// under one shared antiphon (Ps 138/143, Thu/Fri) — no existing part
// kind represents that. No per-feast override mechanism yet (unlike
// Lauds' getPsalmodyOverrideFor) — every day renders the plain weekday
// default for now.
| { kind: 'vespers-psalmody' }
// Vespers only. The weekday-resolved chapter/short-responsory/hymn/
// versicle bundle that follows the psalmody — see hours/vespers.ts and
// data/propers/common/vespers-{capitulum,responsory,hymn,versicle}-*.yml.
// Expands into 4 ResolvedParts (chapter, responsory, hymn, versicle).
| { kind: 'vespers-office' }
// Vespers only. Magnificat (Luke 1:46-55), fixed text, framed by a
// day-resolved antiphon — see hours/resolve-common.ts's
// getMagnificatAntiphon, which mirrors getBenedictusAntiphon's
// saint/temporal-id lookup but also falls back to a real weekday
// default (data/hours/vespers-magnificat-antiphons.yml, Mon-Sat) that
// Benedictus has no equivalent of. Same incipit-or-full/full-after
// framing as benedictus/nunc-dimittis, gated by isDoubleOrHigher.
| { kind: 'magnificat' }
// Lauds only. Benedictus (Luke 1:68-79), fixed text (see propers/common/
// benedictus.yml) framed by a day-resolved antiphon (see hours/
// resolve-common.ts's getBenedictusAntiphon) — same incipit-or-full/
// full-after-Creed... no Creed at Lauds, just before+after framing shape
// as nunc-dimittis, gated the same way by isDoubleOrHigher.
| { kind: 'benedictus' }
// Lauds and Vespers. The four fixed Tridentine suffrages (Holy Cross,
// BVM, Ss. Peter & Paul, Peace) said after the day's collect(s) — see
// data/propers/common/lauds-suffrage-*.yml (reused byte-identical at
// Vespers, same fixed formula after either hour, live-verified
// 2026-08-25 — see hours/resolve-common.ts's resolveSuffrages).
// `omitOnDouble` gates the whole set at once (real practice: suffrages
// drop on a Double-or-higher feast) — an honest simplification, see
// resolveSuffrages for the caveat on what real practice also
// suppresses them for that isn't modeled.
| { kind: 'suffrages'; omitOnDouble?: true }
// Lauds only. The Tridentine 1906/1910 "Preces feriales" litany (Kyrie,
// Pater noster, a long chain of versicles, Psalm 129, closing versicles)
// -- content Monastic 1617 doesn't have at Lauds at all, sourced from a
// different track per direct instruction, same as lauds-suffrage-
// joseph.yml. Real 1906/1910 practice restricts this further still
// (Advent/Lent/Ember days, or an explicit day's own rubric flag) --
// deliberately widened here to *every* ferial or vigil day, mirroring
// the same explicit choice already made for Prime's own ferial Preces
// (see data/hours/prime.yml's header). Replaces `lauds-short-litany` on
// those days rather than stacking with it -- see hours/lauds.ts's
// 'lauds-preces' case for the exact ferial-or-vigil predicate, which is
// deliberately not the shared `isSundayOrFeast` (a Vigil counts as a
// "feast" there; here it needs to count as ferial-like instead).
| { kind: 'lauds-preces' }
// Vespers only. Mirrors 'lauds-preces' exactly — the Tridentine
// 1906/1910 "Preces feriales Vespera" (source: `@:Preces feriales
// Laudes:s/129/50/` — byte-for-byte the Lauds ferial Preces with Psalm
// 129 swapped for Psalm 50, live-verified against a clean Advent feria
// 2026-12-17) on a ferial or vigil day, replacing the short Sunday/feast
// litany otherwise said before the day's collect(s) — see hours/
// resolve-common.ts's isFerialOrVigil (shared with 'lauds-preces') and
// hours/vespers.ts's 'vespers-preces' case. Unlike Lauds, the short form
// here reuses `lauds-short-litany` directly rather than a Vespers-named
// duplicate — confirmed byte-identical by live query at both hours,
// same reuse discipline as data/hours/vespers.yml's `lauds-conclusio`
// closing.
| { kind: 'vespers-preces' }
// Lauds/Vespers. The day's own collect *and* one more per commemoration
// (calendar/types.ts's LiturgicalDay.commemorations) — see hours/
// resolve-common.ts's getDayCollects. Distinct from the singular
// 'day-collect' the Little Hours use (which only ever renders the
// winner's own collect, never a commemoration's). Simplification: each
// collect renders as its own complete "Let us pray ... Amen." block
// rather than the more compressed real form (one "Let us pray," only the
// last collect closes with the full doxology) — see hours/lauds.ts.
| { kind: 'day-collects' }
// Compline's closing Marian antiphon (with its own versicle and collect
// baked into the same text block) — resolved by season, defaulting to
// 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' }
// 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;
parts: HourPart[];
}
/** A part with its text actually filled in, ready for the UI to render. */
export type ResolvedPart =
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText }
| { kind: 'preces'; text: ResolvedText; label?: string }
// `gloriaPatri` is filled in centrally (hours/index.ts, after every hour
// builder returns) rather than per-hour, so it's absent only during the
// Sacred Triduum (calendar/temporal.ts's isInTriduum) — never omit it
// when building a psalm part yourself.
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[]; gloriaPatri?: ResolvedText }
// `gloriaPatri` filled in centrally, same as the 'psalm' kind above —
// see that field's doc comment.
| { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText; gloriaPatri?: ResolvedText }
// A standalone antiphon, said on its own rather than framing a psalm —
// see 'closing-antiphon' above.
| { kind: 'antiphon'; text: ResolvedText }
// Matins only. Groups a nocturn's psalms for the UI — each entry is a
// plain 'psalm' ResolvedPart (Sunday/Duplex+ nocturns 1-2 use the fixed
// Ps 20-31 table, nocturn 3 uses 'canticle' entries instead; ferial days
// have a single unnumbered nocturn and don't use this wrapper at all —
// see hours/matins.ts). `nocturn` is 1-3.
| { kind: 'nocturn-psalmody'; nocturn: number; psalms: ResolvedPart[] }
// A standalone structural heading with no content of its own — marks the
// start of a larger named section that doesn't correspond to one specific
// part kind (Matins' "Invitatory", "Nocturn I/II/III"). Distinct from the
// per-part `<h3 class="ordo-part-label">` every other kind already uses.
| { kind: 'section-heading'; label: string }
// Matins only. Sung after the last lesson on a 3-nocturn day (Sunday or
// a Duplex+ sanctoral winner) — see hours/matins.ts. Never present on a
// plain ferial (1-nocturn) day.
| { kind: 'te-deum'; text: ResolvedText }
// Martyrology and Regula both render as a block of prose text with a
// label (a fixed section name for Martyrology, a date-derived chapter
// title for Regula) rather than a fixed textRef.
// `nocturn`/`source` are Matins-only (see hours/matins.ts): `nocturn`
// groups a reading under its 'nocturn-psalmody' sibling for the UI (1-3,
// omitted on a ferial 1-nocturn day); `source` is a human-readable
// attribution ("St. John Chrysostom, Homily 3 on Matthew"; the user's
// own bible-reading plan doesn't carry one). `label` is usually that same
// plain string (nocturn-readings' own `source`, already written once in
// English and reused as-is in both language columns), but octave
// readings' own `source` is a genuine bilingual attribution (their
// English title is a real translation of a Latin incipit, not an
// editorial description written once) so `label` accepts either shape —
// see hour-view.ts's renderLessonLabel. `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. A Gospel pericope is never
// rendered with this kind — see 'gospel' below.
| {
kind: 'lesson';
text: ResolvedText;
label?: string | Partial<Record<string, string>>;
nocturn?: number;
source?: string;
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 {
n: number;
text: Partial<Record<string, string>>;
status: Partial<Record<string, 'verified' | 'draft' | 'missing'>>;
}
export interface ResolvedText {
text: Partial<Record<string, string>>;
status: Partial<Record<string, 'verified' | 'draft' | 'missing'>>;
/** e.g. "1 Tim 1:17" — set when the text is a scripture quotation. Always
* given in full (book, chapter, verse) rather than just a book name. */
citation?: Partial<Record<string, string>>;
}
/**
* The "ordo" for one hour on one day — the fully resolved output of
* combining calendar resolution, the psalter distribution, and an
* HourDefinition. This is the seam between the engine and the UI: the UI
* never needs to know whether a part came from a static YAML row or a
* resolved rule.
*/
export interface ResolvedOrdo {
hourId: HourId;
date: string;
parts: ResolvedPart[];
/** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */
notImplemented?: true;
/**
* "Monday in the 10th week after Trinity", "St. Ereden — ...", etc.,
* keyed by language code (`en`/`la`) — see calendar/day-label.ts.
* Computed from whichever LiturgicalDay the hour actually resolved
* against, which can differ from `date` itself (Compline may anticipate
* tomorrow — see calendar/vespers.ts). Absent on not-yet-built hours.
*/
dayLabel?: Partial<Record<string, string>>;
}