Compare commits
3 Commits
ec448bc35b
...
226fff2ce6
| Author | SHA1 | Date | |
|---|---|---|---|
| 226fff2ce6 | |||
| eaaca05ad8 | |||
| 2ea21f2c89 |
@@ -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') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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", ...)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user