Compare commits
2 Commits
ef71d9c5bc
...
a9f673fef9
| Author | SHA1 | Date | |
|---|---|---|---|
| a9f673fef9 | |||
| e7bbc7c9f5 |
@@ -27,6 +27,11 @@ across every hour and content type in this app.
|
|||||||
2. **Minimal-proof-done, bulk content pending** — mechanism is built and trusted (a proof
|
2. **Minimal-proof-done, bulk content pending** — mechanism is built and trusted (a proof
|
||||||
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
|
||||||
|
"Bilingual, merged date/day-label header" below) — the `{en, la}` mechanism is built and
|
||||||
|
every proper name falls back to English gracefully, but zero saints, octaves, or named
|
||||||
|
temporal feasts have an authored Latin name anywhere in `src/data/**`. Weekday names,
|
||||||
|
month names, rank labels, and season/ordinal phrasing already have real Latin.
|
||||||
- Matins: bible-plan TSV bulk conversion is done (see "Bible-plan TSV — bulk conversion
|
- Matins: bible-plan TSV bulk conversion is done (see "Bible-plan TSV — bulk conversion
|
||||||
done" below) — not yet added to a test table, though (unlike sanctoral/temporal content)
|
done" below) — not yet added to a test table, though (unlike sanctoral/temporal content)
|
||||||
there's no reference breviary to spot-check this plan against by design. Still open:
|
there's no reference breviary to spot-check this plan against by design. Still open:
|
||||||
@@ -2156,6 +2161,37 @@ Trinity" for their own last Sunday, where before the fix they'd shown four diffe
|
|||||||
Tests in `tests/calendar/day-label.test.ts` updated accordingly. `npm test` (365 tests) and
|
Tests in `tests/calendar/day-label.test.ts` updated accordingly. `npm test` (365 tests) and
|
||||||
`tsc --noEmit` both pass.
|
`tsc --noEmit` both pass.
|
||||||
|
|
||||||
|
### Bilingual, merged date/day-label header — done (2026-08-18)
|
||||||
|
|
||||||
|
The civil date/weekday (`day-nav`'s date span) and the "day being celebrated" text
|
||||||
|
(`getDayLabel`, previously repeated inside every hour's own view) are now merged into one
|
||||||
|
header block in `day-nav.ts`, and both are bilingual. `getDayLabel()` returns
|
||||||
|
`Partial<Record<string,string>>` (`{en, la}`) instead of a plain English string, matching
|
||||||
|
`hours/types.ts`'s existing bilingual convention; `formatDateLong()` (`src/ui/format.ts`) is
|
||||||
|
bilingual too, since `Intl` has no Latin locale data (`MONTH_LABELS_LA` authored directly).
|
||||||
|
Latin weekday names are spelled out per direct instruction — "Feria Tertia", not "Feria III"
|
||||||
|
— `Dominica`/`Feria Secunda`...`Sexta`/`Sabbato`. Advent and Lent specifically drop the
|
||||||
|
redundant leading weekday word from `temporalLabel()` now that the header always shows the
|
||||||
|
weekday on its own ("Tuesday in the 2nd week of Advent" → "In the 2nd week of Advent") — every
|
||||||
|
other season (Epiphanytide, Eastertide, Trinitytide, the plain fallback) keeps its weekday
|
||||||
|
prefix, a deliberate narrower-scope decision, not an oversight.
|
||||||
|
|
||||||
|
**Real content gap, left open on purpose**: no Latin form exists anywhere for saint names
|
||||||
|
(`SanctoralIdentity.name`), octave names, or named-temporal-feast names — only rank labels,
|
||||||
|
weekday names, month names, and season/ordinal phrasing have authored Latin. Every saint/
|
||||||
|
octave/feast name currently falls back to its English string as its own `la` value too (same
|
||||||
|
"surface real text over missing" convention used elsewhere), so Latin mode never shows a blank
|
||||||
|
where a proper name belongs — but it also means Latin mode still reads mostly-English for any
|
||||||
|
day with a real winner. Authoring actual Latin names per saint/octave/feast is separate future
|
||||||
|
content work (hundreds of records), not started here.
|
||||||
|
|
||||||
|
`tests/calendar/day-label.test.ts` rewritten for the `{en, la}` shape (all `.en` values
|
||||||
|
unchanged except the Advent/Lent weekday-drop cases), plus new representative `.la`
|
||||||
|
assertions (weekday, rank, season-ordinal genitive construction, English-fallback-for-a-
|
||||||
|
saint-name). `tests/hours/compline.test.ts` and `tests/ui/shell.test.ts` updated too. `npm
|
||||||
|
test` (383 tests) and `tsc --noEmit` both pass; manually verified in the browser (English,
|
||||||
|
Latin, and both-language modes) against an Advent date with an active octave.
|
||||||
|
|
||||||
## Known, deliberate simplifications (not bugs — working as designed)
|
## Known, deliberate simplifications (not bugs — working as designed)
|
||||||
|
|
||||||
- `getDayCollects`: each collect in a multi-collect day renders as its own
|
- `getDayCollects`: each collect in a multi-collect day renders as its own
|
||||||
|
|||||||
+226
-80
@@ -9,7 +9,16 @@
|
|||||||
// become a configurable choice later (the same day->id indirection
|
// become a configurable choice later (the same day->id indirection
|
||||||
// philosophy already used for the sanctoral calendar), not hardcoded here
|
// philosophy already used for the sanctoral calendar), not hardcoded here
|
||||||
// forever — just not built yet.
|
// forever — just not built yet.
|
||||||
import type { FeastClass, LiturgicalDay, TemporalCategory } from './types';
|
//
|
||||||
|
// 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
|
||||||
|
// name) have no authored Latin form anywhere in `data/` today — those use
|
||||||
|
// the English string as their own `la` value too (same "surface real text
|
||||||
|
// rather than block on missing" convention used elsewhere in this app),
|
||||||
|
// rather than leaving Latin mode showing blanks. Only the closed,
|
||||||
|
// mechanism-level vocabulary here (weekdays, ranks, season/ordinal
|
||||||
|
// phrasing) has real Latin authored.
|
||||||
|
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';
|
||||||
import { addDays, daysBetween, toIsoDate } from './date-math';
|
import { addDays, daysBetween, toIsoDate } from './date-math';
|
||||||
@@ -17,6 +26,26 @@ import { getTemporalFeastRecord } from './temporal-feasts';
|
|||||||
import { activeOctavesFor, octaveGoverningPrivilegedDay, resolveActiveOctave, type ActiveOctave } from './octaves';
|
import { activeOctavesFor, octaveGoverningPrivilegedDay, resolveActiveOctave, type ActiveOctave } from './octaves';
|
||||||
import { resolveTemporalId } from './temporal-id';
|
import { resolveTemporalId } from './temporal-id';
|
||||||
|
|
||||||
|
interface Bi {
|
||||||
|
en: string;
|
||||||
|
la: string;
|
||||||
|
[lang: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bi(en: string, la: string): Bi {
|
||||||
|
return { en, la };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A proper name with no authored Latin form yet — the English string
|
||||||
|
* stands in for `la` too. See file-header note. */
|
||||||
|
function biFallback(name: string): Bi {
|
||||||
|
return { en: name, la: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinBi(items: Bi[], sep = ' — '): Bi {
|
||||||
|
return { en: items.map((i) => i.en).join(sep), la: items.map((i) => i.la).join(sep) };
|
||||||
|
}
|
||||||
|
|
||||||
function capitalize(text: string): string {
|
function capitalize(text: string): string {
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
}
|
}
|
||||||
@@ -38,6 +67,56 @@ function ordinal(n: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feminine Latin ordinal adjectives (agreeing with "Dominica"/"Hebdomada"/
|
||||||
|
// "die", all feminine) — covers 1st through 24th, the full range this app
|
||||||
|
// ever counts to (Trinitytide's fixed last week is the 23rd). Not a general
|
||||||
|
// Latin-numerals utility, just this app's own closed display vocabulary.
|
||||||
|
const ORDINAL_LA = [
|
||||||
|
'',
|
||||||
|
'prima',
|
||||||
|
'secunda',
|
||||||
|
'tertia',
|
||||||
|
'quarta',
|
||||||
|
'quinta',
|
||||||
|
'sexta',
|
||||||
|
'septima',
|
||||||
|
'octava',
|
||||||
|
'nona',
|
||||||
|
'decima',
|
||||||
|
'undecima',
|
||||||
|
'duodecima',
|
||||||
|
'decima tertia',
|
||||||
|
'decima quarta',
|
||||||
|
'decima quinta',
|
||||||
|
'decima sexta',
|
||||||
|
'decima septima',
|
||||||
|
'decima octava',
|
||||||
|
'decima nona',
|
||||||
|
'vicesima',
|
||||||
|
'vicesima prima',
|
||||||
|
'vicesima secunda',
|
||||||
|
'vicesima tertia',
|
||||||
|
'vicesima quarta',
|
||||||
|
];
|
||||||
|
|
||||||
|
function ordinalLa(n: number): string {
|
||||||
|
return ORDINAL_LA[n] ?? `${n}a`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS: Record<Weekday, Bi> = {
|
||||||
|
sunday: bi('Sunday', 'Dominica'),
|
||||||
|
monday: bi('Monday', 'Feria Secunda'),
|
||||||
|
tuesday: bi('Tuesday', 'Feria Tertia'),
|
||||||
|
wednesday: bi('Wednesday', 'Feria Quarta'),
|
||||||
|
thursday: bi('Thursday', 'Feria Quinta'),
|
||||||
|
friday: bi('Friday', 'Feria Sexta'),
|
||||||
|
saturday: bi('Saturday', 'Sabbato'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function weekdayLabel(day: LiturgicalDay): Bi {
|
||||||
|
return WEEKDAY_LABELS[day.weekday];
|
||||||
|
}
|
||||||
|
|
||||||
interface OrdinalSeason {
|
interface OrdinalSeason {
|
||||||
/** ISO date of the season's own anchor day, for a given calendar year. */
|
/** ISO date of the season's own anchor day, for a given calendar year. */
|
||||||
anchorDate(year: number): string;
|
anchorDate(year: number): string;
|
||||||
@@ -45,10 +124,16 @@ interface OrdinalSeason {
|
|||||||
* Lent: the anchor is its own named day, excluded from the count — the
|
* Lent: the anchor is its own named day, excluded from the count — the
|
||||||
* numbered weeks start the following Sunday. */
|
* numbered weeks start the following Sunday. */
|
||||||
includeAnchorWeek: boolean;
|
includeAnchorWeek: boolean;
|
||||||
/** Used in "Weekday after {anchorName}" for the anchor's own partial week. */
|
/** Used in "Weekday after {anchorName}" for the anchor's own partial week,
|
||||||
anchorName: string;
|
* and as the anchor day's own standalone name. */
|
||||||
/** Used in "the Nth Sunday/week {preposition} {ordinalName}". */
|
anchorName: Bi;
|
||||||
ordinalName: string;
|
/** Used in "the Nth Sunday/week {preposition} {ordinalName}". For
|
||||||
|
* `preposition: 'of'`, `ordinalName.la` is already the correct genitive
|
||||||
|
* form (glued directly after the ordinal, no linking word — Latin
|
||||||
|
* idiom). For `preposition: 'after'`, `ordinalName.la` is the form used
|
||||||
|
* after "post" (traditionally accusative, or an idiomatic fixed phrase
|
||||||
|
* like "post Cineres"). */
|
||||||
|
ordinalName: Bi;
|
||||||
preposition: 'after' | 'of';
|
preposition: 'after' | 'of';
|
||||||
/** The season's own anchor day's rank, if it sits on the closed
|
/** The season's own anchor day's rank, if it sits on the closed
|
||||||
* pre-1955 duplex scale at all — Ash Wednesday (Lent's anchor) doesn't;
|
* pre-1955 duplex scale at all — Ash Wednesday (Lent's anchor) doesn't;
|
||||||
@@ -56,44 +141,61 @@ interface OrdinalSeason {
|
|||||||
* and shows no parenthetical, same "omit rather than guess" convention
|
* and shows no parenthetical, same "omit rather than guess" convention
|
||||||
* `formatRank`/`status` fields use elsewhere. */
|
* `formatRank`/`status` fields use elsewhere. */
|
||||||
anchorRank?: FeastClass;
|
anchorRank?: FeastClass;
|
||||||
|
/** Advent and Lent only: once the header always shows the day's weekday
|
||||||
|
* on its own, repeating it at the front of this label is redundant — so
|
||||||
|
* those two seasons drop it here, in both languages. Every other season
|
||||||
|
* keeps the weekday prefix (explicit, narrower scope decision). */
|
||||||
|
dropWeekdayPrefix?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORDINAL_SEASONS: Partial<Record<string, OrdinalSeason>> = {
|
const ORDINAL_SEASONS: Partial<Record<string, OrdinalSeason>> = {
|
||||||
advent: {
|
advent: {
|
||||||
anchorDate: adventStart,
|
anchorDate: adventStart,
|
||||||
includeAnchorWeek: true,
|
includeAnchorWeek: true,
|
||||||
anchorName: 'Advent',
|
anchorName: bi('Advent', 'Adventus'),
|
||||||
ordinalName: 'Advent',
|
// Genitive of 4th-declension "adventus" is also "Adventus" — no
|
||||||
|
// linking word needed: "Hebdomada II Adventus".
|
||||||
|
ordinalName: bi('Advent', 'Adventus'),
|
||||||
preposition: 'of',
|
preposition: 'of',
|
||||||
|
dropWeekdayPrefix: true,
|
||||||
},
|
},
|
||||||
epiphanytide: {
|
epiphanytide: {
|
||||||
anchorDate: (year) => `${year}-01-06`,
|
anchorDate: (year) => `${year}-01-06`,
|
||||||
includeAnchorWeek: false,
|
includeAnchorWeek: false,
|
||||||
anchorName: 'Epiphany',
|
anchorName: bi('Epiphany', 'Epiphania'),
|
||||||
ordinalName: 'Epiphany',
|
// Accusative, for "post Epiphaniam".
|
||||||
|
ordinalName: bi('Epiphany', 'Epiphaniam'),
|
||||||
preposition: 'after',
|
preposition: 'after',
|
||||||
anchorRank: 'duplex-1-classis',
|
anchorRank: 'duplex-1-classis',
|
||||||
},
|
},
|
||||||
lent: {
|
lent: {
|
||||||
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), -46),
|
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), -46),
|
||||||
includeAnchorWeek: false,
|
includeAnchorWeek: false,
|
||||||
anchorName: 'Ash Wednesday',
|
anchorName: bi('Ash Wednesday', 'Feria Quarta Cinerum'),
|
||||||
ordinalName: 'Lent',
|
// Genitive of 1st-declension "Quadragesima": "Hebdomada II
|
||||||
|
// Quadragesimae". The pre-Lent-I days use the real traditional idiom
|
||||||
|
// "post Cineres" instead (see temporalLabel below), not this field.
|
||||||
|
ordinalName: bi('Lent', 'Quadragesimae'),
|
||||||
preposition: 'of',
|
preposition: 'of',
|
||||||
|
dropWeekdayPrefix: true,
|
||||||
},
|
},
|
||||||
eastertide: {
|
eastertide: {
|
||||||
anchorDate: (year) => toIsoDate(easterSunday(year)),
|
anchorDate: (year) => toIsoDate(easterSunday(year)),
|
||||||
includeAnchorWeek: false,
|
includeAnchorWeek: false,
|
||||||
anchorName: 'Easter',
|
anchorName: bi('Easter', 'Pascha'),
|
||||||
ordinalName: 'Easter',
|
// "Pascha" is indeclinable — same form after "post".
|
||||||
|
ordinalName: bi('Easter', 'Pascha'),
|
||||||
preposition: 'after',
|
preposition: 'after',
|
||||||
anchorRank: 'duplex-1-classis',
|
anchorRank: 'duplex-1-classis',
|
||||||
},
|
},
|
||||||
trinitytide: {
|
trinitytide: {
|
||||||
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), 56),
|
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), 56),
|
||||||
includeAnchorWeek: false,
|
includeAnchorWeek: false,
|
||||||
anchorName: 'Trinity Sunday',
|
anchorName: bi('Trinity Sunday', 'Dominica Sanctissimae Trinitatis'),
|
||||||
ordinalName: 'Trinity',
|
// Accusative, for "post Sanctissimam Trinitatem" — this app's own
|
||||||
|
// Trinity-counted convention (see file header), not the historical
|
||||||
|
// Pentecost-counted "post Pentecosten".
|
||||||
|
ordinalName: bi('Trinity', 'Sanctissimam Trinitatem'),
|
||||||
preposition: 'after',
|
preposition: 'after',
|
||||||
anchorRank: 'duplex-1-classis',
|
anchorRank: 'duplex-1-classis',
|
||||||
},
|
},
|
||||||
@@ -107,14 +209,16 @@ const ORDINAL_SEASONS: Partial<Record<string, OrdinalSeason>> = {
|
|||||||
* octave, but nobody calls it that). Advent's own anchor (Advent I Sunday)
|
* octave, but nobody calls it that). Advent's own anchor (Advent I Sunday)
|
||||||
* doesn't take this branch — it's already "the 1st Sunday of Advent" via
|
* doesn't take this branch — it's already "the 1st Sunday of Advent" via
|
||||||
* the ordinal path below. */
|
* the ordinal path below. */
|
||||||
function anchorDayName(day: LiturgicalDay, withRank = true): string | undefined {
|
function anchorDayName(day: LiturgicalDay, withRank = true): Bi | undefined {
|
||||||
const config = ORDINAL_SEASONS[day.season];
|
const config = ORDINAL_SEASONS[day.season];
|
||||||
if (!config || config.includeAnchorWeek) {
|
if (!config || config.includeAnchorWeek) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const year = Number(day.date.slice(0, 4));
|
const year = Number(day.date.slice(0, 4));
|
||||||
if (day.date === config.anchorDate(year)) {
|
if (day.date === config.anchorDate(year)) {
|
||||||
return withRank && config.anchorRank ? `${config.anchorName} (${formatRank(config.anchorRank)})` : config.anchorName;
|
return withRank && config.anchorRank
|
||||||
|
? bi(`${config.anchorName.en} (${formatRank(config.anchorRank).en})`, `${config.anchorName.la} (${formatRank(config.anchorRank).la})`)
|
||||||
|
: config.anchorName;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -147,47 +251,57 @@ const FIXED_LAST_SUNDAY_ORDINAL = 23;
|
|||||||
* every year, not just overflow ones.
|
* every year, not just overflow ones.
|
||||||
* Only ever fires within trinitytide, since that's the only season
|
* Only ever fires within trinitytide, since that's the only season
|
||||||
* resolveTemporalId can return either of these ids for a date outside
|
* resolveTemporalId can return either of these ids for a date outside
|
||||||
* their own native season.
|
* their own native season. Trinitytide isn't in the weekday-drop set, so
|
||||||
|
* both languages keep their weekday prefix here.
|
||||||
*/
|
*/
|
||||||
function trinitytideOverrideLabel(day: LiturgicalDay): string | undefined {
|
function trinitytideOverrideLabel(day: LiturgicalDay): Bi | undefined {
|
||||||
if (day.season !== 'trinitytide') {
|
if (day.season !== 'trinitytide') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const id = resolveTemporalId(day.date);
|
const id = resolveTemporalId(day.date);
|
||||||
const weekdayName = capitalize(day.weekday);
|
const weekdayName = weekdayLabel(day);
|
||||||
|
|
||||||
const epiphanyMatch = id.match(/^post-epiphany-(\d)$/);
|
const epiphanyMatch = id.match(/^post-epiphany-(\d)$/);
|
||||||
if (epiphanyMatch) {
|
if (epiphanyMatch) {
|
||||||
const n = Number(epiphanyMatch[1]);
|
const n = Number(epiphanyMatch[1]);
|
||||||
return day.weekday === 'sunday'
|
return day.weekday === 'sunday'
|
||||||
? `The ${ordinal(n)} Sunday after Epiphany`
|
? bi(`The ${ordinal(n)} Sunday after Epiphany`, `Dominica ${ordinalLa(n)} post Epiphaniam`)
|
||||||
: `${weekdayName} in the ${ordinal(n)} week after Epiphany`;
|
: bi(`${weekdayName.en} in the ${ordinal(n)} week after Epiphany`, `${weekdayName.la} in hebdomada ${ordinalLa(n)} post Epiphaniam`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === 'post-pentecost-24') {
|
if (id === 'post-pentecost-24') {
|
||||||
return day.weekday === 'sunday'
|
return day.weekday === 'sunday'
|
||||||
? `The ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} Sunday after Trinity`
|
? bi(
|
||||||
: `${weekdayName} in the ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} week after Trinity`;
|
`The ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} Sunday after Trinity`,
|
||||||
|
`Dominica ${ordinalLa(FIXED_LAST_SUNDAY_ORDINAL)} post Sanctissimam Trinitatem`,
|
||||||
|
)
|
||||||
|
: bi(
|
||||||
|
`${weekdayName.en} in the ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} week after Trinity`,
|
||||||
|
`${weekdayName.la} in hebdomada ${ordinalLa(FIXED_LAST_SUNDAY_ORDINAL)} post Sanctissimam Trinitatem`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function temporalLabel(day: LiturgicalDay): string {
|
function temporalLabel(day: LiturgicalDay): Bi {
|
||||||
const trinitytideOverride = trinitytideOverrideLabel(day);
|
const trinitytideOverride = trinitytideOverrideLabel(day);
|
||||||
if (trinitytideOverride) {
|
if (trinitytideOverride) {
|
||||||
return trinitytideOverride;
|
return trinitytideOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekdayName = capitalize(day.weekday);
|
const weekdayName = weekdayLabel(day);
|
||||||
const config = ORDINAL_SEASONS[day.season];
|
const config = ORDINAL_SEASONS[day.season];
|
||||||
if (!config) {
|
if (!config) {
|
||||||
// No ordinal convention modeled for this season (Septuagesima-tide,
|
// No ordinal convention modeled for this season (Septuagesima-tide,
|
||||||
// Passiontide, Ascensiontide, Pentecost, Christmastide, the
|
// Passiontide, Ascensiontide, Pentecost, Christmastide, the
|
||||||
// Corpus-Christi/Sacred-Heart single-day seasons) — their few days
|
// Corpus-Christi/Sacred-Heart single-day seasons) — their few days
|
||||||
// mostly have their own proper names rather than ordinal counting, so
|
// mostly have their own proper names rather than ordinal counting, so
|
||||||
// this fallback is expected to be seen, not a gap to fill later.
|
// this fallback is expected to be seen, not a gap to fill later. No
|
||||||
return `${weekdayName} in ${capitalize(day.season.replace(/-/g, ' '))}`;
|
// authored Latin season name exists for these yet, so `la` falls back
|
||||||
|
// to the same English season word.
|
||||||
|
const seasonName = capitalize(day.season.replace(/-/g, ' '));
|
||||||
|
return bi(`${weekdayName.en} in ${seasonName}`, `${weekdayName.la} in ${seasonName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const year = Number(day.date.slice(0, 4));
|
const year = Number(day.date.slice(0, 4));
|
||||||
@@ -199,34 +313,54 @@ function temporalLabel(day: LiturgicalDay): string {
|
|||||||
|
|
||||||
const firstNumberedSunday = config.includeAnchorWeek ? anchor : firstSundayStrictlyAfter(anchor);
|
const firstNumberedSunday = config.includeAnchorWeek ? anchor : firstSundayStrictlyAfter(anchor);
|
||||||
if (day.date < firstNumberedSunday) {
|
if (day.date < firstNumberedSunday) {
|
||||||
return `${weekdayName} after ${config.anchorName}`;
|
// Lent's own pre-Lent-I days use the real traditional idiom "post
|
||||||
|
// Cineres" rather than the season's genitive `ordinalName` (which is
|
||||||
|
// built for the "of Lent" construction, not "after Ash Wednesday").
|
||||||
|
const afterAnchorLa = day.season === 'lent' ? 'Cineres' : config.anchorName.la;
|
||||||
|
return config.dropWeekdayPrefix
|
||||||
|
? bi(`After ${config.anchorName.en}`, `Post ${afterAnchorLa}`)
|
||||||
|
: bi(`${weekdayName.en} after ${config.anchorName.en}`, `${weekdayName.la} post ${afterAnchorLa}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const weeksSince = daysBetween(firstNumberedSunday, sundayOnOrBefore(day.date)) / 7;
|
const weeksSince = daysBetween(firstNumberedSunday, sundayOnOrBefore(day.date)) / 7;
|
||||||
const ordinalStr = ordinal(weeksSince + 1);
|
const ordinalStr = ordinal(weeksSince + 1);
|
||||||
|
const ordinalStrLa = ordinalLa(weeksSince + 1);
|
||||||
if (day.weekday === 'sunday') {
|
if (day.weekday === 'sunday') {
|
||||||
return `The ${ordinalStr} Sunday ${config.preposition} ${config.ordinalName}`;
|
return config.preposition === 'of'
|
||||||
|
? bi(`The ${ordinalStr} Sunday of ${config.ordinalName.en}`, `Dominica ${ordinalStrLa} ${config.ordinalName.la}`)
|
||||||
|
: bi(`The ${ordinalStr} Sunday after ${config.ordinalName.en}`, `Dominica ${ordinalStrLa} post ${config.ordinalName.la}`);
|
||||||
}
|
}
|
||||||
return `${weekdayName} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName}`;
|
const weekPhraseLa =
|
||||||
|
config.preposition === 'of' ? `hebdomada ${ordinalStrLa} ${config.ordinalName.la}` : `hebdomada ${ordinalStrLa} post ${config.ordinalName.la}`;
|
||||||
|
return config.dropWeekdayPrefix
|
||||||
|
? bi(`In the ${ordinalStr} week ${config.preposition} ${config.ordinalName.en}`, `In ${weekPhraseLa}`)
|
||||||
|
: bi(`${weekdayName.en} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName.en}`, `${weekdayName.la} in ${weekPhraseLa}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Third Day within the Octave of St. Lawrence" for an ordinary mid-octave
|
/** "Third Day within the Octave of St. Lawrence" for an ordinary mid-octave
|
||||||
* day (e.g. "Tertia die infra Octavam S. Laurentii Martyris"), or plain
|
* day ("Tertia die infra Octavam S. Laurentii Martyris"), or plain "Octave
|
||||||
* "Octave of St. Lawrence" for the octave's own closing day — real DO
|
* of St. Lawrence" for the octave's own closing day ("In Octava S.
|
||||||
* Latin distinguishes "infra octavam" (within the octave, days 2-7) from
|
* Laurentii Martyris") — real DO Latin distinguishes "infra octavam"
|
||||||
* "in octava" (on the octave day itself, day 8), live-verified: Aug 17
|
* (within the octave, days 2-7) from "in octava" (on the octave day
|
||||||
* (St. Lawrence's closing day) titles itself "In Octava S. Laurentii
|
* itself, day 8), live-verified: Aug 17 (St. Lawrence's closing day)
|
||||||
* Martyris", not "Octava die infra Octavam...". Day 1 shouldn't normally
|
* titles itself "In Octava S. Laurentii Martyris", not "Octava die infra
|
||||||
* reach either branch (that day's own winner is the feast itself, handled
|
* Octavam...". Day 1 shouldn't normally reach either branch (that day's
|
||||||
* above before this is ever called) — kept simple rather than
|
* own winner is the feast itself, handled above before this is ever
|
||||||
* special-cased for that rare edge case (see applyOctaves's own
|
* called) — kept simple rather than special-cased for that rare edge case
|
||||||
* `isOwnStartDay` comment in calendar/index.ts for when it can happen). */
|
* (see applyOctaves's own `isOwnStartDay` comment in calendar/index.ts for
|
||||||
function octaveCoreName(octave: ActiveOctave): string {
|
* when it can happen). The octave's own `name` has no authored Latin form
|
||||||
return octave.isClosingDay ? `Octave of ${octave.name}` : `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`;
|
* (see file header), so both languages currently show the same name text
|
||||||
|
* — only the surrounding phrasing differs. */
|
||||||
|
function octaveCoreName(octave: ActiveOctave): Bi {
|
||||||
|
return octave.isClosingDay
|
||||||
|
? bi(`Octave of ${octave.name}`, `In Octava ${octave.name}`)
|
||||||
|
: bi(`${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`, `${ordinalLa(octave.dayNumber)} die infra Octavam ${octave.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function octaveLabel(octave: ActiveOctave): string {
|
function octaveLabel(octave: ActiveOctave): Bi {
|
||||||
return `${octaveCoreName(octave)} (${formatRank(octave.wins)})`;
|
const core = octaveCoreName(octave);
|
||||||
|
const rank = formatRank(octave.wins);
|
||||||
|
return bi(`${core.en} (${rank.en})`, `${core.la} (${rank.la})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same phrasing as `octaveLabel`, but without the parenthesized rank —
|
/** Same phrasing as `octaveLabel`, but without the parenthesized rank —
|
||||||
@@ -238,7 +372,7 @@ function octaveLabel(octave: ActiveOctave): string {
|
|||||||
* it *won as*, not a fact worth restating once a duplex-or-higher saint
|
* it *won as*, not a fact worth restating once a duplex-or-higher saint
|
||||||
* has displaced it and it's just riding along as a commemoration
|
* has displaced it and it's just riding along as a commemoration
|
||||||
* instead). */
|
* instead). */
|
||||||
function octaveCommemorationLabel(octave: ActiveOctave): string {
|
function octaveCommemorationLabel(octave: ActiveOctave): Bi {
|
||||||
return octaveCoreName(octave);
|
return octaveCoreName(octave);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +392,8 @@ function octaveCommemorationLabel(octave: ActiveOctave): string {
|
|||||||
* - `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).
|
* them, not just the first). No authored Latin saint names exist yet
|
||||||
|
* (see file header), so `la` falls back to the same English name.
|
||||||
* - `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
|
||||||
@@ -282,35 +417,35 @@ function octaveCommemorationLabel(octave: ActiveOctave): string {
|
|||||||
function collectCommemorations(
|
function collectCommemorations(
|
||||||
day: LiturgicalDay,
|
day: LiturgicalDay,
|
||||||
opts: { showOctaves: boolean; excludeOctaveId?: string },
|
opts: { showOctaves: boolean; excludeOctaveId?: string },
|
||||||
): { sanctoral: string[]; temporal: string[]; octaves: string[] } {
|
): { sanctoral: Bi[]; temporal: Bi[]; octaves: Bi[] } {
|
||||||
const temporalId = resolveTemporalId(day.date);
|
const temporalId = resolveTemporalId(day.date);
|
||||||
const activeOctaves = opts.showOctaves ? activeOctavesFor(day.date) : [];
|
const activeOctaves = opts.showOctaves ? activeOctavesFor(day.date) : [];
|
||||||
const sanctoral: string[] = [];
|
const sanctoral: Bi[] = [];
|
||||||
const temporal: string[] = [];
|
const temporal: Bi[] = [];
|
||||||
const octaves: string[] = [];
|
const octaves: Bi[] = [];
|
||||||
for (const c of day.commemorations) {
|
for (const c of day.commemorations) {
|
||||||
if (c.kind === 'sanctoral') {
|
if (c.kind === 'sanctoral') {
|
||||||
sanctoral.push(c.name);
|
sanctoral.push(biFallback(c.name));
|
||||||
} else if (c.kind === 'temporal') {
|
} else if (c.kind === 'temporal') {
|
||||||
if (c.id === temporalId) {
|
if (c.id === temporalId) {
|
||||||
temporal.push(anchorDayName(day, false) ?? temporalLabel(day));
|
temporal.push(anchorDayName(day, false) ?? temporalLabel(day));
|
||||||
}
|
}
|
||||||
} 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) : c.name);
|
octaves.push(octave ? octaveCommemorationLabel(octave) : biFallback(c.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { sanctoral, temporal, octaves };
|
return { sanctoral, temporal, octaves };
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANK_LABELS: Record<FeastClass, string> = {
|
const RANK_LABELS: Record<FeastClass, Bi> = {
|
||||||
simplex: 'Simplex',
|
simplex: bi('Simplex', 'Simplex'),
|
||||||
vigil: 'Vigil',
|
vigil: bi('Vigil', 'Vigilia'),
|
||||||
semiduplex: 'Semiduplex',
|
semiduplex: bi('Semiduplex', 'Semiduplex'),
|
||||||
duplex: 'Duplex',
|
duplex: bi('Duplex', 'Duplex'),
|
||||||
'duplex-majus': 'Duplex Majus',
|
'duplex-majus': bi('Duplex Majus', 'Duplex Majus'),
|
||||||
'duplex-2-classis': 'Duplex II Class',
|
'duplex-2-classis': bi('Duplex II Class', 'Duplex II. Classis'),
|
||||||
'duplex-1-classis': 'Duplex I Class',
|
'duplex-1-classis': bi('Duplex I Class', 'Duplex I. Classis'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The day's own winning saint's rank, parenthesized after their name —
|
/** The day's own winning saint's rank, parenthesized after their name —
|
||||||
@@ -318,7 +453,7 @@ const RANK_LABELS: Record<FeastClass, string> = {
|
|||||||
* saint. Only the day's own *winner* gets this treatment, not every
|
* saint. Only the day's own *winner* gets this treatment, not every
|
||||||
* commemoration, matching the "winner is primary, commemorations are
|
* commemoration, matching the "winner is primary, commemorations are
|
||||||
* secondary" distinction this file already draws throughout. */
|
* secondary" distinction this file already draws throughout. */
|
||||||
function formatRank(rank: FeastClass): string {
|
function formatRank(rank: FeastClass): Bi {
|
||||||
return RANK_LABELS[rank];
|
return RANK_LABELS[rank];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,14 +465,15 @@ function formatRank(rank: FeastClass): string {
|
|||||||
* Semiduplex regardless of whether it's additionally "privileged"
|
* Semiduplex regardless of whether it's additionally "privileged"
|
||||||
* (privilege is about resisting supersession, a property of
|
* (privilege is about resisting supersession, a property of
|
||||||
* `temporalCategory`, not a higher spot on the duplex scale) — so both
|
* `temporalCategory`, not a higher spot on the duplex scale) — so both
|
||||||
* Sunday categories share one label here. */
|
* Sunday categories share one label here. Both "Semiduplex" and "Feria"
|
||||||
const TEMPORAL_CATEGORY_RANK_LABELS: Record<TemporalCategory, string> = {
|
* are already the real Latin words, so `en`/`la` coincide here too. */
|
||||||
'ordinary-sunday': 'Semiduplex',
|
const TEMPORAL_CATEGORY_RANK_LABELS: Record<TemporalCategory, Bi> = {
|
||||||
'privileged-sunday': 'Semiduplex',
|
'ordinary-sunday': bi('Semiduplex', 'Semiduplex'),
|
||||||
'ordinary-feria': 'Feria',
|
'privileged-sunday': bi('Semiduplex', 'Semiduplex'),
|
||||||
'privileged-feria-minor': 'Feria',
|
'ordinary-feria': bi('Feria', 'Feria'),
|
||||||
'privileged-feria': 'Feria',
|
'privileged-feria-minor': bi('Feria', 'Feria'),
|
||||||
'privileged-feria-major': 'Feria',
|
'privileged-feria': bi('Feria', 'Feria'),
|
||||||
|
'privileged-feria-major': bi('Feria', 'Feria'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,9 +484,12 @@ const TEMPORAL_CATEGORY_RANK_LABELS: Record<TemporalCategory, string> = {
|
|||||||
* eligible to ride along with it. Each branch below only decides two
|
* eligible to ride along with it. Each branch below only decides two
|
||||||
* things: what the primary label is, and what commemoration *groups* are
|
* things: what the primary label is, and what commemoration *groups* are
|
||||||
* eligible here and in what order — the actual filtering/formatting work
|
* eligible here and in what order — the actual filtering/formatting work
|
||||||
* is `collectCommemorations`'s alone, shared by every branch.
|
* is `collectCommemorations`'s alone, shared by every branch. Returns a
|
||||||
|
* `Partial<Record<string, string>>` keyed by language code (`en`/`la`),
|
||||||
|
* the same convention `hours/types.ts`'s `ResolvedText`/`ResolvedVerse`
|
||||||
|
* already use.
|
||||||
*/
|
*/
|
||||||
export function getDayLabel(day: LiturgicalDay): string {
|
export function getDayLabel(day: LiturgicalDay): Partial<Record<string, string>> {
|
||||||
if (day.winner.kind === 'sanctoral') {
|
if (day.winner.kind === 'sanctoral') {
|
||||||
// Octaves only ride along here on `ordinary-feria` — a day with real
|
// Octaves only ride along here on `ordinary-feria` — a day with real
|
||||||
// standing of its own (a Sunday, a privileged feria) doesn't mention
|
// standing of its own (a Sunday, a privileged feria) doesn't mention
|
||||||
@@ -362,8 +501,10 @@ export function getDayLabel(day: LiturgicalDay): string {
|
|||||||
const { sanctoral, temporal, octaves } = collectCommemorations(day, {
|
const { sanctoral, temporal, octaves } = collectCommemorations(day, {
|
||||||
showOctaves: day.temporalCategory === 'ordinary-feria',
|
showOctaves: day.temporalCategory === 'ordinary-feria',
|
||||||
});
|
});
|
||||||
const winnerName = `${day.winner.name} (${formatRank(day.winner.rank)})`;
|
const rank = formatRank(day.winner.rank);
|
||||||
return [winnerName, ...temporal, ...sanctoral, ...octaves].join(' — ');
|
const winnerName = biFallback(day.winner.name);
|
||||||
|
const winner = bi(`${winnerName.en} (${rank.en})`, `${winnerName.la} (${rank.la})`);
|
||||||
|
return joinBi([winner, ...temporal, ...sanctoral, ...octaves]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A named temporal feast (Christmas, Pentecost, Marian Saturday, ...)
|
// A named temporal feast (Christmas, Pentecost, Marian Saturday, ...)
|
||||||
@@ -373,9 +514,12 @@ export function getDayLabel(day: LiturgicalDay): 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 primary = namedFeast.rank ? `${namedFeast.name} (${formatRank(namedFeast.rank)})` : namedFeast.name;
|
const name = biFallback(namedFeast.name);
|
||||||
|
const primary = namedFeast.rank
|
||||||
|
? bi(`${name.en} (${formatRank(namedFeast.rank).en})`, `${name.la} (${formatRank(namedFeast.rank).la})`)
|
||||||
|
: name;
|
||||||
const { sanctoral } = collectCommemorations(day, { showOctaves: false });
|
const { sanctoral } = collectCommemorations(day, { showOctaves: false });
|
||||||
return [primary, ...sanctoral].join(' — ');
|
return joinBi([primary, ...sanctoral]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A season's own named anchor day (Trinity Sunday, Easter, Ash
|
// A season's own named anchor day (Trinity Sunday, Easter, Ash
|
||||||
@@ -388,7 +532,7 @@ export function getDayLabel(day: LiturgicalDay): string {
|
|||||||
const anchorName = anchorDayName(day);
|
const anchorName = anchorDayName(day);
|
||||||
if (anchorName) {
|
if (anchorName) {
|
||||||
const { sanctoral } = collectCommemorations(day, { showOctaves: false });
|
const { sanctoral } = collectCommemorations(day, { showOctaves: false });
|
||||||
return [...sanctoral, anchorName].join(' — ');
|
return joinBi([...sanctoral, anchorName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// An active octave (St. Lawrence's, ...) is this day's real primary
|
// An active octave (St. Lawrence's, ...) is this day's real primary
|
||||||
@@ -424,10 +568,12 @@ export function getDayLabel(day: LiturgicalDay): string {
|
|||||||
if (activeOctave) {
|
if (activeOctave) {
|
||||||
const { sanctoral, octaves } = collectCommemorations(day, { showOctaves: true, excludeOctaveId: activeOctave.id });
|
const { sanctoral, octaves } = collectCommemorations(day, { showOctaves: true, excludeOctaveId: activeOctave.id });
|
||||||
const primary = octaveLabel(activeOctave);
|
const primary = octaveLabel(activeOctave);
|
||||||
return [primary, ...octaves, ...sanctoral].join(' — ');
|
return joinBi([primary, ...octaves, ...sanctoral]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const temporal = `${temporalLabel(day)} (${TEMPORAL_CATEGORY_RANK_LABELS[day.temporalCategory]})`;
|
const temporalRank = TEMPORAL_CATEGORY_RANK_LABELS[day.temporalCategory];
|
||||||
|
const label = temporalLabel(day);
|
||||||
|
const temporal = bi(`${label.en} (${temporalRank.en})`, `${label.la} (${temporalRank.la})`);
|
||||||
const { sanctoral } = collectCommemorations(day, { showOctaves: false });
|
const { sanctoral } = collectCommemorations(day, { showOctaves: false });
|
||||||
return [...sanctoral, temporal].join(' — ');
|
return joinBi([...sanctoral, temporal]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ interface MatinsSundayAntiphons {
|
|||||||
}
|
}
|
||||||
const sundayAntiphons = matinsSundayAntiphonsData as unknown as MatinsSundayAntiphons;
|
const sundayAntiphons = matinsSundayAntiphonsData as unknown as MatinsSundayAntiphons;
|
||||||
|
|
||||||
|
const NOCTURN_NUMERAL = ['I', 'II', 'III'] as const;
|
||||||
|
|
||||||
type PsalmPart = Extract<ResolvedPart, { kind: 'psalm' }>;
|
type PsalmPart = Extract<ResolvedPart, { kind: 'psalm' }>;
|
||||||
|
|
||||||
function plainPsalm(number: number): PsalmPart {
|
function plainPsalm(number: number): PsalmPart {
|
||||||
@@ -302,15 +304,19 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
|||||||
const parts: ResolvedPart[] = [
|
const parts: ResolvedPart[] = [
|
||||||
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
|
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
|
||||||
plainPsalm(3),
|
plainPsalm(3),
|
||||||
|
{ kind: 'section-heading', label: 'Invitatory' },
|
||||||
...invitatoryParts(day),
|
...invitatoryParts(day),
|
||||||
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
|
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (threeNocturns && day.weekday === 'sunday') {
|
if (threeNocturns && day.weekday === 'sunday') {
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` });
|
||||||
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
|
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
|
||||||
parts.push(...(nocturn1Readings ?? []));
|
parts.push(...(nocturn1Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` });
|
||||||
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
|
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
|
||||||
parts.push(...(nocturn2Readings ?? []));
|
parts.push(...(nocturn2Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` });
|
||||||
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
|
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
|
||||||
parts.push(...(nocturn3Readings ?? []));
|
parts.push(...(nocturn3Readings ?? []));
|
||||||
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
||||||
@@ -324,18 +330,24 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
|||||||
// three-tier picture).
|
// three-tier picture).
|
||||||
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
|
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
|
||||||
if (override) {
|
if (override) {
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` });
|
||||||
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
|
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
|
||||||
parts.push(...(nocturn1Readings ?? []));
|
parts.push(...(nocturn1Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` });
|
||||||
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
|
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
|
||||||
parts.push(...(nocturn2Readings ?? []));
|
parts.push(...(nocturn2Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` });
|
||||||
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
|
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
|
||||||
parts.push(...(nocturn3Readings ?? []));
|
parts.push(...(nocturn3Readings ?? []));
|
||||||
} else {
|
} else {
|
||||||
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
|
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` });
|
||||||
parts.push(...n1);
|
parts.push(...n1);
|
||||||
parts.push(...(nocturn1Readings ?? []));
|
parts.push(...(nocturn1Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` });
|
||||||
parts.push(...n2);
|
parts.push(...n2);
|
||||||
parts.push(...(nocturn2Readings ?? []));
|
parts.push(...(nocturn2Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` });
|
||||||
parts.push(...n3);
|
parts.push(...n3);
|
||||||
parts.push(...(nocturn3Readings ?? []));
|
parts.push(...(nocturn3Readings ?? []));
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -210,6 +210,11 @@ export type ResolvedPart =
|
|||||||
// have a single unnumbered nocturn and don't use this wrapper at all —
|
// have a single unnumbered nocturn and don't use this wrapper at all —
|
||||||
// see hours/matins.ts). `nocturn` is 1-3.
|
// see hours/matins.ts). `nocturn` is 1-3.
|
||||||
| { kind: 'nocturn-psalmody'; nocturn: number; psalms: ResolvedPart[] }
|
| { 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
|
// 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
|
// a Duplex+ sanctoral winner) — see hours/matins.ts. Never present on a
|
||||||
// plain ferial (1-nocturn) day.
|
// plain ferial (1-nocturn) day.
|
||||||
@@ -269,11 +274,11 @@ export interface ResolvedOrdo {
|
|||||||
/** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */
|
/** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */
|
||||||
notImplemented?: true;
|
notImplemented?: true;
|
||||||
/**
|
/**
|
||||||
* "Monday in the 10th week after Trinity", "St. Ereden — ...", etc. —
|
* "Monday in the 10th week after Trinity", "St. Ereden — ...", etc.,
|
||||||
* see calendar/day-label.ts. Computed from whichever LiturgicalDay the
|
* keyed by language code (`en`/`la`) — see calendar/day-label.ts.
|
||||||
* hour actually resolved against, which can differ from `date` itself
|
* Computed from whichever LiturgicalDay the hour actually resolved
|
||||||
* (Compline may anticipate tomorrow — see calendar/vespers.ts). Absent
|
* against, which can differ from `date` itself (Compline may anticipate
|
||||||
* on not-yet-built hours.
|
* tomorrow — see calendar/vespers.ts). Absent on not-yet-built hours.
|
||||||
*/
|
*/
|
||||||
dayLabel?: string;
|
dayLabel?: Partial<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -1,13 +1,28 @@
|
|||||||
import { getState, shiftDate, goToToday } from '../app-state';
|
import { getState, shiftDate, goToToday } from '../app-state';
|
||||||
import { formatDateLong } from './format';
|
import { resolveDay } from '../calendar';
|
||||||
|
import { getDayLabel } from '../calendar/day-label';
|
||||||
|
import { formatDateLong, renderBilingual } from './format';
|
||||||
|
|
||||||
|
/** Renders navigation plus the merged "when" header: the civil date/
|
||||||
|
* weekday and the liturgical day-being-celebrated, together in one place
|
||||||
|
* — previously the date lived here while the day-being-celebrated text
|
||||||
|
* was repeated separately inside every hour's own view. The day-being-
|
||||||
|
* celebrated text is hour-independent (the same LiturgicalDay for every
|
||||||
|
* hour on a given date), so it's resolved directly here via `resolveDay`
|
||||||
|
* rather than through any particular hour's `resolveOrdo`. */
|
||||||
export function renderDayNav(container: HTMLElement): void {
|
export function renderDayNav(container: HTMLElement): void {
|
||||||
const { date } = getState();
|
const { date, languages } = getState();
|
||||||
|
const day = resolveDay(date);
|
||||||
|
const dateLabel = formatDateLong(date);
|
||||||
|
const dayLabel = getDayLabel(day);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<nav class="day-nav" aria-label="Day navigation">
|
<nav class="day-nav" aria-label="Day navigation">
|
||||||
<button type="button" class="day-nav-btn" data-action="prev" aria-label="Previous day">←</button>
|
<button type="button" class="day-nav-btn" data-action="prev" aria-label="Previous day">←</button>
|
||||||
<button type="button" class="day-nav-btn day-nav-today" data-action="today">Today</button>
|
<button type="button" class="day-nav-btn day-nav-today" data-action="today">Today</button>
|
||||||
<span class="day-nav-date">${formatDateLong(date)}</span>
|
<div class="day-nav-header">
|
||||||
|
${renderBilingual(dateLabel, languages, 'day-nav-date')}
|
||||||
|
${renderBilingual(dayLabel, languages, 'day-nav-label')}
|
||||||
|
</div>
|
||||||
<button type="button" class="day-nav-btn" data-action="next" aria-label="Next day">→</button>
|
<button type="button" class="day-nav-btn" data-action="next" aria-label="Next day">→</button>
|
||||||
</nav>
|
</nav>
|
||||||
`;
|
`;
|
||||||
|
|||||||
+50
-5
@@ -1,17 +1,62 @@
|
|||||||
/** Formats an ISO date as e.g. "Sunday, August 9, 2026" — always in UTC so
|
// Latin month names have no Intl locale data, so the genitive forms below
|
||||||
* it matches calendar/weekday.ts's interpretation regardless of the
|
// (used the same way English "of August" would be, e.g. "18 Augusti 2026")
|
||||||
* viewer's local timezone. */
|
// are authored directly here.
|
||||||
export function formatDateLong(isoDate: string): string {
|
const MONTH_LABELS_LA = [
|
||||||
|
'Ianuarii',
|
||||||
|
'Februarii',
|
||||||
|
'Martii',
|
||||||
|
'Aprilis',
|
||||||
|
'Maii',
|
||||||
|
'Iunii',
|
||||||
|
'Iulii',
|
||||||
|
'Augusti',
|
||||||
|
'Septembris',
|
||||||
|
'Octobris',
|
||||||
|
'Novembris',
|
||||||
|
'Decembris',
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS_LA = ['Dominica', 'Feria Secunda', 'Feria Tertia', 'Feria Quarta', 'Feria Quinta', 'Feria Sexta', 'Sabbato'];
|
||||||
|
|
||||||
|
/** Formats an ISO date as e.g. "Sunday, August 9, 2026" (`en`) / "Dominica,
|
||||||
|
* die 9 Augusti 2026" (`la`) — always in UTC so it matches
|
||||||
|
* calendar/weekday.ts's interpretation regardless of the viewer's local
|
||||||
|
* timezone. Weekday naming mirrors calendar/day-label.ts's own
|
||||||
|
* `WEEKDAY_LABELS` (spelled-out Latin ferial names, not roman numerals). */
|
||||||
|
export function formatDateLong(isoDate: string): Partial<Record<string, string>> {
|
||||||
const date = new Date(`${isoDate}T00:00:00Z`);
|
const date = new Date(`${isoDate}T00:00:00Z`);
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
const en = new Intl.DateTimeFormat('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
}).format(date);
|
}).format(date);
|
||||||
|
|
||||||
|
const day = date.getUTCDate();
|
||||||
|
const month = MONTH_LABELS_LA[date.getUTCMonth()];
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const weekday = WEEKDAY_LABELS_LA[date.getUTCDay()];
|
||||||
|
const la = `${weekday}, die ${day} ${month} ${year}`;
|
||||||
|
|
||||||
|
return { en, la };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hourLabel(hourId: string): string {
|
export function hourLabel(hourId: string): string {
|
||||||
return hourId.charAt(0).toUpperCase() + hourId.slice(1);
|
return hourId.charAt(0).toUpperCase() + hourId.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders one `<div class="lang-column">` per currently-active language
|
||||||
|
* for a bilingual text map — the same pattern hour-view.ts's own
|
||||||
|
* `renderColumns` uses for resolved liturgical text, shared here so
|
||||||
|
* day-nav.ts's header can reuse it instead of re-implementing it. */
|
||||||
|
export function renderBilingual(text: Partial<Record<string, string>>, languages: readonly string[], className: string): string {
|
||||||
|
const cols = languages
|
||||||
|
.map((lang) => `<div class="lang-column" lang="${lang}">${escapeHtml(text[lang] ?? '')}</div>`)
|
||||||
|
.join('');
|
||||||
|
return `<div class="${className} lang-columns lang-columns-${languages.length}">${cols}</div>`;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -122,6 +122,8 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
|
|||||||
${part.psalms.map((p) => renderPart(p, languages)).join('')}
|
${part.psalms.map((p) => renderPart(p, languages)).join('')}
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
|
case 'section-heading':
|
||||||
|
return `<h2 class="section-heading">${escapeHtml(part.label)}</h2>`;
|
||||||
case 'te-deum':
|
case 'te-deum':
|
||||||
return `
|
return `
|
||||||
<section class="ordo-part ordo-part-te-deum">
|
<section class="ordo-part ordo-part-te-deum">
|
||||||
@@ -149,7 +151,6 @@ export function renderHourView(container: HTMLElement): void {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="hour-view">
|
<div class="hour-view">
|
||||||
<h2>${hourLabel(selectedHour)}</h2>
|
<h2>${hourLabel(selectedHour)}</h2>
|
||||||
${ordo.dayLabel ? `<p class="day-label">${escapeHtml(ordo.dayLabel)}</p>` : ''}
|
|
||||||
${ordo.parts.map((part) => renderPart(part, languages)).join('')}
|
${ordo.parts.map((part) => renderPart(part, languages)).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
+22
-7
@@ -165,12 +165,26 @@ button:focus-visible {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-nav-date {
|
.day-nav-header {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
min-width: 16rem;
|
min-width: 16rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.day-nav-date {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-nav-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-brass);
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Layout: hour list + hour view
|
Layout: hour list + hour view
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
@@ -261,12 +275,13 @@ button:focus-visible {
|
|||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-label {
|
.section-heading {
|
||||||
font-family: var(--font-ui);
|
font-size: 1.2rem;
|
||||||
font-size: 0.9em;
|
text-transform: uppercase;
|
||||||
font-style: italic;
|
letter-spacing: 0.06em;
|
||||||
color: var(--color-brass);
|
color: var(--color-brass);
|
||||||
margin: 0 0 var(--space-2);
|
font-family: var(--font-ui);
|
||||||
|
margin: var(--space-4) 0 var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ordo-part-citation {
|
.ordo-part-citation {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('Christ the King (applyChristTheKing)', () => {
|
|||||||
// already carrying (Ss. Chrysanthus and Daria, Simplex, per the next
|
// already carrying (Ss. Chrysanthus and Daria, Simplex, per the next
|
||||||
// test below) -- this app's day label now surfaces every commemorated
|
// test below) -- this app's day label now surfaces every commemorated
|
||||||
// saint, not just the Sunday itself.
|
// saint, not just the Sunday itself.
|
||||||
expect(getDayLabel(day)).toBe('Christ the King (Duplex I Class) — Ss. Chrysanthus and Daria, Martyrs');
|
expect(getDayLabel(day).en).toBe('Christ the King (Duplex I Class) — Ss. Chrysanthus and Daria, Martyrs');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves a nested commemoration: a Simplex saint already commemorated under the ordinary Sunday stays commemorated', () => {
|
it('preserves a nested commemoration: a Simplex saint already commemorated under the ordinary Sunday stays commemorated', () => {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ describe('getDayLabel — ordinal temporal label', () => {
|
|||||||
// the full-year sanctoral import), which would win outright and hide
|
// the full-year sanctoral import), which would win outright and hide
|
||||||
// this purely-temporal label case. 2027's Trinity Sunday is May 23,
|
// this purely-temporal label case. 2027's Trinity Sunday is May 23,
|
||||||
// so Trinity Monday is May 24 -- free of any sanctoral entry.
|
// so Trinity Monday is May 24 -- free of any sanctoral entry.
|
||||||
expect(getDayLabel(resolveDay('2027-05-24'))).toBe('Monday after Trinity Sunday (Feria)');
|
expect(getDayLabel(resolveDay('2027-05-24')).en).toBe('Monday after Trinity Sunday (Feria)');
|
||||||
expect(getDayLabel(resolveDay('2026-01-07'))).toBe('Wednesday after Epiphany (Feria)'); // Epiphany 2026 is a Tuesday
|
expect(getDayLabel(resolveDay('2026-01-07')).en).toBe('Wednesday after Epiphany (Feria)'); // Epiphany 2026 is a Tuesday
|
||||||
|
// Latin: spelled-out ferial weekday name (not roman numerals) and the
|
||||||
|
// "Feria" rank label, which is already the real Latin word.
|
||||||
|
expect(getDayLabel(resolveDay('2027-05-24')).la).toBe('Feria Secunda post Dominica Sanctissimae Trinitatis (Feria)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names the anchor day itself, not "day after itself"', () => {
|
it('names the anchor day itself, not "day after itself"', () => {
|
||||||
@@ -27,11 +30,20 @@ describe('getDayLabel — ordinal temporal label', () => {
|
|||||||
// commemorated in return (`decideOccurrence`'s own `ordinary-sunday`
|
// commemorated in return (`decideOccurrence`'s own `ordinary-sunday`
|
||||||
// branch), by bare name only — a commemoration never carries a rank
|
// branch), by bare name only — a commemoration never carries a rank
|
||||||
// parenthetical, only the day's own winner does.
|
// parenthetical, only the day's own winner does.
|
||||||
expect(getDayLabel(resolveDay('2026-05-31'))).toBe(
|
expect(getDayLabel(resolveDay('2026-05-31')).en).toBe(
|
||||||
'The Queenship of the Blessed Virgin Mary (Duplex II Class) — Trinity Sunday',
|
'The Queenship of the Blessed Virgin Mary (Duplex II Class) — Trinity Sunday',
|
||||||
);
|
);
|
||||||
expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter (Duplex I Class)');
|
expect(getDayLabel(resolveDay('2026-04-05')).en).toBe('Easter (Duplex I Class)');
|
||||||
expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday');
|
expect(getDayLabel(resolveDay('2026-02-18')).en).toBe('Ash Wednesday');
|
||||||
|
// Latin: no authored Latin saint name exists yet, so the winner's name
|
||||||
|
// falls back to the same English string (see file header) — only the
|
||||||
|
// rank label ("Duplex II. Classis") and the commemorated Sunday's own
|
||||||
|
// name ("Dominica Sanctissimae Trinitatis") are real Latin.
|
||||||
|
expect(getDayLabel(resolveDay('2026-05-31')).la).toBe(
|
||||||
|
'The Queenship of the Blessed Virgin Mary (Duplex II. Classis) — Dominica Sanctissimae Trinitatis',
|
||||||
|
);
|
||||||
|
expect(getDayLabel(resolveDay('2026-04-05')).la).toBe('Pascha (Duplex I. Classis)');
|
||||||
|
expect(getDayLabel(resolveDay('2026-02-18')).la).toBe('Feria Quarta Cinerum');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives the correct week-after-Trinity ordinal', () => {
|
it('gives the correct week-after-Trinity ordinal', () => {
|
||||||
@@ -40,7 +52,7 @@ describe('getDayLabel — ordinal temporal label', () => {
|
|||||||
// sanctoral import), which wins outright. 2027's Trinity Sunday is
|
// sanctoral import), which wins outright. 2027's Trinity Sunday is
|
||||||
// May 23, so 5 full weeks after the first Sunday-after-Trinity
|
// May 23, so 5 full weeks after the first Sunday-after-Trinity
|
||||||
// (May 30) is Jun 28 -- free of any sanctoral entry.
|
// (May 30) is Jun 28 -- free of any sanctoral entry.
|
||||||
expect(getDayLabel(resolveDay('2027-06-28'))).toBe('Monday in the 5th week after Trinity (Feria)');
|
expect(getDayLabel(resolveDay('2027-06-28')).en).toBe('Monday in the 5th week after Trinity (Feria)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Advent's own anchor Sunday is already week 1, not a separate anchor-week case", () => {
|
it("Advent's own anchor Sunday is already week 1, not a separate anchor-week case", () => {
|
||||||
@@ -50,8 +62,15 @@ describe('getDayLabel — ordinal temporal label', () => {
|
|||||||
// reference engine, which shows the same pairing), so the label
|
// reference engine, which shows the same pairing), so the label
|
||||||
// reflects both, feast name first, same pattern as Trinity Sunday's
|
// reflects both, feast name first, same pattern as Trinity Sunday's
|
||||||
// own St. Felix I case above.
|
// own St. Felix I case above.
|
||||||
expect(getDayLabel(resolveDay('2025-11-30'))).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)');
|
expect(getDayLabel(resolveDay('2025-11-30')).en).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)');
|
||||||
expect(getDayLabel(resolveDay('2025-12-01'))).toBe('Monday in the 1st week of Advent (Feria)');
|
// Advent drops the redundant leading weekday word now that the header
|
||||||
|
// always shows the weekday on its own (see temporalLabel's
|
||||||
|
// `dropWeekdayPrefix`).
|
||||||
|
expect(getDayLabel(resolveDay('2025-12-01')).en).toBe('In the 1st week of Advent (Feria)');
|
||||||
|
// Latin: same weekday-drop, and the season-ordinal genitive
|
||||||
|
// construction ("Hebdomada prima Adventus") needs no extra linking
|
||||||
|
// word — 4th-declension "Adventus" has the same genitive form.
|
||||||
|
expect(getDayLabel(resolveDay('2025-12-01')).la).toBe('In hebdomada prima Adventus (Feria)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => {
|
it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => {
|
||||||
@@ -59,7 +78,7 @@ describe('getDayLabel — ordinal temporal label', () => {
|
|||||||
// (Duplex, added per the full-year sanctoral import) -- moved to
|
// (Duplex, added per the full-year sanctoral import) -- moved to
|
||||||
// May 21, still within Ascensiontide 2026 (May 14-23) and still
|
// May 21, still within Ascensiontide 2026 (May 14-23) and still
|
||||||
// free of any sanctoral entry.
|
// free of any sanctoral entry.
|
||||||
expect(getDayLabel(resolveDay('2026-05-21'))).toContain('in Ascensiontide');
|
expect(getDayLabel(resolveDay('2026-05-21')).en).toContain('in Ascensiontide');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +96,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
|
|||||||
// Simplex commemoration (Ss. Tryphon, Respicius, and Nympha), shown
|
// Simplex commemoration (Ss. Tryphon, Respicius, and Nympha), shown
|
||||||
// appended to the Sunday's own label per this app's usual
|
// appended to the Sunday's own label per this app's usual
|
||||||
// commemorated-Simplex display.
|
// commemorated-Simplex display.
|
||||||
expect(getDayLabel(resolveDay('2024-11-10'))).toBe(
|
expect(getDayLabel(resolveDay('2024-11-10')).en).toBe(
|
||||||
'Ss. Tryphon, Respicius, and Nympha, Martyrs — The 5th Sunday after Epiphany (Semiduplex)',
|
'Ss. Tryphon, Respicius, and Nympha, Martyrs — The 5th Sunday after Epiphany (Semiduplex)',
|
||||||
);
|
);
|
||||||
// 2024-11-11 is St. Martin of Tours (Duplex, pre-existing content) --
|
// 2024-11-11 is St. Martin of Tours (Duplex, pre-existing content) --
|
||||||
@@ -89,10 +108,10 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
|
|||||||
// overflow-week Monday instead (no single Nov-dated Monday near this
|
// overflow-week Monday instead (no single Nov-dated Monday near this
|
||||||
// Sunday pair is clean any more, now that Nov 4/8/15/16/20 are all
|
// Sunday pair is clean any more, now that Nov 4/8/15/16/20 are all
|
||||||
// fixed real content).
|
// fixed real content).
|
||||||
expect(getDayLabel(resolveDay('2024-11-11'))).toBe(
|
expect(getDayLabel(resolveDay('2024-11-11')).en).toBe(
|
||||||
'St. Martin of Tours, Bishop and Confessor (Duplex Majus) — St. Menna, Martyr',
|
'St. Martin of Tours, Bishop and Confessor (Duplex Majus) — St. Menna, Martyr',
|
||||||
);
|
);
|
||||||
expect(getDayLabel(resolveDay('2024-11-17'))).toBe('The 6th Sunday after Epiphany (Semiduplex)');
|
expect(getDayLabel(resolveDay('2024-11-17')).en).toBe('The 6th Sunday after Epiphany (Semiduplex)');
|
||||||
// 2024-11-18 is the Dedication of the Basilicas of Ss. Peter and Paul
|
// 2024-11-18 is the Dedication of the Basilicas of Ss. Peter and Paul
|
||||||
// (pre-existing fixed content) -- again a real winning saint, not the
|
// (pre-existing fixed content) -- again a real winning saint, not the
|
||||||
// ferial fallback -- with St. Gregory Thaumaturgus (Semiduplex,
|
// ferial fallback -- with St. Gregory Thaumaturgus (Semiduplex,
|
||||||
@@ -100,14 +119,14 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
|
|||||||
// direct instruction, a Semiduplex feast on an ordinary Sunday
|
// direct instruction, a Semiduplex feast on an ordinary Sunday
|
||||||
// transfers to the next open day rather than being commemorated in
|
// transfers to the next open day rather than being commemorated in
|
||||||
// place, and Nov 18 is that day.
|
// place, and Nov 18 is that day.
|
||||||
expect(getDayLabel(resolveDay('2024-11-18'))).toBe(
|
expect(getDayLabel(resolveDay('2024-11-18')).en).toBe(
|
||||||
'Dedication of the Basilicas of Ss. Peter and Paul (Duplex) — St. Gregory Thaumaturgus, Bishop and Confessor',
|
'Dedication of the Basilicas of Ss. Peter and Paul (Duplex) — St. Gregory Thaumaturgus, Bishop and Confessor',
|
||||||
);
|
);
|
||||||
// 2035-10-29, a Monday in the 3rd week of a different overflow
|
// 2035-10-29, a Monday in the 3rd week of a different overflow
|
||||||
// stretch, is genuinely clean (no sanctoral entry, no commemoration)
|
// stretch, is genuinely clean (no sanctoral entry, no commemoration)
|
||||||
// -- covers the ferial "Monday in the Nth week after Epiphany" format
|
// -- covers the ferial "Monday in the Nth week after Epiphany" format
|
||||||
// itself, independent of the specific 5th/6th Sunday pair above.
|
// itself, independent of the specific 5th/6th Sunday pair above.
|
||||||
expect(getDayLabel(resolveDay('2035-10-29'))).toBe('Monday in the 3rd week after Epiphany (Feria)');
|
expect(getDayLabel(resolveDay('2035-10-29')).en).toBe('Monday in the 3rd week after Epiphany (Feria)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the fixed final Sunday of the year always shows the fixed 23rd-after-Trinity ordinal, not a raw elapsed-week count', () => {
|
it('the fixed final Sunday of the year always shows the fixed 23rd-after-Trinity ordinal, not a raw elapsed-week count', () => {
|
||||||
@@ -122,8 +141,8 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
|
|||||||
// Thaumaturgus above -- landing on 2026-11-23 instead, where St.
|
// Thaumaturgus above -- landing on 2026-11-23 instead, where St.
|
||||||
// Clement already natively wins; Cecilia loses that tied collision
|
// Clement already natively wins; Cecilia loses that tied collision
|
||||||
// (both Semiduplex) and joins St. Felicitas as a commemoration there.
|
// (both Semiduplex) and joins St. Felicitas as a commemoration there.
|
||||||
expect(getDayLabel(resolveDay('2026-11-22'))).toBe('The 23rd Sunday after Trinity (Semiduplex)');
|
expect(getDayLabel(resolveDay('2026-11-22')).en).toBe('The 23rd Sunday after Trinity (Semiduplex)');
|
||||||
expect(getDayLabel(resolveDay('2026-11-23'))).toBe(
|
expect(getDayLabel(resolveDay('2026-11-23')).en).toBe(
|
||||||
'St. Clement I, Pope and Martyr (Semiduplex) — St. Felicitas, Martyr — St. Cecilia, Virgin and Martyr',
|
'St. Clement I, Pope and Martyr (Semiduplex) — St. Felicitas, Martyr — St. Cecilia, Virgin and Martyr',
|
||||||
);
|
);
|
||||||
// 1943-11-21 is also the Presentation of the BVM (Duplex Majus, added
|
// 1943-11-21 is also the Presentation of the BVM (Duplex Majus, added
|
||||||
@@ -136,7 +155,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
|
|||||||
// additionally confirms the displaced Sunday is still commemorated in
|
// additionally confirms the displaced Sunday is still commemorated in
|
||||||
// return, using the same fixed 23rd-after-Trinity ordinal, not a raw
|
// return, using the same fixed 23rd-after-Trinity ordinal, not a raw
|
||||||
// elapsed-week count recomputed for 1943.
|
// elapsed-week count recomputed for 1943.
|
||||||
expect(getDayLabel(resolveDay('1943-11-21'))).toBe(
|
expect(getDayLabel(resolveDay('1943-11-21')).en).toBe(
|
||||||
'The Presentation of the Blessed Virgin Mary (Duplex Majus) — The 23rd Sunday after Trinity',
|
'The Presentation of the Blessed Virgin Mary (Duplex Majus) — The 23rd Sunday after Trinity',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -150,8 +169,10 @@ describe('getDayLabel — commemorated Sunday/feria under a sanctoral winner', (
|
|||||||
// (`decideOccurrence`'s `privileged-feria-minor` branch) -- live-
|
// (`decideOccurrence`'s `privileged-feria-minor` branch) -- live-
|
||||||
// verified shape, same mechanism as the ordinary-Sunday case above,
|
// verified shape, same mechanism as the ordinary-Sunday case above,
|
||||||
// for the feria-tier branch instead.
|
// for the feria-tier branch instead.
|
||||||
expect(getDayLabel(resolveDay('2027-12-07'))).toBe(
|
// Advent drops the redundant leading weekday word (see the Dec 1 case
|
||||||
'St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex) — Tuesday in the 2nd week of Advent',
|
// above).
|
||||||
|
expect(getDayLabel(resolveDay('2027-12-07')).en).toBe(
|
||||||
|
'St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex) — In the 2nd week of Advent',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -162,7 +183,7 @@ describe('getDayLabel — active octave', () => {
|
|||||||
// Clare's own day -- Simplex, below the octave's semiduplex threshold,
|
// Clare's own day -- Simplex, below the octave's semiduplex threshold,
|
||||||
// so she's commemorated rather than winning. Matches the live engine's
|
// so she's commemorated rather than winning. Matches the live engine's
|
||||||
// own title for this date directly (see saints/st-clare.yml).
|
// own title for this date directly (see saints/st-clare.yml).
|
||||||
expect(getDayLabel(resolveDay('2026-08-12'))).toBe(
|
expect(getDayLabel(resolveDay('2026-08-12')).en).toBe(
|
||||||
'3rd Day within the Octave of St. Lawrence, Martyr (Semiduplex) — St. Clare, Virgin',
|
'3rd Day within the Octave of St. Lawrence, Martyr (Semiduplex) — St. Clare, Virgin',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -181,7 +202,7 @@ describe('getDayLabel — active octave', () => {
|
|||||||
// "real content closed the gap" shape as August's Lawrence/Assumption
|
// "real content closed the gap" shape as August's Lawrence/Assumption
|
||||||
// overlap. Day 3 of the same octave (2028-11-03, a Friday) is still
|
// overlap. Day 3 of the same octave (2028-11-03, a Friday) is still
|
||||||
// genuinely open -- no sanctoral entry, no separate commemoration.
|
// genuinely open -- no sanctoral entry, no separate commemoration.
|
||||||
expect(getDayLabel(resolveDay('2028-11-03'))).toBe('3rd Day within the Octave of All Saints (Semiduplex)');
|
expect(getDayLabel(resolveDay('2028-11-03')).en).toBe('3rd Day within the Octave of All Saints (Semiduplex)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a season's own named anchor day outranks an active octave, even though Trinity Sunday is technically also day 8 of Pentecost's own octave", () => {
|
it("a season's own named anchor day outranks an active octave, even though Trinity Sunday is technically also day 8 of Pentecost's own octave", () => {
|
||||||
@@ -191,14 +212,14 @@ describe('getDayLabel — active octave', () => {
|
|||||||
// muddy this specifically octave-vs-anchor-day check with a sanctoral
|
// muddy this specifically octave-vs-anchor-day check with a sanctoral
|
||||||
// winner. 2030's Trinity Sunday (June 16) has no sanctoral entry at
|
// winner. 2030's Trinity Sunday (June 16) has no sanctoral entry at
|
||||||
// all, isolating the octave interaction cleanly.
|
// all, isolating the octave interaction cleanly.
|
||||||
expect(getDayLabel(resolveDay('2030-06-16'))).toBe('Trinity Sunday (Duplex I Class)');
|
expect(getDayLabel(resolveDay('2030-06-16')).en).toBe('Trinity Sunday (Duplex I Class)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a temporal day with real standing of its own (not ordinary-feria) never shows an octave name at all, even with one active -- live-verified counterexample: the Christmas Octave's own stack (Dec 30) titles itself off the Sunday, never any of the four octaves layered on top of it", () => {
|
it("a temporal day with real standing of its own (not ordinary-feria) never shows an octave name at all, even with one active -- live-verified counterexample: the Christmas Octave's own stack (Dec 30) titles itself off the Sunday, never any of the four octaves layered on top of it", () => {
|
||||||
const day = resolveDay('2033-12-30');
|
const day = resolveDay('2033-12-30');
|
||||||
expect(day.temporalCategory).toBe('privileged-feria-minor');
|
expect(day.temporalCategory).toBe('privileged-feria-minor');
|
||||||
expect(getDayLabel(day)).not.toContain('Octave');
|
expect(getDayLabel(day).en).not.toContain('Octave');
|
||||||
expect(getDayLabel(day)).toBe('Friday in Christmastide (Feria)');
|
expect(getDayLabel(day).en).toBe('Friday in Christmastide (Feria)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => {
|
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => {
|
||||||
@@ -221,10 +242,10 @@ describe('getDayLabel — active octave', () => {
|
|||||||
// question): "Commemoratio: Tertia die infra Octavam S. Assumptionis
|
// question): "Commemoratio: Tertia die infra Octavam S. Assumptionis
|
||||||
// Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed
|
// Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed
|
||||||
// Virgin Mary".
|
// Virgin Mary".
|
||||||
expect(getDayLabel(resolveDay('2026-08-17'))).toBe(
|
expect(getDayLabel(resolveDay('2026-08-17')).en).toBe(
|
||||||
'Octave of St. Lawrence, Martyr (Duplex) — 3rd Day within the Octave of The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor',
|
'Octave of St. Lawrence, Martyr (Duplex) — 3rd Day within the Octave of The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor',
|
||||||
);
|
);
|
||||||
expect(getDayLabel(resolveDay('2026-08-18'))).toBe(
|
expect(getDayLabel(resolveDay('2026-08-18')).en).toBe(
|
||||||
'4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)',
|
'4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -243,10 +264,10 @@ describe('getDayLabel — active octave', () => {
|
|||||||
// octave mentions alongside a sanctoral winner.
|
// octave mentions alongside a sanctoral winner.
|
||||||
const day = resolveDay('2026-08-16');
|
const day = resolveDay('2026-08-16');
|
||||||
expect(day.weekday).toBe('sunday');
|
expect(day.weekday).toBe('sunday');
|
||||||
expect(getDayLabel(day)).toBe(
|
expect(getDayLabel(day).en).toBe(
|
||||||
'St. Joachim, Confessor, Father of the Blessed Virgin Mary (Duplex II Class) — The 11th Sunday after Trinity',
|
'St. Joachim, Confessor, Father of the Blessed Virgin Mary (Duplex II Class) — The 11th Sunday after Trinity',
|
||||||
);
|
);
|
||||||
expect(getDayLabel(day)).not.toContain('Octave');
|
expect(getDayLabel(day).en).not.toContain('Octave');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a foreign octave superimposed on a privileged season still governs the label when its own effective rank clears that season's real threshold, with the season's own losing saint commemorated alongside it", () => {
|
it("a foreign octave superimposed on a privileged season still governs the label when its own effective rank clears that season's real threshold, with the season's own losing saint commemorated alongside it", () => {
|
||||||
@@ -266,7 +287,7 @@ describe('getDayLabel — active octave', () => {
|
|||||||
// mechanism (also used by hours/resolve-common.ts's
|
// mechanism (also used by hours/resolve-common.ts's
|
||||||
// resolveOfficeWinner for the actual office content, not just this
|
// resolveOfficeWinner for the actual office content, not just this
|
||||||
// label).
|
// label).
|
||||||
expect(getDayLabel(resolveDay('2026-12-15'))).toBe(
|
expect(getDayLabel(resolveDay('2026-12-15')).en).toBe(
|
||||||
'Octave of The Immaculate Conception of the Blessed Virgin Mary (Duplex) — St. Eusebius of Vercelli, Bishop and Martyr',
|
'Octave of The Immaculate Conception of the Blessed Virgin Mary (Duplex) — St. Eusebius of Vercelli, Bishop and Martyr',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -280,7 +301,7 @@ describe('getDayLabel — active octave', () => {
|
|||||||
const day = resolveDay('2033-12-30');
|
const day = resolveDay('2033-12-30');
|
||||||
expect(day.temporalCategory).toBe('privileged-feria-minor');
|
expect(day.temporalCategory).toBe('privileged-feria-minor');
|
||||||
expect(day.season).toBe('christmastide');
|
expect(day.season).toBe('christmastide');
|
||||||
expect(getDayLabel(day)).not.toContain('Octave');
|
expect(getDayLabel(day).en).not.toContain('Octave');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a sanctoral winner that outright displaces the only active octave still shows that octave with its full "Nth Day within the Octave" phrasing, not a bare feast name -- and without a rank, since the octave is riding along under the winning duplex rather than asserting its own rank', () => {
|
it('a sanctoral winner that outright displaces the only active octave still shows that octave with its full "Nth Day within the Octave" phrasing, not a bare feast name -- and without a rank, since the octave is riding along under the winning duplex rather than asserting its own rank', () => {
|
||||||
@@ -296,7 +317,7 @@ describe('getDayLabel — active octave', () => {
|
|||||||
// itself is what's being commemorated against a winning saint) and
|
// itself is what's being commemorated against a winning saint) and
|
||||||
// also not `octaveLabel`'s own rank parenthetical (that belongs only
|
// also not `octaveLabel`'s own rank parenthetical (that belongs only
|
||||||
// to an octave day when it's the day's own primary winner).
|
// to an octave day when it's the day's own primary winner).
|
||||||
expect(getDayLabel(resolveDay('2026-08-19'))).toBe(
|
expect(getDayLabel(resolveDay('2026-08-19')).en).toBe(
|
||||||
'St. John Eudes, Confessor (Duplex) — 5th Day within the Octave of The Assumption of the Blessed Virgin Mary',
|
'St. John Eudes, Confessor (Duplex) — 5th Day within the Octave of The Assumption of the Blessed Virgin Mary',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -311,7 +332,7 @@ describe('getDayLabel — named temporal feast rank', () => {
|
|||||||
// direct winner id) out of scope here. Pentecost Sunday's own id
|
// direct winner id) out of scope here. Pentecost Sunday's own id
|
||||||
// *does* resolve as the direct winner (`temporal-id.ts`'s
|
// *does* resolve as the direct winner (`temporal-id.ts`'s
|
||||||
// EASTER_OFFSET_IDS), so it's a real, clean case for this branch.
|
// EASTER_OFFSET_IDS), so it's a real, clean case for this branch.
|
||||||
expect(getDayLabel(resolveDay('2026-05-24'))).toBe('Pentecost (Duplex I Class)');
|
expect(getDayLabel(resolveDay('2026-05-24')).en).toBe('Pentecost (Duplex I Class)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,7 +351,7 @@ describe('getDayLabel — feast name combination', () => {
|
|||||||
...base,
|
...base,
|
||||||
winner: { kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' },
|
winner: { kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' },
|
||||||
};
|
};
|
||||||
expect(getDayLabel(day)).toBe('St. Ereden (Duplex)');
|
expect(getDayLabel(day).en).toBe('St. Ereden (Duplex)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows both, feast first, when the feast is merely commemorated', () => {
|
it('shows both, feast first, when the feast is merely commemorated', () => {
|
||||||
@@ -338,10 +359,10 @@ describe('getDayLabel — feast name combination', () => {
|
|||||||
...base,
|
...base,
|
||||||
commemorations: [{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis' }],
|
commemorations: [{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis' }],
|
||||||
};
|
};
|
||||||
expect(getDayLabel(day)).toBe('St. Ereden — Monday in the 2nd week after Trinity (Feria)');
|
expect(getDayLabel(day).en).toBe('St. Ereden — Monday in the 2nd week after Trinity (Feria)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows just the temporal label when nothing is commemorated at all', () => {
|
it('shows just the temporal label when nothing is commemorated at all', () => {
|
||||||
expect(getDayLabel(base)).toBe('Monday in the 2nd week after Trinity (Feria)');
|
expect(getDayLabel(base).en).toBe('Monday in the 2nd week after Trinity (Feria)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('Marian Saturday (applyMarianSaturday)', () => {
|
|||||||
const day = resolveDay('2026-07-04');
|
const day = resolveDay('2026-07-04');
|
||||||
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
|
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
|
||||||
expect(day.commemorations).toEqual([]);
|
expect(day.commemorations).toEqual([]);
|
||||||
expect(getDayLabel(day)).toBe("Our Lady's Saturday (Simplex)");
|
expect(getDayLabel(day).en).toBe("Our Lady's Saturday (Simplex)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wins over a Simplex saint, who is commemorated instead of winning outright', () => {
|
it('wins over a Simplex saint, who is commemorated instead of winning outright', () => {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe('resolveOrdo("compline", ...)', () => {
|
|||||||
const eve = resolveOrdo('compline', '2025-11-29');
|
const eve = resolveOrdo('compline', '2025-11-29');
|
||||||
const last = eve.parts[eve.parts.length - 1];
|
const last = eve.parts[eve.parts.length - 1];
|
||||||
expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater');
|
expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater');
|
||||||
expect(eve.dayLabel).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)');
|
expect(eve.dayLabel?.en).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)');
|
||||||
|
|
||||||
const dayBefore = resolveOrdo('compline', '2025-11-28');
|
const dayBefore = resolveOrdo('compline', '2025-11-28');
|
||||||
const lastBefore = dayBefore.parts[dayBefore.parts.length - 1];
|
const lastBefore = dayBefore.parts[dayBefore.parts.length - 1];
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
|
|||||||
it('opens with the Deus in adjutorium versicle, then Psalm 3, then the Invitatory (Psalm 94)', () => {
|
it('opens with the Deus in adjutorium versicle, then Psalm 3, then the Invitatory (Psalm 94)', () => {
|
||||||
expect(ordo.parts[0]?.kind).toBe('versicle');
|
expect(ordo.parts[0]?.kind).toBe('versicle');
|
||||||
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
|
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
|
||||||
expect(ordo.parts[2]).toMatchObject({ kind: 'psalm', psalmNumber: 94 });
|
expect(ordo.parts[2]).toMatchObject({ kind: 'section-heading', label: 'Invitatory' });
|
||||||
const invitatoryAntiphon = (ordo.parts[2] as { antiphon?: { text: Record<string, string> } }).antiphon;
|
expect(ordo.parts[3]).toMatchObject({ kind: 'psalm', psalmNumber: 94 });
|
||||||
|
const invitatoryAntiphon = (ordo.parts[3] as { antiphon?: { text: Record<string, string> } }).antiphon;
|
||||||
expect(invitatoryAntiphon?.text.la).toContain('Veníte');
|
expect(invitatoryAntiphon?.text.la).toContain('Veníte');
|
||||||
// Real practice repeats the invitatory antiphon between verse groups —
|
// Real practice repeats the invitatory antiphon between verse groups —
|
||||||
// deliberately not modeled that way here (see hours/matins.ts's own
|
// deliberately not modeled that way here (see hours/matins.ts's own
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ describe('shell', () => {
|
|||||||
mountShell(root);
|
mountShell(root);
|
||||||
|
|
||||||
expect(root.querySelector('.day-nav-date')?.textContent).toContain('August 9, 2026');
|
expect(root.querySelector('.day-nav-date')?.textContent).toContain('August 9, 2026');
|
||||||
|
// The "day being celebrated" text now lives in the merged day-nav
|
||||||
|
// header, not repeated inside each hour's own view.
|
||||||
|
expect(root.querySelector('.day-nav-label')?.textContent).toBeTruthy();
|
||||||
|
expect(root.querySelector('.hour-view .day-label')).toBeNull();
|
||||||
|
|
||||||
const hourNames = Array.from(root.querySelectorAll('.hour-name')).map((el) => el.textContent);
|
const hourNames = Array.from(root.querySelectorAll('.hour-name')).map((el) => el.textContent);
|
||||||
expect(hourNames).toEqual([
|
expect(hourNames).toEqual([
|
||||||
|
|||||||
Reference in New Issue
Block a user