calendar: model Our Lady's Saturday as a real occurrence outcome
Deploy / deploy (push) Successful in 49s

On a free Saturday (nothing privileged already claims it, no active
octave), the day's own identity is now "Our Lady's Saturday" rather
than an anonymous ordinary-feria -- mirrors privileged-feria-minor
exactly, per direct instruction: Semiduplex-or-higher still wins
outright (Marian Saturday doesn't apply at all); below that (Simplex,
and Vigil, both under semiduplex on the FeastClass scale) loses and is
commemorated instead. Layered additively after decideOccurrence, same
shape as applyOctaves -- never touches decideOccurrence's own rules.

Needed a real winner identity (not just the plain temporal feria id)
so day-label/antiphon/collect sourcing can recognize it -- added a
temporal-feasts record purely for that, and taught getDayLabel to
check it for any named temporal winner (a small, free improvement for
Christmas/Pentecost's own labels too, previously unhandled).

hours: build the duplex-majus+ Lauds psalmody override (per-feast)

lauds-psalmody no longer unconditionally uses the plain weekday
default: a duplex-majus-or-higher sanctoral winner, or Our Lady's
Saturday (unconditional, no rank threshold -- it isn't competing with
the weekday default the way a saint is), now substitutes its own
proper psalm groups/antiphons/canticle. Per-feast, not per-Common, per
direct instruction, even though most duplex-majus+ saints across the
year don't have one authored yet and fall back to the plain weekday
default until they do.

Content for the worked example (St. Lawrence's own day) surfaced a
real bug while sourcing it: st-lawrence-antiphon.yml had been read
from the Roman/secular rite's own [Ant 1], not Monastic 1617's actual
Benedictus antiphon for that day -- corrected from a fresh live query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 14:02:55 -04:00
parent 4548042b65
commit 747a078580
16 changed files with 378 additions and 31 deletions
+4 -1
View File
@@ -53,7 +53,10 @@ describe('August sanctoral pull (first pass)', () => {
});
it("the Vigil of St. Lawrence's own vigil rank forces primacy over a real, otherwise-unranked secondary saint", () => {
const day = resolveDay('2025-08-09');
// 2025-08-09 is a Saturday -- collides with the generic Saturday-of-
// Mary votive this test isn't about (see tests/calendar/marian-
// saturday.test.ts); 2024 keeps it a plain midweek day.
const day = resolveDay('2024-08-09');
expect(day.winner).toEqual({
kind: 'sanctoral',
id: 'vigil-of-st-lawrence',
+5 -2
View File
@@ -40,8 +40,11 @@ describe('June sanctoral pull (first pass)', () => {
});
it('the Nativity of St. John the Baptist wins outright at Duplex I. classis, distinct from his Vigil the day before', () => {
const vigil = resolveDay('2035-06-23');
const nativity = resolveDay('2035-06-24');
// 2035-06-23 is a Saturday -- collides with the generic Saturday-of-
// Mary votive this test isn't about (see tests/calendar/marian-
// saturday.test.ts); 2027 keeps both dates on plain midweek days.
const vigil = resolveDay('2027-06-23');
const nativity = resolveDay('2027-06-24');
expect(vigil.winner).toEqual({
kind: 'sanctoral',
id: 'vigil-of-st-john-the-baptist',
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { resolveDay } from '../../src/calendar';
import { getDayLabel } from '../../src/calendar/day-label';
// Direct instruction: on a free Saturday, "Our Lady's Saturday" mirrors
// privileged-feria-minor exactly — Semiduplex-or-higher still wins
// outright (Marian Saturday doesn't happen); below that (Simplex, and
// Vigil, since both are below semiduplex in the FeastClass scale), the
// saint loses and is commemorated instead.
describe('Marian Saturday (applyMarianSaturday)', () => {
it('wins outright, with no commemoration, on a Saturday with nothing else at all going on', () => {
const day = resolveDay('2026-10-24');
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
expect(day.commemorations).toEqual([]);
expect(getDayLabel(day)).toBe("Our Lady's Saturday");
});
it('wins over a Simplex saint, who is commemorated instead of winning outright', () => {
const day = resolveDay('2026-06-20'); // St. Silverius, Pope and Martyr, Simplex
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
expect(day.commemorations).toEqual([
{ kind: 'sanctoral', id: 'st-silverius', name: 'St. Silverius, Pope and Martyr', rank: 'simplex' },
]);
});
it('wins over a Vigil too (below semiduplex, same as Simplex) -- can commemorate more than one loser at once', () => {
const day = resolveDay('2025-11-29'); // St. Saturninus (Simplex) + Vigil of St. Andrew
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
expect(day.commemorations).toEqual([
{ kind: 'sanctoral', id: 'st-saturninus', name: 'St. Saturninus, Martyr', rank: 'simplex' },
{ kind: 'sanctoral', id: 'vigil-of-st-andrew', name: 'Vigil of St. Andrew', rank: 'vigil' },
]);
});
it('loses outright to a Semiduplex-or-higher saint -- Marian Saturday does not apply at all', () => {
const day = resolveDay('2026-09-19'); // St. Januarius, Semiduplex
expect(day.winner).toEqual({
kind: 'sanctoral',
id: 'st-januarius',
name: 'St. Januarius, Bishop, and Companions, Martyrs',
rank: 'semiduplex',
});
});
it('never applies to a Saturday with real standing of its own (privileged season, Ember days, ...)', () => {
const day = resolveDay('2026-02-28'); // Lenten Ember Saturday
expect(day.winner).not.toEqual({ kind: 'temporal', id: 'marian-saturday' });
expect(day.temporalCategory).toBe('privileged-feria');
});
it('never applies while an octave is active, even on an otherwise-eligible Saturday (Pentecost Ember Saturday stays governed by its octave)', () => {
const day = resolveDay('2026-05-30');
expect(day.winner).toEqual({ kind: 'temporal', id: 'pentecost-sunday' });
});
it('never applies on a weekday other than Saturday', () => {
const day = resolveDay('2026-10-23'); // the Friday before the clean Marian Saturday above
expect(day.winner).not.toEqual({ kind: 'temporal', id: 'marian-saturday' });
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { resolveOrdo } from '../../src/hours';
describe('Lauds psalmody override (duplex-majus+ sanctoral, and Marian Saturday)', () => {
it("substitutes St. Lawrence's own proper psalmody on his own day (Duplex II. classis, above the threshold)", () => {
const ordo = resolveOrdo('lauds', '2026-08-10');
const psalmNumbers = ordo.parts
.filter((p) => p.kind === 'psalm')
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
expect(psalmNumbers).toEqual([66, 92, 99, 62, 148, 149, 150]);
const ps92 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 92);
expect(ps92?.kind === 'psalm' ? ps92.antiphon?.text.en : undefined).toContain('Lawrence went in to be a martyr');
const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus');
expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-trium-puerorum');
});
it("doesn't override a saint below duplex-majus (e.g. Semiduplex) -- plain weekday psalmody still used", () => {
// 2026-09-19: St. Januarius, Semiduplex (below duplex-majus) -- see
// tests/calendar/marian-saturday.test.ts for confirming he wins
// outright as the day's winner despite it being a Saturday.
const ordo = resolveOrdo('lauds', '2026-09-19');
const psalmNumbers = ordo.parts
.filter((p) => p.kind === 'psalm')
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
// Saturday's own plain default (Ps 50 + 142), not the Common-shaped
// override -- no lauds-psalmody-overrides/st-januarius.yml exists.
expect(psalmNumbers).toEqual([66, 50, 142, 148, 149, 150]);
});
it("substitutes Our Lady's Saturday own proper psalmody, unconditionally, whenever it wins", () => {
const ordo = resolveOrdo('lauds', '2026-10-24'); // a clean, otherwise-empty Marian Saturday
const psalmNumbers = ordo.parts
.filter((p) => p.kind === 'psalm')
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
expect(psalmNumbers).toEqual([66, 92, 99, 62, 148, 149, 150]);
const ps92 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 92);
expect(ps92?.kind === 'psalm' ? ps92.antiphon?.text.la : undefined).toContain('Dum esset Rex');
const benedictus = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus');
expect(benedictus?.kind === 'canticle' ? benedictus.antiphon?.text.la : undefined).toContain('Beáta Dei Génitrix');
const collect = ordo.parts.find((p) => p.kind === 'prayer');
expect(collect?.kind === 'prayer' ? collect.text.text.en : undefined).toContain('glorious intercession of the Blessed Mary');
});
it("drops the Holy Cross and Blessed Virgin Mary suffrages on Our Lady's Saturday, keeping Apostles and Peace", () => {
const ordo = resolveOrdo('lauds', '2026-10-24');
const labels = ordo.parts.filter((p) => p.kind === 'preces' && p.label).map((p) => (p.kind === 'preces' ? p.label : undefined));
expect(labels).toEqual(['Of the Holy Apostles Peter and Paul', 'For Peace', 'Salve Regina']);
});
});
+9 -8
View File
@@ -58,12 +58,13 @@ describe('resolveOrdo("lauds", ...)', () => {
});
it("resolves Saturday's own psalmody: only one weekday psalm (142), and the Canticle of Moses said in two pieces (RB 13's own division), one shared antiphon framing both", () => {
// Any Saturday works here: lauds-psalmody doesn't yet model the
// Commune/feast override real practice would apply on a Marian-
// Sabbato Saturday (see hours/types.ts's 'lauds-psalmody' doc comment)
// -- the plain weekday distribution is always used, so this is
// exercising the real, current behavior, not a stand-in for it.
const ordo = resolveOrdo('lauds', '2026-06-20');
// Needs a Saturday that ISN'T Our Lady's Saturday, which now wins
// (and substitutes its own psalmody) on every ordinary-feria Saturday
// — see calendar/index.ts's applyMarianSaturday and tests/calendar/
// marian-saturday.test.ts. A privileged-feria Saturday (Lenten Ember
// Saturday, 2026-02-28) has real standing of its own that Marian
// Saturday doesn't touch, so it still shows the plain weekday default.
const ordo = resolveOrdo('lauds', '2026-02-28');
const psalmNumbers = ordo.parts
.filter((p) => p.kind === 'psalm')
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
@@ -139,10 +140,10 @@ describe('resolveOrdo("lauds", ...)', () => {
expect(canticle?.kind === 'canticle' ? canticle.antiphon?.status?.la : undefined).toBeUndefined();
});
it("resolves a real Benedictus antiphon on St. Lawrence's own octave day, from the saint's already-authored proper", () => {
it("resolves a real Benedictus antiphon on St. Lawrence's own day, from the saint's already-authored proper", () => {
const ordo = resolveOrdo('lauds', '2026-08-10'); // St. Lawrence's own day
const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus');
expect(canticle?.kind === 'canticle' ? canticle.antiphon?.text.la : undefined).toContain('Levíta Lauréntius');
expect(canticle?.kind === 'canticle' ? canticle.antiphon?.text.la : undefined).toContain('In cratícula');
});
it('includes the short litany (Kyrie + Our Father) right after the Benedictus, before the day collect', () => {