Compare commits

...

3 Commits

Author SHA1 Message Date
will 226fff2ce6 ui: show the "day being celebrated" label
Deploy / deploy (push) Successful in 38s
Adds a real label under each hour's heading — "Monday in the 10th week
after Trinity", "The 1st Sunday of Advent" — computed from whichever
LiturgicalDay the hour actually resolved against, which can differ from
the nav date for Compline's evening anticipation.

calendar/day-label.ts combines two things: an ordinal week-within-season
label (pure date arithmetic on the season anchors from calendar/temporal.ts
and calendar/easter.ts — Trinity-counted, not Divinum Officium's own
Pentecost-counted convention; meant to become configurable later via the
same day->id indirection already used for the sanctoral calendar, not
hardcoded forever), and a feast name from calendar/commemorations.ts's
occurrence decision — shown alone if the feast displaces the day outright,
prefixed onto the temporal label if merely commemorated, or omitted
entirely if nothing's occurring.
2026-08-10 07:07:10 -04:00
will eaaca05ad8 calendar: First/Second Vespers anticipation for Compline
Compline (and eventually Vespers) now resolves off whichever day's
identity actually governs tonight's office, not always today's — real
practice anticipates a higher-ranking tomorrow (Alma Redemptoris Mater
starting the Saturday evening before Advent I, Ave Regina Caelorum
starting the eve of Candlemas, Regina Caeli starting at the Easter
Vigil), which the previous date-only resolution missed entirely.

