Implement the resumed-post-Epiphany-Sunday temporal-id algorithm
Deploy / deploy (push) Successful in 1m6s

Post-Pentecost overflow years (early Easter, fewer than 6 Sundays after
Epiphany fit before Septuagesima) previously clamped every excess Sunday
to post-pentecost-24 instead of resuming the skipped post-Epiphany
Sundays' own content, per the traditional rubric. Ported directly from
the reference engine's own DivinumOfficium/Date.pm getweek().

Also adds the Epiphany VI Saturday-before-Septuagesima commemoration
(only when Epiphany V was the last Sunday to actually occur), and fixes
day-label.ts's on-screen display to track the same resumed-Sunday
content instead of a raw elapsed-week count — including a correction so
the fixed final Sunday of the year always reads "23rd Sunday after
Trinity", not a per-year-varying number.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 20:07:04 -04:00
parent f811fcc8f6
commit a7901b3c61
6 changed files with 255 additions and 25 deletions
+60
View File
@@ -15,6 +15,7 @@ import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './tempo
import { addDays, daysBetween, toIsoDate } from './date-math';
import { getTemporalFeastRecord } from './temporal-feasts';
import { resolveActiveOctave, type ActiveOctave } from './octaves';
import { resolveTemporalId } from './temporal-id';
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
@@ -109,7 +110,66 @@ function anchorDayName(day: LiturgicalDay): string | undefined {
return undefined;
}
/** The fixed final Sunday of the liturgical year (temporal-id.ts's
* `post-pentecost-24`) always carries "Sunday XXIV after Pentecost"'s own
* formulary, regardless of how many Sundays actually elapsed since
* Trinity — confirmed by scanning every year 1900-2100, every single one
* lands on this id for its own last Sunday before Advent, never a raw
* elapsed-week count. This app's Trinity-counted display is one week off
* from that Pentecost-counted id (Trinity Sunday itself = post-pentecost-
* 01 = the display's own uncounted anchor day), so the fixed display
* ordinal here is XXIV - 1 = 23, not whatever a plain "weeks since
* Trinity" calculation would produce. */
const FIXED_LAST_SUNDAY_ORDINAL = 23;
/**
* Trinitytide's ordinal display can't be pure "weeks since Trinity's own
* first Sunday" arithmetic once the season gets late enough — see
* temporal-id.ts's own post-Pentecost-XXIII-plus branch. Two cases, both
* driven by `resolveTemporalId` directly (the same mechanism the content
* layer already trusts, rather than re-deriving the wdist arithmetic here
* a second time, so the label can never disagree with what's actually
* rendered underneath):
* - A resumed post-Epiphany Sunday/week (an overflow year's skipped
* Epiphany Sundays, reappearing here) — reads "after Epiphany", not
* the next Trinity-counted number in line.
* - The fixed final Sunday/week of the year itself — always the fixed
* 23rd-after-Trinity ordinal (see FIXED_LAST_SUNDAY_ORDINAL above),
* every year, not just overflow ones.
* Only ever fires within trinitytide, since that's the only season
* resolveTemporalId can return either of these ids for a date outside
* their own native season.
*/
function trinitytideOverrideLabel(day: LiturgicalDay): string | undefined {
if (day.season !== 'trinitytide') {
return undefined;
}
const id = resolveTemporalId(day.date);
const weekdayName = capitalize(day.weekday);
const epiphanyMatch = id.match(/^post-epiphany-(\d)$/);
if (epiphanyMatch) {
const n = Number(epiphanyMatch[1]);
return day.weekday === 'sunday'
? `The ${ordinal(n)} Sunday after Epiphany`
: `${weekdayName} in the ${ordinal(n)} week after Epiphany`;
}
if (id === 'post-pentecost-24') {
return day.weekday === 'sunday'
? `The ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} Sunday after Trinity`
: `${weekdayName} in the ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} week after Trinity`;
}
return undefined;
}
function temporalLabel(day: LiturgicalDay): string {
const trinitytideOverride = trinitytideOverrideLabel(day);
if (trinitytideOverride) {
return trinitytideOverride;
}
const weekdayName = capitalize(day.weekday);
const config = ORDINAL_SEASONS[day.season];
if (!config) {
+31 -1
View File
@@ -5,7 +5,8 @@ import { resolveTemporalId } from './temporal-id';
import { getSanctoralCandidatesFor } from './feasts';
import { decideOccurrence, isAtLeast, type OccurrenceResult } from './commemorations';
import { resolveCollision } from './collision';
import { addDays } from './date-math';
import { addDays, toIsoDate } from './date-math';
import { easterSunday } from './easter';
import { activeOctavesFor, strictestThreshold } from './octaves';
/**
@@ -104,10 +105,39 @@ export function resolveDay(isoDate: string): LiturgicalDay {
winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations);
winner = applyChristTheKing(isoDate, winner, commemorations);
winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations);
applyEpiphany6Commemoration(isoDate, commemorations);
return { date: isoDate, weekday, season, temporalCategory, winner, commemorations };
}
/**
* Real rubric, confirmed 2026-08: when the 6th Sunday after Epiphany is
* bumped by Septuagesima — i.e. only in years where the 5th Sunday after
* Epiphany was the last one to actually occur — it's commemorated on the
* Saturday immediately before Septuagesima, on top of whatever else that
* Saturday already resolved to. Distinct from (and doesn't imply) the
* later "resumed post-Epiphany Sunday" transfer handled in
* temporal-id.ts's post-Pentecost overflow branch — that one applies to
* *every* skipped post-Epiphany Sunday, not just VI; this Saturday
* commemoration is VI's alone. Purely additive: no `Commemoration` variant
* needed beyond the existing `{ kind: 'temporal' }` one, since
* hours/resolve-common.ts's getDayCollects already resolves any temporal
* commemoration's own `${id}-collect` for free.
*/
function applyEpiphany6Commemoration(isoDate: string, commemorations: Commemoration[]): void {
const year = Number(isoDate.slice(0, 4));
const septuagesimaStart = addDays(toIsoDate(easterSunday(year)), -63);
const saturdayBeforeSeptuagesima = addDays(septuagesimaStart, -1);
if (isoDate !== saturdayBeforeSeptuagesima) {
return;
}
const lastEpiphanySunday = addDays(septuagesimaStart, -7);
if (resolveTemporalId(lastEpiphanySunday) !== 'post-epiphany-5') {
return;
}
commemorations.push({ kind: 'temporal', id: 'post-epiphany-6' });
}
/** The Sunday on or before Oct 31 — always lands in October since Oct 31
* is at most 6 days after the month's last Sunday. */
function lastSundayOfOctober(year: number): string {
+38 -12
View File
@@ -11,16 +11,23 @@
// header and propers/index.ts's getTemporalProper for the fuller version
// of this reasoning.
//
// Two known, deliberately unfixed gaps: the ferias between Christmas Day
// One known, deliberately unfixed gap: the ferias between Christmas Day
// and the Sunday within its octave, and between Epiphany and its own
// first Sunday, fall back to that season's first named Sunday a few days
// early — Christmas Day's and Epiphany's own collects aren't authored as
// separate temporal-propers entries (only Sunday collects were pulled), so
// this is a deliberate approximation, not an oversight. Likewise, a real
// overflow year (early Easter, more than 24 weeks between Trinity and
// Advent) would traditionally reuse the unused post-Epiphany Sundays'
// collects for the excess weeks — not modeled; this just clamps at
// post-pentecost-24.
// this is a deliberate approximation, not an oversight.
//
// Overflow years (Easter early enough that fewer than 6 Sundays after
// Epiphany occur before Septuagesima cuts in) are handled below: the
// skipped post-Epiphany Sundays "resume" after Sunday XXIII after
// Pentecost, in their own numeric order, right before the fixed final
// Sunday of the year (always Sunday XXIV's own formulary, regardless of
// how many Sundays actually elapsed since Pentecost). Ported directly
// from the reference engine's own `getweek()`
// (DivinumOfficium/Date.pm) — same rubric, same arithmetic (weeks-until-
// Advent, not a separately-tracked count of skipped Epiphany Sundays);
// confirmed against that source rather than re-derived by hand.
import { resolveSeason, sundayOnOrBefore, firstSundayStrictlyAfter, adventStart, easterOffsetOf } from './temporal';
import { daysBetween } from './date-math';
@@ -43,12 +50,29 @@ const EASTER_OFFSET_IDS: [number, string][] = [
[42, 'sunday-after-ascension'],
[49, 'pentecost-sunday'],
];
for (let n = 1; n <= 24; n++) {
// Sundays I-XXII after Pentecost only — XXIII and XXIV are handled by
// resolvePostPentecost23Plus below, since (unlike I-XXII) which id a given
// week gets can depend on distance-to-Advent, not just its own offset.
for (let n = 1; n <= 22; n++) {
EASTER_OFFSET_IDS.push([56 + 7 * (n - 1), `post-pentecost-${String(n).padStart(2, '0')}`]);
}
const EASTER_OFFSET_ID_MAP = new Map(EASTER_OFFSET_IDS);
const MAX_EASTER_OFFSET = EASTER_OFFSET_IDS[EASTER_OFFSET_IDS.length - 1]![0];
const MAX_EASTER_OFFSET_ID = EASTER_OFFSET_IDS[EASTER_OFFSET_IDS.length - 1]![1];
/**
* A governing Sunday at or beyond Pentecost week XXIII. `pentecostWeek` is
* the reference engine's own `$n` (1 = Trinity Sunday, verified against
* DivinumOfficium/Date.pm and its Tempora/PentNN file naming).
*/
function resolvePostPentecost23Plus(governingSunday: string, year: number, pentecostWeek: number): string {
const wdist = Math.floor((daysBetween(governingSunday, adventStart(year)) + 6) / 7);
if (wdist < 2) {
return 'post-pentecost-24';
}
if (pentecostWeek === 23) {
return 'post-pentecost-23';
}
return `post-epiphany-${8 - wdist}`;
}
export function resolveTemporalId(isoDate: string): string {
const season = resolveSeason(isoDate);
@@ -78,9 +102,11 @@ export function resolveTemporalId(isoDate: string): string {
// regardless of which `season` bucket the feria itself falls in (the
// ferias right after Ash Wednesday genuinely reuse Quinquagesima's
// collect, crossing what resolveSeason calls two different seasons).
const offset = easterOffsetOf(sundayOnOrBefore(isoDate));
if (offset > MAX_EASTER_OFFSET) {
return MAX_EASTER_OFFSET_ID;
const governingSunday = sundayOnOrBefore(isoDate);
const offset = easterOffsetOf(governingSunday);
const pentecostWeek = Math.round((offset - 49) / 7);
if (pentecostWeek >= 23) {
return resolvePostPentecost23Plus(governingSunday, year, pentecostWeek);
}
return EASTER_OFFSET_ID_MAP.get(offset) ?? 'septuagesima';
}