Add nameLa mechanism for bilingual header, proof set of 11 feasts + St. Lawrence
Deploy / deploy (push) Successful in 1m32s

Threads an optional nameLa field through SanctoralIdentity/SaintRecord/
TemporalFeastRecord/ActiveOctave so day-label.ts can render real Latin
proper names instead of always falling back to English. Authors the
proof set: all 11 named temporal feasts plus St. Lawrence (chosen to
exercise the octave-headline and octave-commemoration code paths too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwsZQMjALCvy7u9tFDWmuQ
This commit is contained in:
2026-08-31 06:30:48 -04:00
parent 76cc363a75
commit a6261f044a
27 changed files with 190 additions and 50 deletions
+50 -4
View File
@@ -34,10 +34,17 @@ across every hour and content type in this app.
date/entry already resolves correctly end-to-end); what's left is authoring/importing more date/entry already resolves correctly end-to-end); what's left is authoring/importing more
content in that same shape across the rest of the calendar: content in that same shape across the rest of the calendar:
- Latin saint/octave/named-feast names for the bilingual date/day-label header (see - Latin saint/octave/named-feast names for the bilingual date/day-label header (see
"Bilingual, merged date/day-label header" below) — the `{en, la}` mechanism is built and "Bilingual, merged date/day-label header" below and the dated log's new entry at the
every proper name falls back to English gracefully, but zero saints, octaves, or named bottom of this file) — `SanctoralIdentity`/`SaintRecord`/`TemporalFeastRecord`/
temporal feasts have an authored Latin name anywhere in `src/data/**`. Weekday names, `ActiveOctave` now carry a real optional `nameLa` field, threaded end-to-end through
month names, rank labels, and season/ordinal phrasing already have real Latin. `calendar/day-label.ts`'s `biName` helper (live-verified: winner, octave-headline, and
commemoration branches all render authored Latin, not the English fallback, for a proof
set of 12 records — all 11 named temporal feasts plus St. Lawrence). Zero saints besides
St. Lawrence have an authored `nameLa` yet — this is now real bulk-content work in an
already-proven shape, same "one saint at a time, sourced against the reference engine's
own `[Officium]`/`[Rank]` title, nominative-noun-phrase form" methodology used for the
named temporal feasts. Weekday names, month names, rank labels, and season/ordinal
phrasing already had real Latin before this.
- Matins sanctoral-side readings: the per-book Matins responsory pool has only one book - Matins sanctoral-side readings: the per-book Matins responsory pool has only one book
seeded (`isa`) — needs expanding. Bible-plan TSV content also isn't yet in a test table seeded (`isa`) — needs expanding. Bible-plan TSV content also isn't yet in a test table
(see "Bible-plan TSV — bulk conversion done" below). (see "Bible-plan TSV — bulk conversion done" below).
@@ -4383,3 +4390,42 @@ not just under this app's implementation of them.
Neither is "unauthored content pending" — there is no historical text for either case to author. Neither is "unauthored content pending" — there is no historical text for either case to author.
Dropped from the "Open work" list entirely rather than marked done-with-a-caveat, since authoring Dropped from the "Open work" list entirely rather than marked done-with-a-caveat, since authoring
something anyway would mean inventing text that never existed. something anyway would mean inventing text that never existed.
### Bilingual header `nameLa` mechanism (2026-08-31)
Picked up the standing "Latin saint/octave/named-feast names" backlog item. The `Bi`
(`{en, la}`) *display* wrapper already existed in `calendar/day-label.ts`, but the underlying
*data* had no bilingual name field at all — `SaintRecord.name`, `TemporalFeastRecord.name`, and
the derived `SanctoralIdentity`/`ActiveOctave`/`Commemoration` types were plain English strings,
so every proper name necessarily fell back through `biFallback`.
Added an optional `nameLa?: string` alongside `name` on `SanctoralIdentity` (`calendar/types.ts`),
`SaintRecord`/`SanctoralCandidate` (`calendar/feasts.ts`), `TemporalFeastRecord`
(`calendar/temporal-feasts.ts`), and `ActiveOctave` (`calendar/octaves.ts`), then threaded it
through every construction site that had been spreading `id`/`name`/`rank` (`collision.ts`,
`commemorations.ts`, `movable-feasts.ts`, `index.ts` x3, `vespers.ts` x2,
`octaves.ts`'s `considerCandidate`). `day-label.ts`'s old `biFallback(name)` calls became
`biName(name, nameLa)`, which still falls back to the English string when `nameLa` is absent —
the "surface real text, don't block on missing" convention preserved exactly, just now backed by
a real field instead of no field at all.
Authored `nameLa` for all 11 named temporal feasts as the proof set (each sourced from the
reference engine's own `[Officium]`/`[Rank]` title, adapted from the source's genitive/ablative
liturgical-title style to this app's nominative-noun-phrase naming convention — e.g. "In
Ascensione Domini" → `Ascensio Domini`, "Sacratissimi Cordis Domini Nostri Jesu Christi" →
`Sacratissimum Cor Domini Nostri Jesu Christi`) plus St. Lawrence as the one saint, chosen
specifically to exercise the octave-headline (`octaveCoreName`) and octave-commemoration code
paths, not just the plain-winner path the temporal feasts alone would have covered. Live-verified
end-to-end via a throwaway scratch test (removed after confirming) that Pentecost's/the
Ascension's/the Immaculate Heart's own winner label, St. Lawrence's mid-octave and closing-day
octave label, and his octave *commemoration* line (Aug 12) all render the authored Latin, not the
English fallback. Six pre-existing `toEqual` test fixtures across `august-sanctoral.test.ts`,
`octaves.test.ts`, `transfer.test.ts`, and `december-sanctoral.test.ts` needed their expected
objects updated to include the new `nameLa` key (Christmas's own octave-commemoration entries too,
since `christmas-day.yml` picked up a name in this same pass). `npm test` (807 passed) and
`tsc --noEmit` pass.
**Not started**: `nameLa` for the other ~220 saints — the mechanism is now proven in all three
code paths (plain winner, octave headline, sanctoral commemoration); what's left is the same
per-saint sourcing pass the sanctoral-content sweep itself used, just for a name instead of a
collect.
+2 -2
View File
@@ -19,11 +19,11 @@ export interface CollisionResult {
} }
function asWinner(candidate: SanctoralIdentity): DayWinner { function asWinner(candidate: SanctoralIdentity): DayWinner {
return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; return { kind: 'sanctoral', id: candidate.id, name: candidate.name, nameLa: candidate.nameLa, rank: candidate.rank };
} }
function asCommemoration(candidate: SanctoralIdentity): Commemoration { function asCommemoration(candidate: SanctoralIdentity): Commemoration {
return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; return { kind: 'sanctoral', id: candidate.id, name: candidate.name, nameLa: candidate.nameLa, rank: candidate.rank };
} }
/** /**
+2 -2
View File
@@ -83,11 +83,11 @@ export interface OccurrenceResult {
} }
function sanctoralCommemoration(candidate: SanctoralIdentity): Commemoration { function sanctoralCommemoration(candidate: SanctoralIdentity): Commemoration {
return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; return { kind: 'sanctoral', id: candidate.id, name: candidate.name, nameLa: candidate.nameLa, rank: candidate.rank };
} }
function sanctoralWinner(candidate: SanctoralIdentity): DayWinner { function sanctoralWinner(candidate: SanctoralIdentity): DayWinner {
return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; return { kind: 'sanctoral', id: candidate.id, name: candidate.name, nameLa: candidate.nameLa, rank: candidate.rank };
} }
/** /**
+23 -20
View File
@@ -12,12 +12,14 @@
// //
// Bilingual: every label is built as a `Bi` (`{en, la}`) pair throughout. // Bilingual: every label is built as a `Bi` (`{en, la}`) pair throughout.
// Proper names (a saint's name, an octave's name, a named temporal feast's // Proper names (a saint's name, an octave's name, a named temporal feast's
// name) have no authored Latin form anywhere in `data/` today — those use // name) carry an optional `nameLa` field alongside the required English
// the English string as their own `la` value too (same "surface real text // `name` — see calendar/types.ts's `SanctoralIdentity.nameLa` doc comment.
// rather than block on missing" convention used elsewhere in this app), // Most saints still have no authored Latin name; `biName` falls back to
// rather than leaving Latin mode showing blanks. Only the closed, // the English string as the `la` value in that case (same "surface real
// text rather than block on missing" convention used elsewhere in this
// app), rather than leaving Latin mode showing blanks. The closed,
// mechanism-level vocabulary here (weekdays, ranks, season/ordinal // mechanism-level vocabulary here (weekdays, ranks, season/ordinal
// phrasing) has real Latin authored. // phrasing) has always had real Latin authored, independent of this.
import type { FeastClass, LiturgicalDay, TemporalCategory, Weekday } from './types'; import type { FeastClass, LiturgicalDay, TemporalCategory, Weekday } from './types';
import { easterSunday } from './easter'; import { easterSunday } from './easter';
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
@@ -36,10 +38,11 @@ function bi(en: string, la: string): Bi {
return { en, la }; return { en, la };
} }
/** A proper name with no authored Latin form yet — the English string /** A proper name (saint, octave, named temporal feast) with an optional
* stands in for `la` too. See file-header note. */ * authored Latin form — falls back to the English string when `nameLa`
function biFallback(name: string): Bi { * hasn't been authored yet, same convention as `biFallback` above. */
return { en: name, la: name }; function biName(name: string, nameLa: string | undefined): Bi {
return { en: name, la: nameLa ?? name };
} }
function joinBi(items: Bi[], sep = ' — '): Bi { function joinBi(items: Bi[], sep = ' — '): Bi {
@@ -348,13 +351,13 @@ function temporalLabel(day: LiturgicalDay): Bi {
* own winner is the feast itself, handled above before this is ever * own winner is the feast itself, handled above before this is ever
* called) — kept simple rather than special-cased for that rare edge case * called) — kept simple rather than special-cased for that rare edge case
* (see applyOctaves's own `isOwnStartDay` comment in calendar/index.ts for * (see applyOctaves's own `isOwnStartDay` comment in calendar/index.ts for
* when it can happen). The octave's own `name` has no authored Latin form * when it can happen). Falls back to the English name for `la` too when
* (see file header), so both languages currently show the same name text * the octave's own record has no authored `nameLa` yet (see file header). */
* — only the surrounding phrasing differs. */
function octaveCoreName(octave: ActiveOctave): Bi { function octaveCoreName(octave: ActiveOctave): Bi {
const name = biName(octave.name, octave.nameLa);
return octave.isClosingDay return octave.isClosingDay
? bi(`Octave of ${octave.name}`, `In Octava ${octave.name}`) ? bi(`Octave of ${name.en}`, `In Octava ${name.la}`)
: bi(`${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`, `${ordinalLa(octave.dayNumber)} die infra Octavam ${octave.name}`); : bi(`${ordinal(octave.dayNumber)} Day within the Octave of ${name.en}`, `${ordinalLa(octave.dayNumber)} die infra Octavam ${name.la}`);
} }
function octaveLabel(octave: ActiveOctave): Bi { function octaveLabel(octave: ActiveOctave): Bi {
@@ -405,8 +408,8 @@ const CROSS_DAY_VESPERS_LABELS: Record<'today' | 'tomorrow', Bi> = {
* - `sanctoral`: every commemorated saint, plain name, no rank (multiple * - `sanctoral`: every commemorated saint, plain name, no rank (multiple
* real ones can coexist — e.g. two colliding saints on the same date — * real ones can coexist — e.g. two colliding saints on the same date —
* this app's own "generous commemorations" design keeps every one of * this app's own "generous commemorations" design keeps every one of
* them, not just the first). No authored Latin saint names exist yet * them, not just the first). `la` uses the saint's own `nameLa` when
* (see file header), so `la` falls back to the same English name. * authored, else falls back to the same English name (see file header).
* - `temporal`: the day's own real Sunday/feria identity, if * - `temporal`: the day's own real Sunday/feria identity, if
* `decideOccurrence` demoted it to a commemoration (`ordinary-sunday`/ * `decideOccurrence` demoted it to a commemoration (`ordinary-sunday`/
* `privileged-feria`/`privileged-feria-minor`) — matched by id against * `privileged-feria`/`privileged-feria-minor`) — matched by id against
@@ -438,7 +441,7 @@ function collectCommemorations(
const octaves: Bi[] = []; const octaves: Bi[] = [];
for (const c of day.commemorations) { for (const c of day.commemorations) {
if (c.kind === 'sanctoral') { if (c.kind === 'sanctoral') {
const name = biFallback(c.name); const name = biName(c.name, c.nameLa);
sanctoral.push( sanctoral.push(
c.vespersNote c.vespersNote
? bi(`${name.en} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].en})`, `${name.la} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].la})`) ? bi(`${name.en} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].en})`, `${name.la} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].la})`)
@@ -450,7 +453,7 @@ function collectCommemorations(
} }
} else if (opts.showOctaves && c.id !== opts.excludeOctaveId) { } else if (opts.showOctaves && c.id !== opts.excludeOctaveId) {
const octave = activeOctaves.find((a) => a.id === c.id); const octave = activeOctaves.find((a) => a.id === c.id);
octaves.push(octave ? octaveCommemorationLabel(octave) : biFallback(c.name)); octaves.push(octave ? octaveCommemorationLabel(octave) : biName(c.name, c.nameLa));
} }
} }
return { sanctoral, temporal, octaves }; return { sanctoral, temporal, octaves };
@@ -542,7 +545,7 @@ export function getDayLabel(day: LiturgicalDay): Partial<Record<string, string>>
showOctaves: day.temporalCategory === 'ordinary-feria', showOctaves: day.temporalCategory === 'ordinary-feria',
}); });
const rank = formatRank(day.winner.rank); const rank = formatRank(day.winner.rank);
const winnerName = biFallback(day.winner.name); const winnerName = biName(day.winner.name, day.winner.nameLa);
const winner = bi(`${winnerName.en} (${rank.en})`, `${winnerName.la} (${rank.la})`); const winner = bi(`${winnerName.en} (${rank.en})`, `${winnerName.la} (${rank.la})`);
return joinBi([winner, ...temporal, ...sanctoral, ...octaves]); return joinBi([winner, ...temporal, ...sanctoral, ...octaves]);
} }
@@ -554,7 +557,7 @@ export function getDayLabel(day: LiturgicalDay): Partial<Record<string, string>>
// temporal-feasts.ts) and fall through further down. // temporal-feasts.ts) and fall through further down.
const namedFeast = getTemporalFeastRecord(day.winner.id); const namedFeast = getTemporalFeastRecord(day.winner.id);
if (namedFeast) { if (namedFeast) {
const name = biFallback(namedFeast.name); const name = biName(namedFeast.name, namedFeast.nameLa);
const primary = namedFeast.rank const primary = namedFeast.rank
? bi(`${name.en} (${formatRank(namedFeast.rank).en})`, `${name.la} (${formatRank(namedFeast.rank).la})`) ? bi(`${name.en} (${formatRank(namedFeast.rank).en})`, `${name.la} (${formatRank(namedFeast.rank).la})`)
: name; : name;
+5 -1
View File
@@ -15,6 +15,9 @@ import sanctoralCalendarData from '../data/calendar/sanctoral-calendar.yml';
export interface SaintRecord { export interface SaintRecord {
id: string; id: string;
name: string; name: string;
/** Latin form of `name`, when authored — see calendar/types.ts's
* `SanctoralIdentity.nameLa` doc comment for the display fallback. */
nameLa?: string;
rank: FeastClass; rank: FeastClass;
common: string; common: string;
propers: string | null; propers: string | null;
@@ -138,6 +141,7 @@ for (const mod of Object.values(saintModules)) {
export interface SanctoralCandidate { export interface SanctoralCandidate {
id: string; id: string;
name: string; name: string;
nameLa?: string;
rank: FeastClass; rank: FeastClass;
} }
@@ -157,6 +161,6 @@ export function getSanctoralCandidatesFor(isoDate: string): SanctoralCandidate[]
if (!saint) { if (!saint) {
throw new Error(`sanctoral-calendar.yml references unknown saint id '${id}'`); throw new Error(`sanctoral-calendar.yml references unknown saint id '${id}'`);
} }
return { id: saint.id, name: saint.name, rank: saint.rank }; return { id: saint.id, name: saint.name, nameLa: saint.nameLa, rank: saint.rank };
}); });
} }
+4 -4
View File
@@ -237,7 +237,7 @@ function applyChristmasOctaveSunday(
if (idx !== -1) { if (idx !== -1) {
commemorations.splice(idx, 1); commemorations.splice(idx, 1);
} }
return { kind: 'sanctoral', id: displaced.id, name: displaced.name, rank: displaced.rank }; return { kind: 'sanctoral', id: displaced.id, name: displaced.name, nameLa: displaced.nameLa, rank: displaced.rank };
} }
} }
@@ -290,7 +290,7 @@ function applyMarianSaturday(
if (winner.rank !== 'simplex') { if (winner.rank !== 'simplex') {
return winner; return winner;
} }
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank }); commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, nameLa: winner.nameLa, rank: winner.rank });
return MARIAN_SATURDAY; return MARIAN_SATURDAY;
} }
@@ -343,7 +343,7 @@ function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commem
if (cmp < 0 || tiedAgainstClosingDay) { if (cmp < 0 || tiedAgainstClosingDay) {
const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id); const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
if (!isOneOfTheseOctaves) { if (!isOneOfTheseOctaves) {
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank }); commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, nameLa: winner.nameLa, rank: winner.rank });
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) }; resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
} }
} }
@@ -356,7 +356,7 @@ function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commem
// again alongside itself would be redundant. // again alongside itself would be redundant.
const isOwnStartDay = octave.dayNumber === 1 && resolvedWinner.kind === 'temporal'; const isOwnStartDay = octave.dayNumber === 1 && resolvedWinner.kind === 'temporal';
if (!isSelf && !isOwnStartDay) { if (!isSelf && !isOwnStartDay) {
commemorations.push({ kind: 'octave', id: octave.id, name: octave.name }); commemorations.push({ kind: 'octave', id: octave.id, name: octave.name, nameLa: octave.nameLa });
} }
} }
+2 -2
View File
@@ -83,7 +83,7 @@ export function applyMovableFeasts(isoDate: string, winner: DayWinner, commemora
if (resolvedWinner.kind === 'temporal') { if (resolvedWinner.kind === 'temporal') {
commemorations.push({ kind: 'temporal', id: resolvedWinner.id }); commemorations.push({ kind: 'temporal', id: resolvedWinner.id });
} else { } else {
commemorations.push({ kind: 'sanctoral', id: resolvedWinner.id, name: resolvedWinner.name, rank: resolvedWinner.rank }); commemorations.push({ kind: 'sanctoral', id: resolvedWinner.id, name: resolvedWinner.name, nameLa: resolvedWinner.nameLa, rank: resolvedWinner.rank });
} }
resolvedWinner = FEAST_WINNER; resolvedWinner = FEAST_WINNER;
continue; continue;
@@ -95,7 +95,7 @@ export function applyMovableFeasts(isoDate: string, winner: DayWinner, commemora
} else if (compareFeastClass(resolvedWinner.rank, rank) >= 0) { } else if (compareFeastClass(resolvedWinner.rank, rank) >= 0) {
commemorations.push({ kind: 'temporal', id: feast.id }); commemorations.push({ kind: 'temporal', id: feast.id });
} else { } else {
commemorations.push({ kind: 'sanctoral', id: resolvedWinner.id, name: resolvedWinner.name, rank: resolvedWinner.rank }); commemorations.push({ kind: 'sanctoral', id: resolvedWinner.id, name: resolvedWinner.name, nameLa: resolvedWinner.nameLa, rank: resolvedWinner.rank });
resolvedWinner = FEAST_WINNER; resolvedWinner = FEAST_WINNER;
} }
} }
+7 -3
View File
@@ -17,6 +17,9 @@ import { compareFeastClass, minimumOutrightWinningRank } from './commemorations'
export interface ActiveOctave { export interface ActiveOctave {
id: string; id: string;
name: string; name: string;
/** Latin form of `name`, when authored — see calendar/types.ts's
* `SanctoralIdentity.nameLa` doc comment for the display fallback. */
nameLa?: string;
/** This octave's own effective rank *today* — the ordinary `wins` /** This octave's own effective rank *today* — the ordinary `wins`
* threshold on every day except its own closing day, where it's * threshold on every day except its own closing day, where it's
* `closingDayRank` instead (see calendar/types.ts's OctaveConfig). * `closingDayRank` instead (see calendar/types.ts's OctaveConfig).
@@ -49,6 +52,7 @@ function considerCandidate(
startDate: string, startDate: string,
id: string, id: string,
name: string, name: string,
nameLa: string | undefined,
octave: OctaveConfig | undefined, octave: OctaveConfig | undefined,
): void { ): void {
if (!octave?.enabled || seen.has(id)) { if (!octave?.enabled || seen.has(id)) {
@@ -63,7 +67,7 @@ function considerCandidate(
const dayNumber = offset + 1; const dayNumber = offset + 1;
const isClosingDay = dayNumber === days; const isClosingDay = dayNumber === days;
const wins = isClosingDay ? (octave.closingDayRank ?? DEFAULT_CLOSING_DAY_RANK) : (octave.wins ?? DEFAULT_WINS); const wins = isClosingDay ? (octave.closingDayRank ?? DEFAULT_CLOSING_DAY_RANK) : (octave.wins ?? DEFAULT_WINS);
active.push({ id, name, wins, dayNumber, isClosingDay }); active.push({ id, name, nameLa, wins, dayNumber, isClosingDay });
} }
/** Every octave (sanctoral or temporal) whose window covers `isoDate`, /** Every octave (sanctoral or temporal) whose window covers `isoDate`,
@@ -80,14 +84,14 @@ export function activeOctavesFor(isoDate: string): ActiveOctave[] {
for (const candidate of getSanctoralCandidatesFor(candidateDate)) { for (const candidate of getSanctoralCandidatesFor(candidateDate)) {
const record = getSaintRecord(candidate.id); const record = getSaintRecord(candidate.id);
if (record) { if (record) {
considerCandidate(active, seen, isoDate, candidateDate, record.id, record.name, record.octave); considerCandidate(active, seen, isoDate, candidateDate, record.id, record.name, record.nameLa, record.octave);
} }
} }
for (const feastId of temporalFeastIdsStartingOn(candidateDate)) { for (const feastId of temporalFeastIdsStartingOn(candidateDate)) {
const record = getTemporalFeastRecord(feastId); const record = getTemporalFeastRecord(feastId);
if (record) { if (record) {
considerCandidate(active, seen, isoDate, candidateDate, record.id, record.name, record.octave); considerCandidate(active, seen, isoDate, candidateDate, record.id, record.name, record.nameLa, record.octave);
} }
} }
} }
+3
View File
@@ -30,6 +30,9 @@ export type MovableAnchor =
export interface TemporalFeastRecord { export interface TemporalFeastRecord {
id: string; id: string;
name: string; name: string;
/** Latin form of `name`, when authored — see calendar/types.ts's
* `SanctoralIdentity.nameLa` doc comment for the display fallback. */
nameLa?: string;
octave?: OctaveConfig; octave?: OctaveConfig;
rank?: FeastClass; rank?: FeastClass;
anchor?: MovableAnchor; anchor?: MovableAnchor;
+6 -1
View File
@@ -103,6 +103,11 @@ export type TemporalCategory =
export interface SanctoralIdentity { export interface SanctoralIdentity {
id: string; id: string;
name: string; name: string;
/** Latin form of `name`, when authored (see `data/calendar/saints/*.yml`'s
* own `nameLa` field). Absent for the majority of saints still — display
* code falls back to `name` itself when this is missing, same "surface
* real text rather than block on missing" convention used elsewhere. */
nameLa?: string;
rank: FeastClass; rank: FeastClass;
} }
@@ -180,7 +185,7 @@ export type Commemoration =
// carries no such qualifier. // carries no such qualifier.
vespersNote?: 'today' | 'tomorrow'; vespersNote?: 'today' | 'tomorrow';
} & SanctoralIdentity) } & SanctoralIdentity)
| { kind: 'octave'; id: string; name: string }; | { kind: 'octave'; id: string; name: string; nameLa?: string };
export interface LiturgicalDay { export interface LiturgicalDay {
/** ISO date, e.g. "2026-08-09" */ /** ISO date, e.g. "2026-08-09" */
+2 -2
View File
@@ -85,7 +85,7 @@ function commemorationOfDisplacedPredecessor(loser: DayWinner): Commemoration |
if (loser.kind !== 'sanctoral' || loser.rank === 'simplex') { if (loser.kind !== 'sanctoral' || loser.rank === 'simplex') {
return undefined; return undefined;
} }
return { kind: 'sanctoral', id: loser.id, name: loser.name, rank: loser.rank, vespersNote: 'today' }; return { kind: 'sanctoral', id: loser.id, name: loser.name, nameLa: loser.nameLa, rank: loser.rank, vespersNote: 'today' };
} }
/** /**
@@ -120,7 +120,7 @@ function commemorationOfDisplacedSuccessor(winner: DayWinner, loser: DayWinner):
if (forbids) { if (forbids) {
return undefined; return undefined;
} }
return { kind: 'sanctoral', id: loser.id, name: loser.name, rank: loser.rank, vespersNote: 'tomorrow' }; return { kind: 'sanctoral', id: loser.id, name: loser.name, nameLa: loser.nameLa, rank: loser.rank, vespersNote: 'tomorrow' };
} }
function tagVespersFrom(tomorrow: LiturgicalDay): LiturgicalDay { function tagVespersFrom(tomorrow: LiturgicalDay): LiturgicalDay {
+4
View File
@@ -6,6 +6,10 @@
# antiphon. # antiphon.
id: st-lawrence id: st-lawrence
name: "St. Lawrence, Martyr" name: "St. Lawrence, Martyr"
# Standard Latin form, matching this app's own "St. X, Rank" naming
# convention (the source's own martyrology/kalendar entries for him use
# the genitive "S. Laurentii Martyris", dependent on an implied "Festum").
nameLa: "Sanctus Laurentius, Martyr"
rank: duplex-2-classis rank: duplex-2-classis
common: common-of-a-martyr common: common-of-a-martyr
propers: "st-lawrence" propers: "st-lawrence"
@@ -15,6 +15,11 @@
# — the `ordinary-feria` category was never actually load-bearing here. # — the `ordinary-feria` category was never actually load-bearing here.
id: ascension id: ascension
name: "The Ascension of Our Lord" name: "The Ascension of Our Lord"
# Nominative form of the reference engine's own title ("In Ascensione
# Domini", Pasc5-4.txt's [Officium]/[Rank] header), matching this app's
# own nominative-noun-phrase naming convention rather than the source's
# prepositional-phrase style.
nameLa: "Ascensio Domini"
rank: duplex-1-classis rank: duplex-1-classis
anchor: anchor:
kind: easter kind: easter
@@ -8,6 +8,11 @@
# movable feast. See calendar/movable-feasts.ts for the occurrence rule. # movable feast. See calendar/movable-feasts.ts for the occurrence rule.
id: christ-the-king id: christ-the-king
name: "Christ the King" name: "Christ the King"
# The feast's own traditional short Latin title (Pius XI's "Quas Primas"),
# matching this app's nominative-noun-phrase naming convention — the
# reference engine's own [Rank] title is genitive ("Domini Nostri Jesu
# Christi Regis", Sancti/10-DU.txt), dependent on an implied "Festum".
nameLa: "Christus Rex"
rank: duplex-1-classis rank: duplex-1-classis
anchor: anchor:
kind: nth-sunday-of-month kind: nth-sunday-of-month
@@ -12,6 +12,10 @@
# specifically. # specifically.
id: christmas-day id: christmas-day
name: "Christmas" name: "Christmas"
# Nominative form of the reference engine's own title ("In Nativitate
# Domini", Sancti/12-25.txt's [Rank]), matching this app's own
# nominative-noun-phrase naming convention.
nameLa: "Nativitas Domini"
rank: duplex-1-classis rank: duplex-1-classis
octave: octave:
enabled: true enabled: true
@@ -20,6 +20,10 @@
# not winner resolution at all. # not winner resolution at all.
id: circumcision id: circumcision
name: "The Circumcision of Our Lord" name: "The Circumcision of Our Lord"
# Nominative form of the reference engine's own title ("In Circumcisione
# Domini", Sancti/01-01.txt's [Rank]), matching this app's own
# nominative-noun-phrase naming convention.
nameLa: "Circumcisio Domini"
rank: duplex-2-classis rank: duplex-2-classis
anchor: anchor:
kind: fixed-date kind: fixed-date
@@ -14,6 +14,11 @@
# day's real winner. # day's real winner.
id: corpus-christi id: corpus-christi
name: "The Most Holy Body of Christ" name: "The Most Holy Body of Christ"
# Common short Latin title, well attested in the reference engine's own
# text ("Festum Sanctissimi Corporis Christi", Tempora/Pent01-4.txt's
# [Officium]) — already a nominative noun phrase on its own, no
# adaptation needed.
nameLa: "Corpus Christi"
rank: duplex-1-classis rank: duplex-1-classis
anchor: anchor:
kind: easter kind: easter
@@ -16,6 +16,10 @@
# the day. # the day.
id: holy-name-of-jesus id: holy-name-of-jesus
name: "The Most Holy Name of Jesus" name: "The Most Holy Name of Jesus"
# Nominative form of the reference engine's own title ("Sanctissimi
# Nominis Jesu", Sancti/01-00.txt's [Rank]), matching this app's own
# nominative-noun-phrase naming convention.
nameLa: "Sanctissimum Nomen Jesu"
rank: duplex-2-classis rank: duplex-2-classis
anchor: anchor:
kind: fixed-date kind: fixed-date
@@ -11,6 +11,10 @@
# own content. # own content.
id: immaculate-heart-of-mary id: immaculate-heart-of-mary
name: "The Immaculate Heart of the Blessed Virgin Mary" name: "The Immaculate Heart of the Blessed Virgin Mary"
# Nominative form of the reference engine's own title ("Immaculati Cordis
# Beatæ Mariæ Virginis", Sancti/08-22.txt's [Rank]), matching this app's
# own nominative-noun-phrase naming convention.
nameLa: "Immaculatum Cor Beatæ Mariæ Virginis"
rank: duplex-2-classis rank: duplex-2-classis
anchor: anchor:
kind: easter kind: easter
@@ -5,4 +5,9 @@
# for how a day actually becomes this. # for how a day actually becomes this.
id: marian-saturday id: marian-saturday
name: "Our Lady's Saturday" name: "Our Lady's Saturday"
# Adapted to a nominative noun phrase from the reference engine's own
# title ("Sanctæ Mariæ Sabbato", Commune/C10.txt's [Officium] — an
# ablative time-when construction, "on the Saturday of Holy Mary"),
# matching this app's own nominative naming convention.
nameLa: "Sabbatum Sanctæ Mariæ"
rank: simplex rank: simplex
@@ -21,6 +21,11 @@
# (PascN-D = Easter + 7N + D, e.g. Ascension = Pasc5-4 = Easter+39). # (PascN-D = Easter + 7N + D, e.g. Ascension = Pasc5-4 = Easter+39).
id: patronage-of-st-joseph id: patronage-of-st-joseph
name: "The Patronage of St. Joseph" name: "The Patronage of St. Joseph"
# Nominative form of the reference engine's own title ("In Octava
# Patrocinii S. Joseph", Tempora/Pasc3-3.txt's [Officium], octave-day
# form of the feast's own genitive "Patrocinii S. Joseph"), matching this
# app's own nominative-noun-phrase naming convention.
nameLa: "Patrocinium Sancti Joseph"
rank: duplex-1-classis rank: duplex-1-classis
anchor: anchor:
kind: easter kind: easter
@@ -10,6 +10,9 @@
# before it gets the chance. # before it gets the chance.
id: pentecost-sunday id: pentecost-sunday
name: "Pentecost" name: "Pentecost"
# Reference engine's own [Officium] title, Tempora/Pent00-0.txt — already
# a nominative noun phrase, no adaptation needed.
nameLa: "Dominica Pentecostes"
rank: duplex-1-classis rank: duplex-1-classis
octave: octave:
enabled: true enabled: true
@@ -10,6 +10,10 @@
# temporal-feasts.ts's own doc comment) a record to live on. # temporal-feasts.ts's own doc comment) a record to live on.
id: sacred-heart id: sacred-heart
name: "The Most Sacred Heart of Our Lord Jesus Christ" name: "The Most Sacred Heart of Our Lord Jesus Christ"
# Nominative form of the reference engine's own title ("Sacratissimi
# Cordis Domini Nostri Jesu Christi", Tempora/Pent02-5.txt's [Officium]),
# matching this app's own nominative-noun-phrase naming convention.
nameLa: "Sacratissimum Cor Domini Nostri Jesu Christi"
rank: duplex-1-classis rank: duplex-1-classis
anchor: anchor:
kind: easter kind: easter
+2 -1
View File
@@ -71,6 +71,7 @@ describe('August sanctoral pull (first pass)', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-lawrence', id: 'st-lawrence',
name: 'St. Lawrence, Martyr', name: 'St. Lawrence, Martyr',
nameLa: 'Sanctus Laurentius, Martyr',
rank: 'duplex-2-classis', rank: 'duplex-2-classis',
}); });
expect(getDayCollect(day).status.en).toBe('verified'); expect(getDayCollect(day).status.en).toBe('verified');
@@ -86,7 +87,7 @@ describe('August sanctoral pull (first pass)', () => {
expect(clareDay.winner.kind).toBe('temporal'); expect(clareDay.winner.kind).toBe('temporal');
expect(clareDay.commemorations).toEqual([ expect(clareDay.commemorations).toEqual([
{ kind: 'sanctoral', id: 'st-clare', name: 'St. Clare, Virgin', rank: 'simplex' }, { kind: 'sanctoral', id: 'st-clare', name: 'St. Clare, Virgin', rank: 'simplex' },
{ kind: 'octave', id: 'st-lawrence', name: 'St. Lawrence, Martyr' }, { kind: 'octave', id: 'st-lawrence', name: 'St. Lawrence, Martyr', nameLa: 'Sanctus Laurentius, Martyr' },
]); ]);
// Aug 22 is the Assumption's own octave day (In Octava Assumptionis), // Aug 22 is the Assumption's own octave day (In Octava Assumptionis),
// Duplex, blocking Ss. Timothy/Hippolytus/Symphorian the same way -- // Duplex, blocking Ss. Timothy/Hippolytus/Symphorian the same way --
+3 -3
View File
@@ -84,7 +84,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
// dedicated stacking test below for the full week's build-up. // dedicated stacking test below for the full week's build-up.
expect(stephen.commemorations).toEqual([ expect(stephen.commemorations).toEqual([
{ kind: 'temporal', id: expect.any(String) }, { kind: 'temporal', id: expect.any(String) },
{ kind: 'octave', id: 'christmas-day', name: 'Christmas' }, { kind: 'octave', id: 'christmas-day', name: 'Christmas', nameLa: 'Nativitas Domini' },
]); ]);
}); });
@@ -101,7 +101,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
// all stacked up and commemorate alongside him. // all stacked up and commemorate alongside him.
expect(day.commemorations).toEqual([ expect(day.commemorations).toEqual([
{ kind: 'temporal', id: expect.any(String) }, { kind: 'temporal', id: expect.any(String) },
{ kind: 'octave', id: 'christmas-day', name: 'Christmas' }, { kind: 'octave', id: 'christmas-day', name: 'Christmas', nameLa: 'Nativitas Domini' },
{ kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' }, { kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' },
{ kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' }, { kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' },
{ kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' }, { kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' },
@@ -155,7 +155,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
const dec30 = resolveDay('2033-12-30'); // no saint of its own -- a plain Octave-of-the-Nativity day const dec30 = resolveDay('2033-12-30'); // no saint of its own -- a plain Octave-of-the-Nativity day
expect(dec30.winner.kind).toBe('temporal'); expect(dec30.winner.kind).toBe('temporal');
expect(dec30.commemorations).toEqual([ expect(dec30.commemorations).toEqual([
{ kind: 'octave', id: 'christmas-day', name: 'Christmas' }, { kind: 'octave', id: 'christmas-day', name: 'Christmas', nameLa: 'Nativitas Domini' },
{ kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' }, { kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' },
{ kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' }, { kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' },
{ kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' }, { kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' },
+24 -3
View File
@@ -10,7 +10,14 @@ describe('activeOctavesFor', () => {
it("finds a saint's own octave on its own day (day 1) and through day 8, not on day 9", () => { it("finds a saint's own octave on its own day (day 1) and through day 8, not on day 9", () => {
const day1 = activeOctavesFor('2026-08-10'); // St. Lawrence's own day const day1 = activeOctavesFor('2026-08-10'); // St. Lawrence's own day
expect(day1).toEqual([ expect(day1).toEqual([
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1, isClosingDay: false }, {
id: 'st-lawrence',
name: 'St. Lawrence, Martyr',
nameLa: 'Sanctus Laurentius, Martyr',
wins: 'semiduplex',
dayNumber: 1,
isClosingDay: false,
},
]); ]);
// Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) -- // Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) --
@@ -19,7 +26,14 @@ describe('activeOctavesFor', () => {
// pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig). // pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig).
const day8 = activeOctavesFor('2026-08-17'); const day8 = activeOctavesFor('2026-08-17');
expect(day8).toEqual([ expect(day8).toEqual([
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8, isClosingDay: true }, {
id: 'st-lawrence',
name: 'St. Lawrence, Martyr',
nameLa: 'Sanctus Laurentius, Martyr',
wins: 'duplex',
dayNumber: 8,
isClosingDay: true,
},
{ {
id: 'assumption', id: 'assumption',
name: 'The Assumption of the Blessed Virgin Mary', name: 'The Assumption of the Blessed Virgin Mary',
@@ -37,7 +51,14 @@ describe('activeOctavesFor', () => {
it("Pentecost's octave uses its own configured threshold (duplex), not the default (semiduplex)", () => { it("Pentecost's octave uses its own configured threshold (duplex), not the default (semiduplex)", () => {
const octaves = activeOctavesFor('2026-05-29'); // within Pentecost's octave, 2026 const octaves = activeOctavesFor('2026-05-29'); // within Pentecost's octave, 2026
expect(octaves).toEqual([ expect(octaves).toEqual([
{ id: 'pentecost-sunday', name: 'Pentecost', wins: 'duplex', dayNumber: 6, isClosingDay: false }, {
id: 'pentecost-sunday',
name: 'Pentecost',
nameLa: 'Dominica Pentecostes',
wins: 'duplex',
dayNumber: 6,
isClosingDay: false,
},
]); ]);
expect(strictestThreshold(octaves)).toBe('duplex'); expect(strictestThreshold(octaves)).toBe('duplex');
}); });
+3 -2
View File
@@ -46,6 +46,7 @@ describe('transfer mechanism (resolveDay integration)', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-lawrence', id: 'st-lawrence',
name: 'St. Lawrence, Martyr', name: 'St. Lawrence, Martyr',
nameLa: 'Sanctus Laurentius, Martyr',
rank: 'duplex-2-classis', rank: 'duplex-2-classis',
}); });
}); });
@@ -74,7 +75,7 @@ describe('transfer mechanism (resolveDay integration)', () => {
expect(saturday.winner).toEqual({ kind: 'temporal', id: 'pentecost-sunday' }); expect(saturday.winner).toEqual({ kind: 'temporal', id: 'pentecost-sunday' });
expect(saturday.commemorations).toEqual([ expect(saturday.commemorations).toEqual([
{ kind: 'sanctoral', id: 'st-felix-i', name: 'St. Felix I, Pope and Martyr', rank: 'simplex' }, { kind: 'sanctoral', id: 'st-felix-i', name: 'St. Felix I, Pope and Martyr', rank: 'simplex' },
{ kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost' }, { kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost', nameLa: 'Dominica Pentecostes' },
]); ]);
// Felix doesn't reach Trinity Sunday at all now. Trinity Sunday 2026 // Felix doesn't reach Trinity Sunday at all now. Trinity Sunday 2026
@@ -93,7 +94,7 @@ describe('transfer mechanism (resolveDay integration)', () => {
}); });
expect(sunday.commemorations).toEqual([ expect(sunday.commemorations).toEqual([
{ kind: 'temporal', id: 'post-pentecost-01' }, { kind: 'temporal', id: 'post-pentecost-01' },
{ kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost' }, { kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost', nameLa: 'Dominica Pentecostes' },
]); ]);
}); });
}); });