calendar/vespers.ts's resolveEveningDay compares today's and tomorrow's
precedence (reusing commemorations.ts's FeastClass machinery) to decide.
One deliberate absolute exception, caught by a failing test: a short,
explicit list of supreme fixed/movable feasts (Easter, Christmas,
Epiphany, Candlemas, Ascension, Corpus Christi, Sacred Heart) always wins
tomorrow's Vespers before checking whether today would otherwise keep its
own — without it, Holy Saturday's privileged-feria status incorrectly
blocked Easter's anticipation, the textbook case this whole thing exists
to get right.

hours/compline.ts now resolves its whole ordo through resolveEveningDay
instead of resolveDay.
2026-08-10 07:05:41 -04:00
will 2ea21f2c89 calendar: real occurrence/precedence resolution (feast ranks, sanctoral content)
Replaces the FeastRank placeholder with a real ordered FeastClass (the old
pre-1955 six-level scale) and a new TemporalCategory (privileged/ordinary
Sunday/feria). calendar/commemorations.ts's decideOccurrence encodes the
actual precedence rules as explicit, commented branches per category
rather than the reference engine's own opaque numeric weights — this app
targets one ruleset, so the readability trade-off is worth it. Category
membership (data/calendar/temporal-categories.yml) is a best-effort
reconstruction of the pre-1955 tradition, not sourced from a primary text;
expect corrections.

calendar/feasts.ts resolves real sanctoral candidates, and resolveDay()
wires it all together — occurring/temporalCategory are no longer stubs.
isDoubleOrHigher (hours/antiphon.ts) is now a real comparison instead of
a hardcoded false.

Seeded 7 real saints/feasts, each verified directly against Divinum
Officium's own rank data (not reconstructed from memory) as a small,
growable start: St. Lawrence, the Assumption, St. Augustine, the Nativity
of the BVM, St. Michael, All Saints, the Immaculate Conception.
2026-08-10 07:03:28 -04:00
28 changed files with 859 additions and 59 deletions
+75 -5
View File
@@ -1,5 +1,75 @@
// Precedence/occurrence rules for when a lower-ranked feast is commemorated // Precedence/occurrence resolution: given a day's temporal-cycle standing
// rather than fully displaced by the day's winning feast. Deliberately not // (calendar/types.ts's TemporalCategory) and a candidate sanctoral feast (if
// designed yet — see plan point 5 ("calendar rules are expected to iterate"). // any), which one is actually kept, and whether the loser is commemorated.
// Unused until milestone 4. //
export {}; // Modeled as explicit rules per category, not a numeric-weight comparison
// like the reference engine's occurrence()/concurrence() — see
// data/calendar/temporal-categories.yml's header for why. The thresholds
// below are a best-effort reconstruction of the pre-1955 tradition (partly
// grounded in General Rubrics §15/§16/§23, though that document is itself a
// later, differently-numbered edition — see that file's header), not
// verified against a primary source for this exact era. Expect corrections.
import type { FeastClass, TemporalCategory } from './types';
const FEAST_CLASS_ORDER: FeastClass[] = [
'simplex',
'semiduplex',
'duplex',
'duplex-majus',
'duplex-2-classis',
'duplex-1-classis',
];
export function compareFeastClass(a: FeastClass, b: FeastClass): number {
return FEAST_CLASS_ORDER.indexOf(a) - FEAST_CLASS_ORDER.indexOf(b);
}
export function isAtLeast(rank: FeastClass, threshold: FeastClass): boolean {
return compareFeastClass(rank, threshold) >= 0;
}
export interface OccurrenceResult {
winner: 'temporal' | 'sanctoral';
commemorated: boolean;
}
/**
* `sanctoral === null` means no feast is assigned to this date at all —
* temporal wins trivially, nothing to decide.
*/
export function decideOccurrence(temporal: TemporalCategory, sanctoral: FeastClass | null): OccurrenceResult {
if (!sanctoral) {
return { winner: 'temporal', commemorated: false };
}
switch (temporal) {
case 'ordinary-feria':
// An ordinary feria has no standing of its own to defend — any real
// feast, however low-ranked, is kept in its place.
return { winner: 'sanctoral', commemorated: false };
case 'privileged-feria':
// "These ferias are preferred to any feasts whatsoever, and they
// admit of no commemoration, except one of the privileged class"
// (General Rubrics §23) — read here as: only the very highest class
// even gets a mention.
return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-1-classis') };
case 'ordinary-sunday':
// An ordinary Sunday yields outright only to the highest class; a
// Double of the 2nd Class or a Greater Double is kept as a
// commemoration instead of displacing the Sunday; anything lower
// isn't even mentioned.
if (isAtLeast(sanctoral, 'duplex-1-classis')) {
return { winner: 'sanctoral', commemorated: false };
}
return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-majus') };
case 'privileged-sunday':
// "A Sunday of the 1st class is preferred to any feast whatsoever"
// (General Rubrics §15) — never displaced; commemorated only if the
// feast is otherwise of the very top ranks.
return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-2-classis') };
}
}
+157
View File
@@ -0,0 +1,157 @@
// "Day being celebrated" label — combines an ordinal week-within-season
// label (pure date arithmetic on the season anchors already computed
// elsewhere in calendar/) with a feast name when
// calendar/commemorations.ts's occurrence decision says one applies.
//
// Counting convention is Trinity-counted ("Nth Sunday/week after Trinity"),
// not Divinum Officium's own "after Pentecost" counting — a deliberate
// choice, one week off from Pentecost-counting for the same date. Meant to
// become a configurable choice later (the same day->id indirection
// philosophy already used for the sanctoral calendar), not hardcoded here
// forever — just not built yet.
import type { LiturgicalDay } from './types';
import { easterSunday } from './easter';
import { adventStart } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math';
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
function ordinal(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 13) {
return `${n}th`;
}
switch (n % 10) {
case 1:
return `${n}st`;
case 2:
return `${n}nd`;
case 3:
return `${n}rd`;
default:
return `${n}th`;
}
}
function weekdayOf(isoDate: string): number {
return new Date(`${isoDate}T00:00:00Z`).getUTCDay(); // 0 = Sunday
}
/** The first Sunday strictly after `isoDate` (even if `isoDate` is itself a Sunday). */
function firstSundayStrictlyAfter(isoDate: string): string {
const dow = weekdayOf(isoDate);
return addDays(isoDate, dow === 0 ? 7 : 7 - dow);
}
function sundayOnOrBefore(isoDate: string): string {
return addDays(isoDate, -weekdayOf(isoDate));
}
interface OrdinalSeason {
/** ISO date of the season's own anchor day, for a given calendar year. */
anchorDate(year: number): string;
/** Advent: the anchor Sunday itself is "week 1". Trinity/Epiphany/Easter/
* Lent: the anchor is its own named day, excluded from the count — the
* numbered weeks start the following Sunday. */
includeAnchorWeek: boolean;
/** Used in "Weekday after {anchorName}" for the anchor's own partial week. */
anchorName: string;
/** Used in "the Nth Sunday/week {preposition} {ordinalName}". */
ordinalName: string;
preposition: 'after' | 'of';
}
const ORDINAL_SEASONS: Partial<Record<string, OrdinalSeason>> = {
advent: {
anchorDate: adventStart,
includeAnchorWeek: true,
anchorName: 'Advent',
ordinalName: 'Advent',
preposition: 'of',
},
epiphanytide: {
anchorDate: (year) => `${year}-01-06`,
includeAnchorWeek: false,
anchorName: 'Epiphany',
ordinalName: 'Epiphany',
preposition: 'after',
},
lent: {
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), -46),
includeAnchorWeek: false,
anchorName: 'Ash Wednesday',
ordinalName: 'Lent',
preposition: 'of',
},
eastertide: {
anchorDate: (year) => toIsoDate(easterSunday(year)),
includeAnchorWeek: false,
anchorName: 'Easter',
ordinalName: 'Easter',
preposition: 'after',
},
trinitytide: {
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), 56),
includeAnchorWeek: false,
anchorName: 'Trinity Sunday',
ordinalName: 'Trinity',
preposition: 'after',
},
};
function temporalLabel(day: LiturgicalDay): string {
const weekdayName = capitalize(day.weekday);
const config = ORDINAL_SEASONS[day.season];
if (!config) {
// No ordinal convention modeled for this season (Septuagesima-tide,
// Passiontide, Ascensiontide, Pentecost, Christmastide, the
// Corpus-Christi/Sacred-Heart single-day seasons) — their few days
// mostly have their own proper names rather than ordinal counting, so
// this fallback is expected to be seen, not a gap to fill later.
return `${weekdayName} in ${capitalize(day.season.replace(/-/g, ' '))}`;
}
const year = Number(day.date.slice(0, 4));
const anchor = config.anchorDate(year);
if (!config.includeAnchorWeek && day.date === anchor) {
// The anchor day itself (Ash Wednesday, Epiphany, Easter Sunday,
// Trinity Sunday) is its own named day, not "day after itself" — only
// reachable here at all when nothing in `occurring` already covers it
// (none of these are modeled as sanctoral entries yet). Advent's own
// anchor (Advent I Sunday) doesn't take this branch — it's already
// "the 1st Sunday of Advent" via the ordinal path below.
return config.anchorName;
}
const firstNumberedSunday = config.includeAnchorWeek ? anchor : firstSundayStrictlyAfter(anchor);
if (day.date < firstNumberedSunday) {
return `${weekdayName} after ${config.anchorName}`;
}
const weeksSince = daysBetween(firstNumberedSunday, sundayOnOrBefore(day.date)) / 7;
const ordinalStr = ordinal(weeksSince + 1);
if (day.weekday === 'sunday') {
return `The ${ordinalStr} Sunday ${config.preposition} ${config.ordinalName}`;
}
return `${weekdayName} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName}`;
}
/**
* The full "day being celebrated" label: a feast name when
* calendar/commemorations.ts says the day has one, combined with (or
* replaced by) the ordinal temporal label depending on whether the feast
* won outright or was merely commemorated. See the plan discussion this
* came from for the three cases.
*/
export function getDayLabel(day: LiturgicalDay): string {
const winner = day.occurring.find((feast) => !feast.commemorated);
if (winner) {
return winner.name;
}
const temporal = temporalLabel(day);
const commemorated = day.occurring.find((feast) => feast.commemorated);
return commemorated ? `${commemorated.name}${temporal}` : temporal;
}
+49 -4
View File
@@ -1,6 +1,51 @@
// Sanctoral occurrence resolution: given a date, which saint(s) are // Sanctoral occurrence resolution: given a date, which saint(s) are
// assigned via data/calendar/sanctoral-calendar.yml's day -> saint-id // assigned via data/calendar/sanctoral-calendar.yml's day -> saint-id
// mapping, and their rank/propers/common from data/calendar/saints/<id>.yml. // mapping, and their rank from data/calendar/saints/<id>.yml. See
// See calendar/temporal.ts for the separate Easter/fixed-date resolution. // calendar/temporal.ts for the separate temporal-cycle resolution, and
// Unused until milestone 4. // calendar/commemorations.ts for how a candidate returned here actually
export {}; // gets weighed against the day's temporal standing.
//
// Doesn't resolve clashes *among* multiple saints sharing a date — real
// practice has its own precedence/commemoration rules for that too, not
// modeled here. If a day has more than one candidate, calendar/index.ts
// just picks the highest-ranked as the day's sole sanctoral contender.
import type { FeastClass } from './types';
import sanctoralCalendarData from '../data/calendar/sanctoral-calendar.yml';
interface SaintRecord {
id: string;
name: string;
rank: FeastClass;
common: string;
propers: string | null;
}
const sanctoralCalendar = sanctoralCalendarData as { days: Record<string, string[]> };
const saintModules = import.meta.glob<{ default: SaintRecord }>('../data/calendar/saints/*.yml', {
eager: true,
});
const saintsById = new Map<string, SaintRecord>();
for (const mod of Object.values(saintModules)) {
saintsById.set(mod.default.id, mod.default);
}
export interface SanctoralCandidate {
id: string;
name: string;
rank: FeastClass;
}
/** Which saint(s) (if any) are assigned to this date. `isoDate`'s year is ignored — the sanctoral cycle repeats every civil year. */
export function getSanctoralCandidatesFor(isoDate: string): SanctoralCandidate[] {
const monthDay = isoDate.slice(5);
const ids = sanctoralCalendar.days[monthDay] ?? [];
return ids.map((id) => {
const saint = saintsById.get(id);
if (!saint) {
throw new Error(`sanctoral-calendar.yml references unknown saint id '${id}'`);
}
return { id: saint.id, name: saint.name, rank: saint.rank };
});
}
+37 -14
View File
@@ -1,22 +1,45 @@
import type { LiturgicalDay } from './types'; import type { LiturgicalDay, OccurringFeast } from './types';
import { weekdayOf } from './weekday'; import { weekdayOf } from './weekday';
import { resolveSeason } from './temporal'; import { resolveSeason, resolveTemporalCategory } from './temporal';
import { getSanctoralCandidatesFor } from './feasts';
import { decideOccurrence, compareFeastClass } from './commemorations';
/** /**
* Resolves everything about a given day *except* hour content — weekday, * Resolves everything about a given day *except* hour content — weekday,
* season, and any occurring feasts. Season is real (see * season, temporal precedence category, and any occurring feast. All real
* calendar/temporal.ts); occurring feasts are still a stub until * now: `occurring` combines calendar/feasts.ts's sanctoral candidates (if
* calendar/feasts.ts lands (the sanctoral calendar — which saint, if any, * more than one shares a date, the highest-ranked wins that contest too —
* is kept on a given day, and Double-vs-not ranking — is a separate, * clashes *among* saints aren't otherwise modeled) with
* larger project from temporal-cycle season resolution). * calendar/commemorations.ts's occurrence decision. A feast that's fully
* superseded (not even commemorated) doesn't appear in `occurring` at all.
*/ */
export function resolveDay(isoDate: string): LiturgicalDay { export function resolveDay(isoDate: string): LiturgicalDay {
return { const weekday = weekdayOf(isoDate);
date: isoDate, const season = resolveSeason(isoDate);
weekday: weekdayOf(isoDate), const temporalCategory = resolveTemporalCategory(isoDate, season, weekday);
season: resolveSeason(isoDate),
occurring: [], const candidates = getSanctoralCandidatesFor(isoDate);
}; let topCandidate = candidates[0] ?? null;
for (const candidate of candidates) {
if (compareFeastClass(candidate.rank, topCandidate!.rank) > 0) {
topCandidate = candidate;
}
}
const occurring: OccurringFeast[] = [];
if (topCandidate) {
const { winner, commemorated } = decideOccurrence(temporalCategory, topCandidate.rank);
if (winner === 'sanctoral' || commemorated) {
occurring.push({
id: topCandidate.id,
name: topCandidate.name,
rank: topCandidate.rank,
commemorated: winner === 'temporal',
});
}
}
return { date: isoDate, weekday, season, temporalCategory, occurring };
} }
/** /**
@@ -31,4 +54,4 @@ export function isSundayOrFeast(day: LiturgicalDay): boolean {
return day.weekday === 'sunday' || day.occurring.length > 0; return day.weekday === 'sunday' || day.occurring.length > 0;
} }
export type { LiturgicalDay, OccurringFeast, Season, Weekday, FeastRank } from './types'; export type { LiturgicalDay, OccurringFeast, Season, Weekday, FeastClass, TemporalCategory } from './types';
+56 -2
View File
@@ -11,17 +11,24 @@
// a season is the temporal-cycle backdrop a day sits on; which saint (if // a season is the temporal-cycle backdrop a day sits on; which saint (if
// any) is being kept that day, and whether it outranks the season, is a // any) is being kept that day, and whether it outranks the season, is a
// separate, larger project. // separate, larger project.
import type { Season } from './types'; import type { Season, TemporalCategory, Weekday } from './types';
import { easterSunday } from './easter'; import { easterSunday } from './easter';
import { addDays, daysBetween, toIsoDate } from './date-math'; import { addDays, daysBetween, toIsoDate } from './date-math';
import fixedDateData from '../data/calendar/fixed-date-calendar.yml'; import fixedDateData from '../data/calendar/fixed-date-calendar.yml';
import easterOffsetsData from '../data/calendar/easter-offsets.yml'; import easterOffsetsData from '../data/calendar/easter-offsets.yml';
import temporalCategoriesData from '../data/calendar/temporal-categories.yml';
const fixedDates = fixedDateData as { dates: Record<string, string> }; const fixedDates = fixedDateData as { dates: Record<string, string> };
const easterOffsets = easterOffsetsData as { const easterOffsets = easterOffsetsData as {
ranges: { season: string; fromOffset: number }[]; ranges: { season: string; fromOffset: number }[];
days?: Record<string, string>; days?: Record<string, string>;
}; };
const temporalCategories = temporalCategoriesData as {
bySeason: Record<string, { sunday: TemporalCategory; feria: TemporalCategory }>;
offsets?: Record<string, TemporalCategory>;
offsetRanges?: { fromOffset: number; toOffset: number; category: TemporalCategory }[];
fixedDates?: Record<string, TemporalCategory>;
};
function fixedDateFor(id: string): { month: number; day: number } { function fixedDateFor(id: string): { month: number; day: number } {
const entry = Object.entries(fixedDates.dates).find(([, value]) => value === id); const entry = Object.entries(fixedDates.dates).find(([, value]) => value === id);
@@ -36,7 +43,7 @@ const CHRISTMAS = fixedDateFor('christmas-day');
const EPIPHANY = fixedDateFor('epiphany'); const EPIPHANY = fixedDateFor('epiphany');
/** The Sunday nearest Nov 30 (St. Andrew's Day) — Advent's real start rule. */ /** The Sunday nearest Nov 30 (St. Andrew's Day) — Advent's real start rule. */
function adventStart(year: number): string { export function adventStart(year: number): string {
const nov30 = `${year}-11-30`; const nov30 = `${year}-11-30`;
const dow = new Date(`${nov30}T00:00:00Z`).getUTCDay(); // 0 = Sunday const dow = new Date(`${nov30}T00:00:00Z`).getUTCDay(); // 0 = Sunday
const delta = dow <= 3 ? -dow : 7 - dow; const delta = dow <= 3 ? -dow : 7 - dow;
@@ -94,3 +101,50 @@ export function resolveSeason(isoDate: string): Season {
return seasonFromEasterOffset(daysBetween(easterIso, isoDate)); return seasonFromEasterOffset(daysBetween(easterIso, isoDate));
} }
/** Days from Easter Sunday (negative = before, 0 = Easter, positive = after) for the given date's own year. */
export function easterOffsetOf(isoDate: string): number {
const year = Number(isoDate.slice(0, 4));
return daysBetween(toIsoDate(easterSunday(year)), isoDate);
}
/**
* A day's precedence category under the temporal cycle alone — see
* calendar/types.ts's TemporalCategory doc comment and
* data/calendar/temporal-categories.yml for the reconstruction caveat.
*
* Sundays never fall on any of the offset/fixed-date overrides below (Ash
* Wednesday, Holy Week, the Easter/Pentecost octaves' weekdays, and the
* Christmas vigil are all, by construction, not Sundays) except possibly
* the Christmas vigil (Dec 24 can land on a Sunday) — and a Sunday's own
* privileged/ordinary status should win in that case regardless, so
* Sundays are resolved straight from `bySeason` without consulting the
* overrides at all.
*/
export function resolveTemporalCategory(isoDate: string, season: Season, weekday: Weekday): TemporalCategory {
const bySeasonEntry = temporalCategories.bySeason[season];
const base = bySeasonEntry ? (weekday === 'sunday' ? bySeasonEntry.sunday : bySeasonEntry.feria) : 'ordinary-feria';
if (weekday === 'sunday') {
return base;
}
const fixedOverride = temporalCategories.fixedDates?.[isoDate.slice(5)];
if (fixedOverride) {
return fixedOverride;
}
const offset = easterOffsetOf(isoDate);
const singleOverride = temporalCategories.offsets?.[String(offset)];
if (singleOverride) {
return singleOverride;
}
for (const range of temporalCategories.offsetRanges ?? []) {
if (offset >= range.fromOffset && offset <= range.toOffset) {
return range.category;
}
}
return base;
}
+27 -8
View File
@@ -36,18 +36,35 @@ export type Weekday =
// third case shows up and the duplication starts to hurt. // third case shows up and the duplication starts to hurt.
export type Season = string; export type Season = string;
// Open-ended on purpose — the actual ranking scheme (double/semidouble/simple, // The old (pre-1955) rank scale, six levels, low to high. Deliberately a
// or whatever the finalized rank system turns out to be) is a rule decision for // closed union rather than an open string like Season — the whole point of
// milestone 4, not a type decision for milestone 0. // calendar/commemorations.ts's decideOccurrence is to compare two of these
export type FeastRank = string; // with explicit, readable rules, which only works if the set of values is
// fixed and known. See calendar/types.ts's TemporalCategory doc comment for
// the other half of that comparison.
export type FeastClass = 'simplex' | 'semiduplex' | 'duplex' | 'duplex-majus' | 'duplex-2-classis' | 'duplex-1-classis';
// A day's own precedence class *before* any sanctoral feast is considered —
// i.e. what the temporal cycle alone says this day is entitled to. This is
// coarser than `season` on purpose: several different seasons share the
// same precedence behavior (Advent/Septuagesima/Lent/Passiontide Sundays
// are all "privileged" in the same way; Epiphanytide/Trinitytide Sundays
// are all "ordinary" in the same way), and decideOccurrence only cares
// about that behavior, not which season produced it. See
// data/calendar/temporal-categories.yml for which season maps to which
// category — reconstructed from general knowledge of the pre-1955
// tradition, not yet verified against a primary source, so expect
// corrections.
export type TemporalCategory = 'ordinary-feria' | 'privileged-feria' | 'ordinary-sunday' | 'privileged-sunday';
export interface OccurringFeast { export interface OccurringFeast {
id: string; id: string;
name: string; name: string;
rank: FeastRank; rank: FeastClass;
commemorated: boolean; commemorated: boolean;
// Placeholder for the first/second-Vespers overlap wrinkle — unresolved until // Set by calendar/vespers.ts's resolveEveningDay when this feast's First
// milestone 4 actually needs it. // Vespers is being anticipated this evening (i.e. this OccurringFeast
// belongs to *tomorrow*, but is winning tonight's Vespers/Compline).
vespersFrom?: 'today' | 'firstVespersOfTomorrow'; vespersFrom?: 'today' | 'firstVespersOfTomorrow';
} }
@@ -57,6 +74,8 @@ export interface LiturgicalDay {
weekday: Weekday; weekday: Weekday;
/** Real temporal-cycle season, computed via calendar/temporal.ts. */ /** Real temporal-cycle season, computed via calendar/temporal.ts. */
season: Season; season: Season;
/** Always [] until milestone 4 wires up calendar/feasts.ts. */ /** This day's own precedence class, before any sanctoral feast wins or loses against it. */
temporalCategory: TemporalCategory;
/** The feast(s) actually occurring — real, via calendar/feasts.ts + calendar/commemorations.ts. */
occurring: OccurringFeast[]; occurring: OccurringFeast[];
} }
+97
View File
@@ -0,0 +1,97 @@
// First vs. Second Vespers: whether an evening hour (Vespers, Compline)
// belongs to *today* (Second Vespers, closing today's own office) or
// anticipates *tomorrow* (First Vespers of a higher-ranking day) — e.g.
// Alma Redemptoris Mater starting at Compline the Saturday evening before
// Advent I, not on Advent Sunday itself. See calendar/commemorations.ts for
// the sibling temporal-vs-sanctoral decision this mirrors.
//
// Same reconstruction caveat as commemorations.ts/temporal-categories.yml:
// best-effort, not sourced from a primary text for this era, expect
// corrections.
import type { LiturgicalDay, OccurringFeast } from './types';
import { resolveDay } from './index';
import { addDays } from './date-math';
import { easterOffsetOf } from './temporal';
import { isAtLeast } from './commemorations';
/**
* Fixed/movable "feasts of the Lord" load-bearing elsewhere in this app
* (season boundaries, hours/marian-antiphon.ts's Candlemas window) but not
* yet modeled as real sanctoral/occurring entries — listed explicitly here
* so they still participate in First Vespers eligibility, rather than
* silently having none. Not a general mechanism, just this short, known
* list; a real sanctoral entry for any of these would make this
* redundant for that one date once added.
*/
function isMajorFixedFeastOfTheLord(isoDate: string): boolean {
const monthDay = isoDate.slice(5);
if (monthDay === '12-25' || monthDay === '01-06' || monthDay === '02-02') {
return true; // Christmas, Epiphany, Candlemas
}
const offset = easterOffsetOf(isoDate);
return offset === 0 || offset === 39 || offset === 60 || offset === 68; // Easter, Ascension, Corpus Christi, Sacred Heart
}
function winningFeast(day: LiturgicalDay): OccurringFeast | undefined {
return day.occurring.find((feast) => !feast.commemorated);
}
/** Does this day's evening claim First Vespers at all (for the day *after* it)? */
function hasFirstVespers(day: LiturgicalDay): boolean {
if (day.weekday === 'sunday' || isMajorFixedFeastOfTheLord(day.date)) {
return true;
}
const winner = winningFeast(day);
return winner ? isAtLeast(winner.rank, 'duplex-majus') : false;
}
/** Does this day keep its own Second Vespers regardless of what tomorrow is? */
function keepsOwnSecondVespers(day: LiturgicalDay): boolean {
if (day.weekday === 'sunday' || day.temporalCategory === 'privileged-feria' || isMajorFixedFeastOfTheLord(day.date)) {
return true;
}
const winner = winningFeast(day);
return winner ? isAtLeast(winner.rank, 'duplex-2-classis') : false;
}
function anticipated(tomorrow: LiturgicalDay): LiturgicalDay {
return {
...tomorrow,
occurring: tomorrow.occurring.map((feast) =>
feast.commemorated ? feast : { ...feast, vespersFrom: 'firstVespersOfTomorrow' },
),
};
}
/**
* Resolves which day's identity actually governs this evening's office.
* Returns `resolveDay(isoDate)` unchanged unless tomorrow has First
* Vespers *and* today doesn't keep its own Second Vespers regardless — in
* which case returns tomorrow's `LiturgicalDay`, with `vespersFrom:
* 'firstVespersOfTomorrow'` set on its winning feast (if any) so callers
* can tell the difference from an ordinary day.
*
* One deliberate exception to that ordering: a day on
* `isMajorFixedFeastOfTheLord`'s list always wins tomorrow's Vespers,
* *before* checking whether today would otherwise keep its own — Easter
* displaces even Holy Saturday's privileged-feria status (the classic
* case: the Vigil already belongs to Easter, not to Holy Saturday), and
* would likewise displace an actual Sunday if one ever landed on Dec 24 or
* an Ember Saturday. This is why it's a short, explicit list rather than a
* threshold: these particular days are understood to be *absolute*.
*/
export function resolveEveningDay(isoDate: string): LiturgicalDay {
const today = resolveDay(isoDate);
const tomorrow = resolveDay(addDays(isoDate, 1));
if (isMajorFixedFeastOfTheLord(tomorrow.date)) {
return anticipated(tomorrow);
}
if (keepsOwnSecondVespers(today)) {
return today;
}
if (!hasFirstVespers(tomorrow)) {
return today;
}
return anticipated(tomorrow);
}
+7
View File
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/11-01.txt,
# untagged/default [Rank] block).
id: all-saints
name: "All Saints"
rank: duplex-1-classis
common: proper-to-all-saints
propers: null
+7
View File
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/08-15.txt,
# untagged/default [Rank] block).
id: assumption
name: "The Assumption of the Blessed Virgin Mary"
rank: duplex-1-classis
common: common-of-the-bvm
propers: null
@@ -1,9 +1,8 @@
# PLACEHOLDER — demonstrates the saint-record shape only. Not a real saint, # PLACEHOLDER — demonstrates the saint-record shape only, deliberately not
# not a real rank, not a real day assignment (see sanctoral-calendar.yml for # mapped to any day in sanctoral-calendar.yml (which now holds real,
# where this id gets pointed at a day). Which day(s) point here can change # verified entries — see that file). Not a real saint, not a real rank.
# freely without ever touching this file.
id: example-confessor id: example-confessor
name: "Example Confessor (placeholder)" name: "Example Confessor (placeholder)"
rank: placeholder # real rank scheme is a milestone 4 decision, not made yet rank: simplex # placeholder value
common: common-of-a-confessor-not-bishop common: common-of-a-confessor-not-bishop
propers: null # set to a propers id once/if this saint has a full proper propers: null
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/12-08.txt,
# untagged/default [Rank] block).
id: immaculate-conception
name: "The Immaculate Conception of the Blessed Virgin Mary"
rank: duplex-1-classis
common: common-of-the-bvm
propers: null
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/09-08.txt,
# untagged/default [Rank] block).
id: nativity-bvm
name: "The Nativity of the Blessed Virgin Mary"
rank: duplex-2-classis
common: common-of-the-bvm
propers: null
@@ -0,0 +1,9 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/08-28.txt,
# untagged/default [Rank] block — that file's own "(sed rubrica 1930)"
# alternate raises this to Duplex majus, but 1930 postdates the pre-1910
# calendar this app targets, so the plain default is used instead.
id: st-augustine
name: "St. Augustine of Hippo, Bishop, Confessor and Doctor of the Church"
rank: duplex
common: common-of-a-confessor-bishop
propers: null
+8
View File
@@ -0,0 +1,8 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/08-10.txt,
# untagged/default [Rank] block — the pre-1955 value, not the "(sed rubrica
# 196)" alternate given alongside it in that file).
id: st-lawrence
name: "St. Lawrence, Martyr"
rank: duplex-2-classis
common: common-of-a-martyr
propers: null
+9
View File
@@ -0,0 +1,9 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/09-29.txt,
# untagged/default [Rank] block — that file's own "(sed rubrica tridentina)"
# alternate gives Duplex II classis instead; the plain default is used here
# for consistency with how every other saint file in this set was read).
id: st-michael
name: "St. Michael the Archangel"
rank: duplex-1-classis
common: common-of-an-angel
propers: null
+13 -4
View File
@@ -5,8 +5,17 @@
# (data/calendar/saints/<id>.yml) never changes — it's the same record # (data/calendar/saints/<id>.yml) never changes — it's the same record
# whichever day points at it. # whichever day points at it.
# #
# PLACEHOLDER: "01-01" -> example-confessor is fake data proving the shape, # A small, growable subset, not a full year — see the plan discussion this
# not a real calendar assignment. Real sanctoral content-authoring # came out of. Each entry's rank is verified directly against Divinum
# (a pre-1910-leaning calendar, per the plan) is a separate task. # Officium (see the saint's own file for which one) rather than
# reconstructed from memory, unlike data/calendar/temporal-categories.yml's
# privileged-day lists. `example-confessor` is deliberately NOT mapped to
# any day here — it's fictional, kept only to document the record shape.
days: days:
"01-01": [example-confessor] "08-10": [st-lawrence]
"08-15": [assumption]
"08-28": [st-augustine]
"09-08": [nativity-bvm]
"09-29": [st-michael]
"11-01": [all-saints]
"12-08": [immaculate-conception]
+59
View File
@@ -0,0 +1,59 @@
# A day's precedence category under the temporal cycle alone, before any
# sanctoral feast is weighed against it — see calendar/types.ts's
# TemporalCategory doc comment for why this is coarser than `season`, and
# calendar/commemorations.ts for how it's actually used (decideOccurrence).
#
# RECONSTRUCTED, not verified against a primary source: the two rubric
# documents actually present in the reference engine
# (divinum-officium-reference/web/www/horas/Help/Rubrics/) are both later
# editions (~1960) with a different, renumbered classification than the
# pre-1955 system this app targets. This is a best-effort reconstruction
# from general knowledge of that older tradition — expect corrections.
#
# `bySeason` gives the default for an ordinary Sunday/feria falling in that
# season. `offsets`/`offsetRanges` (from Easter, same convention as
# easter-offsets.yml) and `fixedDates` (MM-DD) override that default for
# specific privileged ferias that don't line up with a whole season.
#
# Known gap: Advent Ember days (Wed/Fri/Sat after Dec 13) and September
# Ember days (Wed/Fri/Sat near the Exaltation of the Cross, Sept 14) are
# NOT modeled here — both are fixed-calendar "nearest such-and-such a date"
# rules, not a plain offset, and weren't worth guessing at without a source.
# Lent's and Pentecost's Ember days *are* modeled, since those are clean
# Easter offsets.
bySeason:
advent: { sunday: privileged-sunday, feria: ordinary-feria }
# Christmastide's ferias are folded into the privileged-feria default for
# the whole season as a simplification of "the Octave of Christmas (Dec
# 25 - Jan 1) is privileged" — slightly overbroad into Jan 2-5, harmless.
christmastide: { sunday: privileged-sunday, feria: privileged-feria }
epiphanytide: { sunday: ordinary-sunday, feria: ordinary-feria }
septuagesima: { sunday: privileged-sunday, feria: ordinary-feria }
lent: { sunday: privileged-sunday, feria: ordinary-feria }
passiontide: { sunday: privileged-sunday, feria: ordinary-feria }
eastertide: { sunday: privileged-sunday, feria: ordinary-feria }
ascensiontide: { sunday: ordinary-sunday, feria: ordinary-feria }
pentecost: { sunday: privileged-sunday, feria: ordinary-feria }
trinitytide: { sunday: ordinary-sunday, feria: ordinary-feria }
# Corpus Christi/Sacred Heart are themselves "feasts of the Lord" that
# shouldn't cede to an ordinary saint — modeled here as privileged-feria
# (rather than injecting a separate synthetic FeastClass) since with the
# small sanctoral calendar this app has, a real clash is unlikely and the
# practical effect (temporal wins) is the same either way.
corpus-christi: { sunday: ordinary-sunday, feria: privileged-feria }
sacred-heart: { sunday: ordinary-sunday, feria: privileged-feria }
offsets:
-46: privileged-feria # Ash Wednesday
-39: privileged-feria # Lent Ember Wednesday (Wed after 1st Sunday of Lent)
-37: privileged-feria # Lent Ember Friday
-36: privileged-feria # Lent Ember Saturday
48: privileged-feria # Vigil of Pentecost
offsetRanges:
- { fromOffset: -6, toOffset: -1, category: privileged-feria } # ferias of Holy Week
- { fromOffset: 1, toOffset: 6, category: privileged-feria } # Easter octave
- { fromOffset: 50, toOffset: 55, category: privileged-feria } # Pentecost octave (also covers Pentecost's own Ember days)
fixedDates:
"12-24": privileged-feria # Vigil of Christmas
+9 -8
View File
@@ -1,4 +1,5 @@
import type { OccurringFeast } from '../calendar/types'; import type { OccurringFeast } from '../calendar/types';
import { isAtLeast } from '../calendar/commemorations';
export interface SplitAntiphon { export interface SplitAntiphon {
incipit: string; incipit: string;
@@ -30,13 +31,13 @@ export function splitAntiphon(text: string): SplitAntiphon {
* said before the psalms (the full text is always said after, regardless * said before the psalms (the full text is always said after, regardless
* of rank). * of rank).
* *
* FeastRank is an open string with no defined hierarchy yet (see * Only asks whether the day's *winning* feast (not a merely-commemorated
* calendar/types.ts), and `occurring` is always [] until milestone 4 wires * one) is Double-or-higher — a privileged Sunday with nothing occurring,
* up real feast data — so this can only ever return false today. That's * or a commemorated low-rank saint, both correctly return false here. A
* the *correct* answer for every day currently reachable (a plain ferial * Sunday being "privileged" doesn't by itself make this true; that's a
* day is below Double), not a stub papering over missing logic; it starts * `TemporalCategory` question, not a `FeastClass` one.
* doing real work the moment FeastRank has an ordering to compare against.
*/ */
export function isDoubleOrHigher(_occurring: OccurringFeast[]): boolean { export function isDoubleOrHigher(occurring: OccurringFeast[]): boolean {
return false; const winner = occurring.find((feast) => !feast.commemorated);
return winner ? isAtLeast(winner.rank, 'duplex') : false;
} }
+10 -3
View File
@@ -1,6 +1,7 @@
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
import type { LiturgicalDay } from '../calendar/types'; import type { LiturgicalDay } from '../calendar/types';
import { resolveDay } from '../calendar'; import { resolveEveningDay } from '../calendar/vespers';
import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getCommonProper } from '../propers'; import { getCommonProper } from '../propers';
import { getHymnDoxologyId } from './hymn-doxology'; import { getHymnDoxologyId } from './hymn-doxology';
@@ -89,7 +90,13 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
} }
export function resolveOrdo(date: string): ResolvedOrdo { export function resolveOrdo(date: string): ResolvedOrdo {
const day = resolveDay(date); // Every season/occurring-dependent part below (hymn, doxology, Preces
// omitOnDouble, opening versicle, Nunc Dimittis rank, Marian antiphon) is
// an evening-hour concern, so the whole ordo resolves off whichever day's
// identity actually governs tonight — see calendar/vespers.ts. Nothing
// here is date-literal the way Prime's Martyrology is, so there's no
// need to keep a separate "real" day around.
const day = resolveEveningDay(date);
const parts = complineDefinition.parts.flatMap((part) => resolvePart(part, day)); const parts = complineDefinition.parts.flatMap((part) => resolvePart(part, day));
return { hourId: 'compline', date, parts }; return { hourId: 'compline', date, parts, dayLabel: getDayLabel(day) };
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types'; import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveDay, isSundayOrFeast } from '../calendar'; import { resolveDay, isSundayOrFeast } from '../calendar';
import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getMartyrologyEntryFor } from '../martyrology'; import { getMartyrologyEntryFor } from '../martyrology';
@@ -117,5 +118,5 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
export function resolveOrdo(date: string): ResolvedOrdo { export function resolveOrdo(date: string): ResolvedOrdo {
const day = resolveDay(date); const day = resolveDay(date);
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day)); const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day));
return { hourId: 'prime', date, parts }; return { hourId: 'prime', date, parts, dayLabel: getDayLabel(day) };
} }
+8
View File
@@ -135,4 +135,12 @@ export interface ResolvedOrdo {
parts: ResolvedPart[]; parts: ResolvedPart[];
/** 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. —
* see calendar/day-label.ts. Computed from whichever LiturgicalDay the
* hour actually resolved against, which can differ from `date` itself
* (Compline may anticipate tomorrow — see calendar/vespers.ts). Absent
* on not-yet-built hours.
*/
dayLabel?: string;
} }
+1
View File
@@ -130,6 +130,7 @@ 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>
`; `;
+8
View File
@@ -261,6 +261,14 @@ button:focus-visible {
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);
} }
.day-label {
font-family: var(--font-ui);
font-size: 0.9em;
font-style: italic;
color: var(--color-brass);
margin: 0 0 var(--space-2);
}
.ordo-part-citation { .ordo-part-citation {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 0.85em; font-size: 0.85em;
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { decideOccurrence, compareFeastClass, isAtLeast } from '../../src/calendar/commemorations';
describe('compareFeastClass / isAtLeast', () => {
it('orders the six ranks low to high', () => {
expect(compareFeastClass('simplex', 'duplex-1-classis')).toBeLessThan(0);
expect(compareFeastClass('duplex-1-classis', 'simplex')).toBeGreaterThan(0);
expect(compareFeastClass('duplex', 'duplex')).toBe(0);
});
it('isAtLeast is inclusive of the threshold itself', () => {
expect(isAtLeast('duplex-majus', 'duplex-majus')).toBe(true);
expect(isAtLeast('duplex', 'duplex-majus')).toBe(false);
});
});
describe('decideOccurrence', () => {
it('temporal wins trivially when nothing is assigned to the date', () => {
expect(decideOccurrence('ordinary-feria', null)).toEqual({ winner: 'temporal', commemorated: false });
expect(decideOccurrence('privileged-sunday', null)).toEqual({ winner: 'temporal', commemorated: false });
});
it('an ordinary feria always yields to any real feast, uncommemorated', () => {
expect(decideOccurrence('ordinary-feria', 'simplex')).toEqual({ winner: 'sanctoral', commemorated: false });
expect(decideOccurrence('ordinary-feria', 'duplex-1-classis')).toEqual({ winner: 'sanctoral', commemorated: false });
});
it('a privileged feria yields to nothing short of the top class', () => {
expect(decideOccurrence('privileged-feria', 'duplex-2-classis')).toEqual({
winner: 'temporal',
commemorated: false,
});
expect(decideOccurrence('privileged-feria', 'duplex-1-classis')).toEqual({
winner: 'temporal',
commemorated: true,
});
});
it('an ordinary Sunday yields outright only to the top class, and is commemorated by the two ranks below it', () => {
expect(decideOccurrence('ordinary-sunday', 'duplex-1-classis')).toEqual({
winner: 'sanctoral',
commemorated: false,
});
expect(decideOccurrence('ordinary-sunday', 'duplex-2-classis')).toEqual({
winner: 'temporal',
commemorated: true,
});
expect(decideOccurrence('ordinary-sunday', 'duplex-majus')).toEqual({
winner: 'temporal',
commemorated: true,
});
expect(decideOccurrence('ordinary-sunday', 'duplex')).toEqual({ winner: 'temporal', commemorated: false });
});
it('a privileged Sunday is never displaced, and only the top two ranks are even commemorated', () => {
expect(decideOccurrence('privileged-sunday', 'duplex-1-classis')).toEqual({
winner: 'temporal',
commemorated: true,
});
expect(decideOccurrence('privileged-sunday', 'duplex-2-classis')).toEqual({
winner: 'temporal',
commemorated: true,
});
expect(decideOccurrence('privileged-sunday', 'duplex-majus')).toEqual({
winner: 'temporal',
commemorated: false,
});
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { resolveDay } from '../../src/calendar';
import { getDayLabel } from '../../src/calendar/day-label';
import type { LiturgicalDay } from '../../src/calendar/types';
describe('getDayLabel — ordinal temporal label', () => {
it("labels an anchor's own partial week as \"Weekday after {Anchor}\"", () => {
// Trinity Sunday 2026 is May 31.
expect(getDayLabel(resolveDay('2026-06-01'))).toBe('Monday after Trinity Sunday');
expect(getDayLabel(resolveDay('2026-01-07'))).toBe('Wednesday after Epiphany'); // Epiphany 2026 is a Tuesday
});
it('names the anchor day itself, not "day after itself"', () => {
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('Trinity Sunday');
expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter');
expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday');
});
it('gives the correct week-after-Trinity ordinal', () => {
// Trinity Sunday 2026 is May 31; Jul 6 falls 5 full weeks after the
// first Sunday-after-Trinity (Jun 7).
expect(getDayLabel(resolveDay('2026-07-06'))).toBe('Monday in the 5th week after Trinity');
});
it("Advent's own anchor Sunday is already week 1, not a separate anchor-week case", () => {
expect(getDayLabel(resolveDay('2025-11-30'))).toBe('The 1st Sunday of Advent');
expect(getDayLabel(resolveDay('2025-12-01'))).toBe('Monday in the 1st week of Advent');
});
it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => {
expect(getDayLabel(resolveDay('2026-05-15'))).toContain('in Ascensiontide');
});
});
describe('getDayLabel — feast name combination', () => {
const base: LiturgicalDay = {
date: '2026-06-15',
weekday: 'monday',
season: 'trinitytide',
temporalCategory: 'ordinary-feria',
occurring: [],
};
it('shows just the feast name when it wins outright', () => {
const day: LiturgicalDay = {
...base,
occurring: [{ id: 'x', name: 'St. Ereden', rank: 'duplex', commemorated: false }],
};
expect(getDayLabel(day)).toBe('St. Ereden');
});
it('shows both, feast first, when the feast is merely commemorated', () => {
const day: LiturgicalDay = {
...base,
occurring: [{ id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis', commemorated: true }],
};
expect(getDayLabel(day)).toBe('St. Ereden — Monday in the 2nd week after Trinity');
});
it('shows just the temporal label when nothing is occurring at all', () => {
expect(getDayLabel(base)).toBe('Monday in the 2nd week after Trinity');
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { resolveEveningDay } from '../../src/calendar/vespers';
describe('resolveEveningDay', () => {
it('anticipates tomorrow when tomorrow is a Sunday and today is a plain feria (Saturday before Advent I)', () => {
// Nov 30, 2025 is Advent I Sunday.
const day = resolveEveningDay('2025-11-29');
expect(day.date).toBe('2025-11-30');
expect(day.season).toBe('advent');
});
it('does not anticipate between two plain ferias', () => {
const day = resolveEveningDay('2026-06-16'); // Tuesday, ordinary trinitytide
expect(day.date).toBe('2026-06-16');
});
it('anticipates Easter from Holy Saturday', () => {
// Easter 2026 is Apr 5, so Holy Saturday is Apr 4.
const day = resolveEveningDay('2026-04-04');
expect(day.date).toBe('2026-04-05');
expect(day.season).toBe('eastertide');
});
it("a Sunday always keeps its own Second Vespers, even before a higher-ranked weekday feast", () => {
// Aug 10, 2026 (Monday) is St. Lawrence, Duplex II classis — well above
// the "has First Vespers" threshold — but Sunday never cedes.
const day = resolveEveningDay('2026-08-09');
expect(day.date).toBe('2026-08-09');
});
it('an ordinary feria before a real high-ranked feast anticipates it', () => {
// Aug 14, 2026 (Friday) is a plain feria; Aug 15 is the Assumption,
// Duplex I classis.
const day = resolveEveningDay('2026-08-14');
expect(day.date).toBe('2026-08-15');
const winner = day.occurring.find((f) => !f.commemorated);
expect(winner?.id).toBe('assumption');
expect(winner?.vespersFrom).toBe('firstVespersOfTomorrow');
});
});
+19 -3
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveOrdo } from '../../src/hours'; import { resolveOrdo } from '../../src/hours';
const MONDAY = '2026-08-10'; const MONDAY = '2026-06-15';
const SUNDAY = '2026-08-09'; const SUNDAY = '2026-06-14';
describe('resolveOrdo("compline", ...)', () => { describe('resolveOrdo("compline", ...)', () => {
it('is implemented, with fixed psalms 4, 90, 133 regardless of weekday', () => { it('is implemented, with fixed psalms 4, 90, 133 regardless of weekday', () => {
@@ -85,6 +85,18 @@ describe('resolveOrdo("compline", ...)', () => {
expect(last?.kind === 'preces' ? last.text.text.en : undefined).toContain('Hail holy Queen'); expect(last?.kind === 'preces' ? last.text.text.en : undefined).toContain('Hail holy Queen');
}); });
it('anticipates Advent I: the Saturday evening before switches to the Alma Redemptoris Mater and shows the anticipated day label', () => {
// Nov 30, 2025 is Advent I Sunday.
const eve = resolveOrdo('compline', '2025-11-29');
const last = eve.parts[eve.parts.length - 1];
expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater');
expect(eve.dayLabel).toBe('The 1st Sunday of Advent');
const dayBefore = resolveOrdo('compline', '2025-11-28');
const lastBefore = dayBefore.parts[dayBefore.parts.length - 1];
expect(lastBefore?.kind === 'preces' ? lastBefore.label : undefined).toBe('Salve Regina');
});
it('switches to the Ave Regina Caelorum from Candlemas through the day before Maundy Thursday', () => { it('switches to the Ave Regina Caelorum from Candlemas through the day before Maundy Thursday', () => {
const marianLabel = (date: string) => { const marianLabel = (date: string) => {
const ordo = resolveOrdo('compline', date); const ordo = resolveOrdo('compline', date);
@@ -92,7 +104,11 @@ describe('resolveOrdo("compline", ...)', () => {
return last?.kind === 'preces' ? last.label : undefined; return last?.kind === 'preces' ? last.label : undefined;
}; };
// Easter 2026 is Apr 5, so Maundy Thursday is Apr 2. // Easter 2026 is Apr 5, so Maundy Thursday is Apr 2.
expect(marianLabel('2026-02-01')).toBe('Salve Regina'); // day before Candlemas expect(marianLabel('2026-01-31')).toBe('Salve Regina'); // well before Candlemas
// Candlemas (Feb 2) has First Vespers — Compline on Feb 1 evening
// already anticipates it, same as Advent I and Easter do (see
// calendar/vespers.ts).
expect(marianLabel('2026-02-01')).toBe('Ave Regina Caelorum'); // eve of Candlemas, anticipated
expect(marianLabel('2026-02-02')).toBe('Ave Regina Caelorum'); // Candlemas itself expect(marianLabel('2026-02-02')).toBe('Ave Regina Caelorum'); // Candlemas itself
expect(marianLabel('2026-03-10')).toBe('Ave Regina Caelorum'); // mid-Lent expect(marianLabel('2026-03-10')).toBe('Ave Regina Caelorum'); // mid-Lent
expect(marianLabel('2026-04-01')).toBe('Ave Regina Caelorum'); // eve of Maundy Thursday expect(marianLabel('2026-04-01')).toBe('Ave Regina Caelorum'); // eve of Maundy Thursday
+1 -1
View File
@@ -5,7 +5,7 @@ import { resolveOrdo } from '../../src/hours';
// whole psalms (1, 2, 6), the first of which (Psalm 1) has real sample // whole psalms (1, 2, 6), the first of which (Psalm 1) has real sample
// content in data/psalms/001.yml, so these tests can check real verse data // content in data/psalms/001.yml, so these tests can check real verse data
// without needing every psalm authored. // without needing every psalm authored.
const MONDAY = '2026-08-10'; const MONDAY = '2026-06-15';
const SUNDAY = '2026-08-09'; const SUNDAY = '2026-08-09';
describe('resolveOrdo("prime", ...)', () => { describe('resolveOrdo("prime", ...)', () => {