Add Ember-day mechanism (privileged feria, not a rank contest) + real content
Deploy / deploy (push) Successful in 1m17s

Researched whether September/Advent Ember days belong in the
movable-feasts.ts table alongside Christ the King/Immaculate Heart of
Mary. Live-verified against the reference engine they don't: Ember days
are privileged ferias (a saint can still win outright against them), not
named feasts contesting the day by rank.

Two smaller pieces instead: calendar/temporal.ts's septemberEmberDayOffset
(Sunday nearest Sept 14, same "nearest" arithmetic adventStart already
used, factored into a shared nearestSunday helper) feeds a new
resolveTemporalCategory check giving those 3 dates privileged-feria-minor
(live-verified correct); calendar/ember-days.ts's applyEmberDay relabels
the day's own temporal id to the Ember day's own (so its real content is
found) when the feria itself wins, or adds a commemoration when a saint
does. Advent Ember days needed no precedence change -- Advent's own
season default already covers them.

Found and fixed a real bug along the way: matins.ts's nocturnReadingIds
only ever pulled a commemorated *sanctoral* id's own readings, never a
commemorated *temporal* one's -- so a commemorated Ember day (the common
case) would never have surfaced its own content. Same root cause as the
day.winner.id gap the IHM relocation fixed earlier, just hiding in the
commemorations loop instead.

