calendar: model octaves as a generic, data-driven mechanism
Deploy / deploy (push) Successful in 46s
Deploy / deploy (push) Successful in 46s
Adds calendar/octaves.ts: a lookback over the past week collecting every
octave (sanctoral or temporal) still active on a date, stacking multiple
at once (Christmas + St. Stephen + St. John + Holy Innocents all
commemorated together within the Christmas Octave). Declared via an
optional `octave` field on a saint's own record or a new small
TemporalFeastRecord (calendar/temporal-feasts.ts) for temporal ids like
Christmas/Pentecost that didn't have a metadata record before -- data-
driven per user design discussion, with `{ enabled: true }` alone using
sensible defaults (8 days, semiduplex threshold) so a minimal declaration
works without authored content.
Wired into resolveDay as a post-processing layer: doesn't change how a
single day's own precedence contest is decided, just adds commemorations
for active octaves and occasionally overrides the winner when the
occurring saint doesn't clear the strictest active octave's threshold.
Populated so far: St. Lawrence's own octave (the one that repeatedly cost
real saints their spot in August), the three Comites Christi octaves
(Stephen/John/Innocents -- Thomas of Canterbury deliberately excluded,
per discussion), and Pentecost's (duplex threshold, user-specified).
Pentecost's octave offsets are also removed from temporal-categories.yml's
privileged-feria-major classification, letting a real candidate reach the
new octave layer instead of being transferred away first -- with the
side effect that Pentecost's own Ember Saturday no longer forces a
transfer (a sub-threshold saint is now commemorated in place instead, see
tests/calendar/transfer.test.ts's updated case). Assumption, Nativity
BVM, Immaculate Conception, and All Saints' own octaves are not yet
populated with octave data -- deliberately deferred to a follow-up pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,10 +76,10 @@ describe('August sanctoral pull (first pass)', () => {
|
||||
expect(getDayCollect(day).status.en).toBe('verified');
|
||||
});
|
||||
|
||||
it('dates within St. Lawrence and the Assumption\'s own octaves are excluded entirely, even where the source names a real secondary saint (St. Clare, Aug 12), pending octave support', () => {
|
||||
it('dates within St. Lawrence and the Assumption\'s own octaves still exclude the real secondary saint the source names (St. Clare, Aug 12) -- Clare herself isn\'t modeled yet -- but St. Lawrence\'s own octave (now modeled, see calendar/octaves.ts) correctly keeps commemorating him through it regardless. The Assumption\'s own octave isn\'t populated with octave data yet, so Aug 22 stays plain.', () => {
|
||||
const clareDay = resolveDay('2025-08-12');
|
||||
expect(clareDay.winner.kind).toBe('temporal');
|
||||
expect(clareDay.commemorations).toEqual([]);
|
||||
expect(clareDay.commemorations).toEqual([{ kind: 'octave', id: 'st-lawrence', name: 'St. Lawrence, Martyr' }]);
|
||||
const assumptionOctaveDay = resolveDay('2025-08-22');
|
||||
expect(assumptionOctaveDay.winner.kind).toBe('temporal');
|
||||
expect(assumptionOctaveDay.commemorations).toEqual([]);
|
||||
|
||||
@@ -11,11 +11,13 @@ describe('getDayLabel — ordinal temporal label', () => {
|
||||
});
|
||||
|
||||
it('names the anchor day itself, not "day after itself"', () => {
|
||||
// St. Felix I (May 30, Simplex) is impeded by that date's own Pentecost
|
||||
// Ember Saturday and transfers forward into Trinity Sunday, where he's
|
||||
// commemorated (not displacing the Sunday) — real sanctoral content
|
||||
// now populates this date, so the label reflects both, feast name first.
|
||||
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('St. Felix I, Pope and Martyr — Trinity Sunday');
|
||||
// Trinity Sunday no longer carries a sanctoral commemoration of its
|
||||
// own (St. Felix I, May 30, is now commemorated in place on his own
|
||||
// day within Pentecost's octave instead of transferring here — see
|
||||
// calendar/octaves.ts and tests/calendar/transfer.test.ts), so this
|
||||
// is back to a plain temporal label; the "feast — temporal" combined
|
||||
// form is covered separately below with synthetic data.
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -53,9 +53,11 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
|
||||
});
|
||||
|
||||
it('the Christmas Octave "Comites Christi" days each win outright with real propers, the Octave day commemorated in return', () => {
|
||||
const stephen = resolveDay('2025-12-26');
|
||||
const john = resolveDay('2025-12-27');
|
||||
const innocents = resolveDay('2026-12-28');
|
||||
// 2033 has no Sunday collision anywhere in Dec 26-31, so each of
|
||||
// these resolves without a privileged-Sunday complication.
|
||||
const stephen = resolveDay('2033-12-26');
|
||||
const john = resolveDay('2033-12-27');
|
||||
const innocents = resolveDay('2033-12-28');
|
||||
expect(stephen.winner).toEqual({
|
||||
kind: 'sanctoral',
|
||||
id: 'st-stephen-protomartyr',
|
||||
@@ -75,21 +77,48 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
|
||||
rank: 'duplex-2-classis',
|
||||
});
|
||||
for (const day of [stephen, john, innocents]) {
|
||||
expect(day.commemorations.length).toBe(1);
|
||||
expect(day.commemorations[0]?.kind).toBe('temporal');
|
||||
expect(getDayCollect(day).status.en).toBe('verified');
|
||||
}
|
||||
// Each also carries the day's own Octave-of-the-Nativity commemoration
|
||||
// plus Christmas's own octave, which keeps accumulating -- see the
|
||||
// dedicated stacking test below for the full week's build-up.
|
||||
expect(stephen.commemorations).toEqual([
|
||||
{ kind: 'temporal', id: expect.any(String) },
|
||||
{ kind: 'octave', id: 'christmas-day', name: 'Christmas' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('St. Thomas of Canterbury, only Semiduplex, still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => {
|
||||
const day = resolveDay('2025-12-29');
|
||||
const day = resolveDay('2033-12-29');
|
||||
expect(day.winner).toEqual({
|
||||
kind: 'sanctoral',
|
||||
id: 'st-thomas-becket',
|
||||
name: 'St. Thomas of Canterbury, Bishop and Martyr',
|
||||
rank: 'semiduplex',
|
||||
});
|
||||
expect(day.commemorations.length).toBe(1);
|
||||
expect(day.commemorations[0]?.kind).toBe('temporal');
|
||||
// Thomas himself has no octave (user-confirmed, 2026-08), but by his
|
||||
// own day, Christmas + Stephen + John + Holy Innocents' octaves have
|
||||
// all stacked up and commemorate alongside him.
|
||||
expect(day.commemorations).toEqual([
|
||||
{ kind: 'temporal', id: expect.any(String) },
|
||||
{ kind: 'octave', id: 'christmas-day', name: 'Christmas' },
|
||||
{ kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' },
|
||||
{ kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' },
|
||||
{ kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('the Christmas Octave commemorations build up day by day as each Comites Christi feast starts its own octave', () => {
|
||||
// Same clean year (2033) as above, walking the whole week to show the
|
||||
// accumulation directly -- this is the concrete shape of "commemorations
|
||||
// build up" the octave mechanism was built for.
|
||||
const dec30 = resolveDay('2033-12-30'); // no saint of its own -- a plain Octave-of-the-Nativity day
|
||||
expect(dec30.winner.kind).toBe('temporal');
|
||||
expect(dec30.commemorations).toEqual([
|
||||
{ kind: 'octave', id: 'christmas-day', name: 'Christmas' },
|
||||
{ kind: 'octave', id: 'st-stephen-protomartyr', name: 'St. Stephen, Protomartyr' },
|
||||
{ kind: 'octave', id: 'st-john-apostle', name: 'St. John, Apostle and Evangelist' },
|
||||
{ kind: 'octave', id: 'holy-innocents', name: 'The Holy Innocents, Martyrs' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activeOctavesFor, strictestThreshold } from '../../src/calendar/octaves';
|
||||
|
||||
describe('activeOctavesFor', () => {
|
||||
it('finds nothing outside any octave window', () => {
|
||||
expect(activeOctavesFor('2026-07-15')).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds a saint's own octave on its own day (day 1) and through day 8, not on day 9", () => {
|
||||
const day1 = activeOctavesFor('2026-08-10'); // St. Lawrence's own day
|
||||
expect(day1).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1 }]);
|
||||
|
||||
const day8 = activeOctavesFor('2026-08-17');
|
||||
expect(day8).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 8 }]);
|
||||
|
||||
expect(activeOctavesFor('2026-08-18')).toEqual([]);
|
||||
});
|
||||
|
||||
it("Pentecost's octave uses its own configured threshold (duplex), not the default (semiduplex)", () => {
|
||||
const octaves = activeOctavesFor('2026-05-29'); // within Pentecost's octave, 2026
|
||||
expect(octaves).toEqual([{ id: 'pentecost-sunday', name: 'Pentecost', wins: 'duplex', dayNumber: 6 }]);
|
||||
expect(strictestThreshold(octaves)).toBe('duplex');
|
||||
});
|
||||
|
||||
it('stacks multiple active octaves oldest-started first', () => {
|
||||
const octaves = activeOctavesFor('2033-12-29'); // St. Thomas of Canterbury's day
|
||||
expect(octaves.map((o) => o.id)).toEqual([
|
||||
'christmas-day',
|
||||
'st-stephen-protomartyr',
|
||||
'st-john-apostle',
|
||||
'holy-innocents',
|
||||
]);
|
||||
expect(octaves.map((o) => o.dayNumber)).toEqual([5, 4, 3, 2]);
|
||||
});
|
||||
});
|
||||
@@ -50,25 +50,38 @@ describe('transfer mechanism (resolveDay integration)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Found via real data (the May sanctoral pull): a transferred-in
|
||||
// candidate was blindly winning outright whenever the receiving day's
|
||||
// own native winner was temporal, regardless of that day's own
|
||||
// precedence category — so a transferred-in Simplex was overwriting
|
||||
// Trinity Sunday itself instead of going through the same ordinary-
|
||||
// Sunday rule a native candidate would have.
|
||||
it('a simplex saint impeded by a privileged feria transfers forward and is merely commemorated, not made to win, on the ordinary Sunday it lands on', () => {
|
||||
// St. Felix I (May 30, Simplex) is impeded by that date's own
|
||||
// Pentecost Ember Saturday (privileged-feria-major) and transfers
|
||||
// forward into Trinity Sunday 2026 (May 31, an ordinary Sunday).
|
||||
// Originally found via real data (the May sanctoral pull): a
|
||||
// transferred-in candidate was blindly winning outright whenever the
|
||||
// receiving day's own native winner was temporal, regardless of that
|
||||
// day's own precedence category — so a transferred-in Simplex was
|
||||
// overwriting Trinity Sunday itself instead of going through the same
|
||||
// ordinary-Sunday rule a native candidate would have. That fix (in
|
||||
// applyIncomingTransfer) is unchanged and still covered here, but the
|
||||
// concrete St. Felix I / Trinity Sunday scenario that first surfaced it
|
||||
// no longer demonstrates a *transfer* at all: since the octave
|
||||
// mechanism (calendar/octaves.ts) was added, Pentecost's own Ember
|
||||
// Saturday is no longer classified `privileged-feria-major` (see
|
||||
// data/calendar/temporal-categories.yml), so Felix now wins his own day
|
||||
// outright under the permissive `ordinary-feria` default — then gets
|
||||
// caught and commemorated in place by Pentecost's octave itself
|
||||
// (threshold: duplex), never even reaching a transfer. He simply
|
||||
// doesn't appear on Trinity Sunday at all anymore. Kept here anyway as
|
||||
// a still-useful demonstration of the octave layer's own override path.
|
||||
it("St. Felix I, below Pentecost's octave threshold, is commemorated in place on his own day rather than transferring anywhere", () => {
|
||||
const saturday = resolveDay('2026-05-30');
|
||||
const sunday = resolveDay('2026-05-31');
|
||||
|
||||
expect(saturday.winner).toEqual({ kind: 'temporal', id: 'pentecost-sunday' });
|
||||
expect(saturday.commemorations).toEqual([]);
|
||||
|
||||
expect(sunday.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-01' });
|
||||
expect(sunday.commemorations).toEqual([
|
||||
expect(saturday.commemorations).toEqual([
|
||||
{ kind: 'sanctoral', id: 'st-felix-i', name: 'St. Felix I, Pope and Martyr', rank: 'simplex' },
|
||||
{ kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost' },
|
||||
]);
|
||||
|
||||
// Felix doesn't reach Trinity Sunday at all now -- but Trinity Sunday
|
||||
// is day 8 of Pentecost's own 8-day octave (the default length, not
|
||||
// yet independently confirmed to be right for Pentecost specifically),
|
||||
// so Pentecost's own octave commemoration still persists here.
|
||||
expect(sunday.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-01' });
|
||||
expect(sunday.commemorations).toEqual([{ kind: 'octave', id: 'pentecost-sunday', name: 'Pentecost' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user