diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 9e829af..3318756 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -14,6 +14,7 @@ import { easterSunday } from './easter'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { addDays, daysBetween, toIsoDate } from './date-math'; import { getTemporalFeastRecord } from './temporal-feasts'; +import { activeOctavesFor, type ActiveOctave } from './octaves'; function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); @@ -88,6 +89,26 @@ const ORDINAL_SEASONS: Partial> = { }, }; +/** The anchor day itself (Ash Wednesday, Epiphany, Easter Sunday, Trinity + * Sunday) is its own named day, not "day after itself" — and outranks + * everything else this module computes (an active octave, an ordinal week + * label), since it's the day's real primary identity in the live engine + * too (e.g. Trinity Sunday is also technically day 8 of Pentecost's own + * octave, but nobody calls it that). Advent's own anchor (Advent I Sunday) + * doesn't take this branch — it's already "the 1st Sunday of Advent" via + * the ordinal path below. */ +function anchorDayName(day: LiturgicalDay): string | undefined { + const config = ORDINAL_SEASONS[day.season]; + if (!config || config.includeAnchorWeek) { + return undefined; + } + const year = Number(day.date.slice(0, 4)); + if (day.date === config.anchorDate(year)) { + return config.anchorName; + } + return undefined; +} + function temporalLabel(day: LiturgicalDay): string { const weekdayName = capitalize(day.weekday); const config = ORDINAL_SEASONS[day.season]; @@ -102,14 +123,9 @@ function temporalLabel(day: LiturgicalDay): string { const year = Number(day.date.slice(0, 4)); const anchor = config.anchorDate(year); - if (!config.includeAnchorWeek && day.date === anchor) { - // The anchor day itself (Ash Wednesday, Epiphany, Easter Sunday, - // Trinity Sunday) is its own named day, not "day after itself" — only - // reachable here at all when nothing in `occurring` already covers it - // (none of these are modeled as sanctoral entries yet). Advent's own - // anchor (Advent I Sunday) doesn't take this branch — it's already - // "the 1st Sunday of Advent" via the ordinal path below. - return config.anchorName; + const anchorName = anchorDayName(day); + if (anchorName) { + return anchorName; } const firstNumberedSunday = config.includeAnchorWeek ? anchor : firstSundayStrictlyAfter(anchor); @@ -125,6 +141,17 @@ function temporalLabel(day: LiturgicalDay): string { return `${weekdayName} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName}`; } +/** "Third Day within the Octave of St. Lawrence" — the real DO title an + * octave day carries on its own (e.g. "Tertia die infra Octavam S. + * Laurentii Martyris") when nothing else has displaced it. Day 1 shouldn't + * normally reach this (that day's own winner is the feast itself, handled + * above before this is ever called) — kept simple rather than + * special-cased for that rare edge case (see applyOctaves's own + * `isOwnStartDay` comment in calendar/index.ts for when it can happen). */ +function octaveLabel(octave: ActiveOctave): string { + return `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`; +} + /** * The full "day being celebrated" label: a feast name when * calendar/commemorations.ts says the day has one, combined with (or @@ -147,7 +174,31 @@ export function getDayLabel(day: LiturgicalDay): string { return namedFeast.name; } + const commemoratedSaint = day.commemorations.find((c) => c.kind === 'sanctoral'); + + // A season's own named anchor day (Trinity Sunday, Easter, Ash + // Wednesday, Epiphany) outranks an active octave, same reasoning as + // anchorDayName's own doc comment — checked before the octave case + // below since Trinity Sunday, e.g., also happens to be day 8 of + // Pentecost's octave, and the anchor name is what actually governs. + const anchorName = anchorDayName(day); + if (anchorName) { + return commemoratedSaint ? `${commemoratedSaint.name} — ${anchorName}` : anchorName; + } + + // An active octave (St. Lawrence's, Christmas's own stack, ...) is this + // day's real primary identity in the live engine, not a footnote — e.g. + // "Tertia die infra Octavam S. Laurentii Martyris", not "Wednesday in + // the 11th week after Trinity". Picks the oldest-started octave when + // several are stacked (activeOctavesFor's own ordering) as the headline; + // the others still ride along as ordinary octave commemorations, just + // not separately named here. + const octaves = activeOctavesFor(day.date); + if (octaves.length > 0) { + const primary = octaveLabel(octaves[0]!); + return commemoratedSaint ? `${primary} — ${commemoratedSaint.name}` : primary; + } + const temporal = temporalLabel(day); - const commemorated = day.commemorations.find((c) => c.kind === 'sanctoral'); - return commemorated ? `${commemorated.name} — ${temporal}` : temporal; + return commemoratedSaint ? `${commemoratedSaint.name} — ${temporal}` : temporal; } diff --git a/src/data/calendar/saints/st-clare.yml b/src/data/calendar/saints/st-clare.yml new file mode 100644 index 0000000..5b30244 --- /dev/null +++ b/src/data/calendar/saints/st-clare.yml @@ -0,0 +1,17 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), live +# Lauds query (2026/2027/2028-08-12, all clean years with no Sunday +# collision) -- always commemorated, never wins outright: St. Lawrence's +# own octave (Aug 10-17, semiduplex threshold) reliably takes the day +# from her ("Tertia die infra Octavam S. Laurentii Martyris ~ +# Semiduplex", with "Commemoratio: S. Claræ Virginis"). Her own rank here +# is the Monastic-specific one (the raw file's "sed rubrica 1570 aut +# rubrica ^Monastica" branch, Simplex) rather than the later, higher +# Roman/secular Duplex given alongside it in the same file -- consistent +# with this app's general Monastic-track sourcing discipline. +# No unique collect/antiphon authored yet -- one of the remaining +# `propers: null` saints (see TODO.md). +id: st-clare +name: "St. Clare, Virgin" +rank: simplex +common: common-of-a-virgin +propers: null diff --git a/src/data/calendar/sanctoral-calendar.yml b/src/data/calendar/sanctoral-calendar.yml index 97a575f..3d9042a 100644 --- a/src/data/calendar/sanctoral-calendar.yml +++ b/src/data/calendar/sanctoral-calendar.yml @@ -182,6 +182,7 @@ days: "08-08": [ss-cyriacus-largus-and-smaragdus] "08-09": [vigil-of-st-lawrence, st-romanus] "08-10": [st-lawrence] + "08-12": [st-clare] "08-15": [assumption] "08-20": [st-bernard] "08-23": [vigil-of-st-bartholomew] diff --git a/tests/calendar/august-sanctoral.test.ts b/tests/calendar/august-sanctoral.test.ts index 2007237..0961d23 100644 --- a/tests/calendar/august-sanctoral.test.ts +++ b/tests/calendar/august-sanctoral.test.ts @@ -76,13 +76,22 @@ describe('August sanctoral pull (first pass)', () => { expect(getDayCollect(day).status.en).toBe('verified'); }); - 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 both octaves themselves (now modeled, see calendar/octaves.ts) correctly keep commemorating through them regardless", () => { + it("St. Lawrence's own octave correctly keeps St. Clare (Simplex) to a commemoration, and the Assumption's octave does the same for its own date", () => { + // St. Clare is now modeled (added directly off this gap) -- Simplex + // under Monastic 1617, below the octave's semiduplex `wins` threshold, + // so she's commemorated rather than winning outright, matching the + // live engine's own "Tertia die infra Octavam S. Laurentii Martiris ~ + // Semiduplex" / "Commemoratio: S. Claræ Virginis" for this date. const clareDay = resolveDay('2025-08-12'); expect(clareDay.winner.kind).toBe('temporal'); - expect(clareDay.commemorations).toEqual([{ kind: 'octave', id: 'st-lawrence', name: 'St. Lawrence, Martyr' }]); + expect(clareDay.commemorations).toEqual([ + { kind: 'sanctoral', id: 'st-clare', name: 'St. Clare, Virgin', rank: 'simplex' }, + { kind: 'octave', id: 'st-lawrence', name: 'St. Lawrence, Martyr' }, + ]); // Aug 22 is the Assumption's own octave day (In Octava Assumptionis), // Duplex, blocking Ss. Timothy/Hippolytus/Symphorian the same way -- - // it's also day 8 of the Assumption's own octave. + // it's also day 8 of the Assumption's own octave. Those three still + // aren't modeled, so this stays a bare octave commemoration. const assumptionOctaveDay = resolveDay('2025-08-22'); expect(assumptionOctaveDay.winner.kind).toBe('temporal'); expect(assumptionOctaveDay.commemorations).toEqual([ diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index e98f985..29def87 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -44,6 +44,26 @@ describe('getDayLabel — ordinal temporal label', () => { }); }); +describe('getDayLabel — active octave', () => { + it('shows the octave day itself as the primary label, plus a lesser saint commemorated alongside it', () => { + // St. Lawrence's own octave (Aug 10-17); Aug 12 is day 3, and also St. + // Clare's own day -- Simplex, below the octave's semiduplex threshold, + // so she's commemorated rather than winning. Matches the live engine's + // own title for this date directly (see saints/st-clare.yml). + expect(getDayLabel(resolveDay('2026-08-12'))).toBe('3rd Day within the Octave of St. Lawrence, Martyr — St. Clare, Virgin'); + }); + + it('shows the octave day alone when nothing is separately commemorated that day', () => { + // Aug 17 is day 8 (the last day) of St. Lawrence's octave, with no + // sanctoral candidate of its own in this app's calendar. + expect(getDayLabel(resolveDay('2026-08-17'))).toBe('8th Day within the Octave of St. Lawrence, Martyr'); + }); + + it("a season's own named anchor day outranks an active octave, even though Trinity Sunday is technically also day 8 of Pentecost's own octave", () => { + expect(getDayLabel(resolveDay('2026-05-31'))).toBe('Trinity Sunday'); + }); +}); + describe('getDayLabel — feast name combination', () => { const base: LiturgicalDay = { date: '2026-06-15',