Authored all 6 days' real collect + 3 Matins readings each, transcribed
directly from the reference engine (September: Tempora/093-{3,5,6}.txt;
Advent: Tempora/Adv3-{3,5,6}.txt, whose lessons are themselves a
cross-reference to the Annunciation's own Common, followed and
transcribed from there). Kept as 3 separate readings per day rather than
the usual combine-into-one default, since each carries its own
genuinely distinct proper responsory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZSAgRi4QE4XRTqVto93zA
This commit is contained in:
2026-08-22 07:27:52 -04:00
parent 879d4d607c
commit 9fb9cb5e24
19 changed files with 928 additions and 9 deletions
+72
View File
@@ -0,0 +1,72 @@
// Ember days (Quatuor Temporum) are privileged ferias, not named feasts —
// structurally different from calendar/movable-feasts.ts's rank-contest
// table. The precedence side (whether an occurring saint outranks the
// feria) is already handled generically by resolveTemporalCategory
// (September Ember days get their own `privileged-feria-minor` entry
// there; Advent Ember days inherit Advent's own season default) feeding
// the ordinary decideOccurrence contest -- this file ONLY handles content
// identity: relabeling the day's own temporal id so an Ember day's real,
// distinct proper content (collect, Matins readings) gets found instead
// of inheriting whatever Sunday governs that week, the same way
// applyMarianSaturday/applyChristmasOctaveSunday relabel a feria's own id
// for the same reason.
import type { Commemoration, DayWinner } from './types';
import { septemberEmberDayOffset, adventEmberDayOffset } from './temporal';
import { activeOctavesFor } from './octaves';
const SEPTEMBER_EMBER_IDS: Record<number, string> = {
3: 'ember-september-wednesday',
5: 'ember-september-friday',
6: 'ember-september-saturday',
};
const ADVENT_EMBER_IDS: Record<number, string> = {
3: 'ember-advent-wednesday',
5: 'ember-advent-friday',
6: 'ember-advent-saturday',
};
function emberIdFor(isoDate: string): string | undefined {
const septemberOffset = septemberEmberDayOffset(isoDate);
if (septemberOffset !== undefined) {
return SEPTEMBER_EMBER_IDS[septemberOffset];
}
const adventOffset = adventEmberDayOffset(isoDate);
if (adventOffset !== undefined) {
return ADVENT_EMBER_IDS[adventOffset];
}
return undefined;
}
/**
* Run after applyOctaves, same gate that function's own doc comment and
* applyMarianSaturday both rely on: an active octave (the Nativity of the
* BVM's, Sep 8-15, can genuinely overlap a late-anchored September Ember
* week in some years) already has real standing of its own and shouldn't
* be silently relabeled out from under it.
*
* Two cases, mirroring how a "Commemoratio ad Laudes tantum" reads
* live against the reference engine: if the feria itself is what's
* winning (`winner.kind === 'temporal'`, i.e. resolveTemporalCategory's
* `privileged-feria-minor` threshold already beat whatever saint was
* there, or nothing was there at all), its id is swapped for the Ember
* day's own — real content gets found where it wouldn't otherwise be. If
* a saint won outright instead (strong enough to clear that threshold),
* the winner is left alone but the Ember day is still commemorated
* alongside it, so its own collect/readings still surface (this app
* doesn't model the "Laudes only" hour-scoping nuance — same simplification
* level as every other commemoration here, not a new gap).
*/
export function applyEmberDay(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner {
if (activeOctavesFor(isoDate).length > 0) {
return winner;
}
const emberId = emberIdFor(isoDate);
if (!emberId) {
return winner;
}
if (winner.kind === 'temporal') {
return { kind: 'temporal', id: emberId };
}
commemorations.push({ kind: 'temporal', id: emberId });
return winner;
}
+2
View File
@@ -9,6 +9,7 @@ import { addDays, toIsoDate } from './date-math';
import { easterSunday } from './easter';
import { activeOctavesFor, strictestThreshold } from './octaves';
import { applyMovableFeasts } from './movable-feasts';
import { applyEmberDay } from './ember-days';
/**
* A day's occurrence considered on its own — no awareness of what an
@@ -103,6 +104,7 @@ export function resolveDay(isoDate: string): LiturgicalDay {
}
winner = applyOctaves(isoDate, winner, commemorations);
winner = applyEmberDay(isoDate, winner, commemorations);
winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations);
winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations);
winner = applyMovableFeasts(isoDate, winner, commemorations);
+57 -4
View File
@@ -42,12 +42,21 @@ function fixedDateFor(id: string): { month: number; day: number } {
const CHRISTMAS = fixedDateFor('christmas-day');
const EPIPHANY = fixedDateFor('epiphany');
/** The Sunday nearest `isoDate` — ties (exactly 3-4 days either way can't
* happen; a week has one middle point, Wednesday, which is always closer
* to one side or the other) resolved the usual "nearest" way: on or
* before if `isoDate` is Sun-Wed, the next one if Thu-Sat. Shared by
* `adventStart` (nearest Nov 30) and calendar/ember-days.ts's own
* September Ember anchor (nearest Sept 14). */
export function nearestSunday(isoDate: string): string {
const dow = dayOfWeek(isoDate);
const delta = dow <= 3 ? -dow : 7 - dow;
return addDays(isoDate, delta);
}
/** The Sunday nearest Nov 30 (St. Andrew's Day) — Advent's real start rule. */
export function adventStart(year: number): string {
const nov30 = `${year}-11-30`;
const dow = dayOfWeek(nov30);
const delta = dow <= 3 ? -dow : 7 - dow;
return addDays(nov30, delta);
return nearestSunday(`${year}-11-30`);
}
/**
@@ -147,6 +156,41 @@ export function isInTriduum(isoDate: string): boolean {
return offset >= -3 && offset <= -1;
}
const SEPTEMBER_EMBER_OFFSETS = [3, 5, 6]; // Wed, Fri, Sat after the anchor Sunday
const ADVENT_EMBER_OFFSETS = [3, 5, 6]; // Wed, Fri, Sat after the 3rd Sunday of Advent
/** The Sunday nearest Sept 14 (Exaltation of the Holy Cross) — anchors
* September's Ember days, same "nearest" rule as Advent's own start.
* Live-verified (2026, 2019) against the reference engine: genuinely
* "nearest," not "3rd Sunday of the calendar month" — those two
* computations disagree in some years (e.g. 2025). */
export function septemberEmberAnchor(year: number): string {
return nearestSunday(`${year}-09-14`);
}
/** Which of September's 3 Ember-day offsets (3/5/6 = Wed/Fri/Sat)
* `isoDate` is, if any — shared by resolveTemporalCategory below (the
* precedence side: these get `privileged-feria-minor`) and
* calendar/ember-days.ts (the content-id side: each gets its own proper
* collect/readings instead of inheriting the governing Sunday's). */
export function septemberEmberDayOffset(isoDate: string): number | undefined {
const year = Number(isoDate.slice(0, 4));
const anchor = septemberEmberAnchor(year);
return SEPTEMBER_EMBER_OFFSETS.find((offset) => addDays(anchor, offset) === isoDate);
}
/** Which of Advent's 3 Ember-day offsets `isoDate` is, if any. Unlike
* September's, this doesn't need its own resolveTemporalCategory entry —
* Advent's ordinary `privileged-feria-minor` season default already
* covers it (no live evidence found that Advent Ember days need a
* stronger tier) — but it still needs its own content id, same as
* September's, hence still exported for calendar/ember-days.ts. */
export function adventEmberDayOffset(isoDate: string): number | undefined {
const year = Number(isoDate.slice(0, 4));
const thirdAdventSunday = addDays(adventStart(year), 14);
return ADVENT_EMBER_OFFSETS.find((offset) => addDays(thirdAdventSunday, offset) === isoDate);
}
/**
* A day's precedence category under the temporal cycle alone — see
* calendar/types.ts's TemporalCategory doc comment and
@@ -172,6 +216,15 @@ export function resolveTemporalCategory(isoDate: string, season: Season, weekday
return fixedOverride;
}
// September Ember days: live-verified privileged-feria-minor (Ss.
// Cornelius & Cyprian, Semiduplex, won outright there with the feria
// demoted to a commemoration) — see septemberEmberDayOffset's own doc
// comment for why this can't be expressed as a plain Easter offset or
// fixed MM-DD the way every other override in this function is.
if (septemberEmberDayOffset(isoDate) !== undefined) {
return 'privileged-feria-minor';
}
const offset = easterOffsetOf(isoDate);
const singleOverride = temporalCategories.offsets?.[String(offset)];