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
+11
View File
@@ -13,6 +13,7 @@ import type { LiturgicalDay } from './types';
import { easterSunday } from './easter';
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math';
import { getTemporalFeastRecord } from './temporal-feasts';
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
@@ -136,6 +137,16 @@ export function getDayLabel(day: LiturgicalDay): string {
return day.winner.name;
}
// A named temporal feast (Christmas, Pentecost, Marian Saturday, ...)
// shows its own name rather than the ordinal week label — same
// "winner displaces, doesn't combine" rule a sanctoral winner gets
// above. Most temporal ids don't have a record at all (see
// temporal-feasts.ts) and fall through to the ordinal label as before.
const namedFeast = getTemporalFeastRecord(day.winner.id);
if (namedFeast) {
return namedFeast.name;
}
const temporal = temporalLabel(day);
const commemorated = day.commemorations.find((c) => c.kind === 'sanctoral');
return commemorated ? `${commemorated.name}${temporal}` : temporal;
+46
View File
@@ -101,10 +101,56 @@ export function resolveDay(isoDate: string): LiturgicalDay {
}
winner = applyOctaves(isoDate, winner, commemorations);
winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations);
return { date: isoDate, weekday, season, temporalCategory, winner, commemorations };
}
/**
* Layered on top of everything above, same spirit as applyOctaves: never
* changes decideOccurrence's own rules, just relabels the result on a free
* Saturday. Real standing of its own (per direct instruction, mirrors
* privileged-feria-minor exactly) — a Semiduplex-or-higher saint still
* wins outright and this never applies; a Simplex (or Vigil) saint loses
* to it and is commemorated instead, same as an Advent feria would demote
* one. `ordinary-feria` is the only category this touches — every
* privileged season/day (Ember Saturdays, Advent, Lent, ...) already has
* its own real standing and keeps it untouched.
*
* The relabel (not just leaving `winner` as the plain temporal id) is
* what lets getDayLabel/getBenedictusAntiphon/getDayCollects/getLaudsPsalmodyOverride
* all recognize this day as "Our Lady's Saturday" rather than an
* anonymous feria — see data/calendar/temporal-feasts/marian-saturday.yml.
*
* Also gated on no octave being active: `ordinary-feria` isn't only "a
* genuinely free Saturday" — Pentecost's own Ember Saturday is
* deliberately classified this way too (see data/calendar/temporal-
* categories.yml), specifically so the octave layer above can reach it.
* Found via a real regression: without this gate, Pentecost's Ember
* Saturday got relabeled "Our Lady's Saturday" right out from under the
* octave commemoration that's supposed to govern it.
*/
function applyMarianSaturday(
isoDate: string,
weekday: ReturnType<typeof weekdayOf>,
temporalCategory: TemporalCategory,
winner: DayWinner,
commemorations: Commemoration[],
): DayWinner {
if (weekday !== 'saturday' || temporalCategory !== 'ordinary-feria' || activeOctavesFor(isoDate).length > 0) {
return winner;
}
const MARIAN_SATURDAY: DayWinner = { kind: 'temporal', id: 'marian-saturday' };
if (winner.kind === 'temporal') {
return MARIAN_SATURDAY;
}
if (isAtLeast(winner.rank, 'semiduplex')) {
return winner;
}
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
return MARIAN_SATURDAY;
}
/**
* Layered on top of everything above, not part of it: an octave doesn't
* change how a single day's own precedence contest is decided, it just