diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 92568e3..2045d8f 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -9,7 +9,7 @@ // become a configurable choice later (the same day->id indirection // philosophy already used for the sanctoral calendar), not hardcoded here // forever — just not built yet. -import type { Commemoration, FeastClass, LiturgicalDay } from './types'; +import type { Commemoration, FeastClass, LiturgicalDay, TemporalCategory } from './types'; import { easterSunday } from './easter'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { addDays, daysBetween, toIsoDate } from './date-math'; @@ -50,6 +50,12 @@ interface OrdinalSeason { /** Used in "the Nth Sunday/week {preposition} {ordinalName}". */ ordinalName: string; preposition: 'after' | 'of'; + /** The season's own anchor day's rank, if it sits on the closed + * pre-1955 duplex scale at all — Ash Wednesday (Lent's anchor) doesn't; + * a privileged feria has no duplex-scale rank, so it's left undefined + * and shows no parenthetical, same "omit rather than guess" convention + * `formatRank`/`status` fields use elsewhere. */ + anchorRank?: FeastClass; } const ORDINAL_SEASONS: Partial> = { @@ -66,6 +72,7 @@ const ORDINAL_SEASONS: Partial> = { anchorName: 'Epiphany', ordinalName: 'Epiphany', preposition: 'after', + anchorRank: 'duplex-1-classis', }, lent: { anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), -46), @@ -80,6 +87,7 @@ const ORDINAL_SEASONS: Partial> = { anchorName: 'Easter', ordinalName: 'Easter', preposition: 'after', + anchorRank: 'duplex-1-classis', }, trinitytide: { anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), 56), @@ -87,6 +95,7 @@ const ORDINAL_SEASONS: Partial> = { anchorName: 'Trinity Sunday', ordinalName: 'Trinity', preposition: 'after', + anchorRank: 'duplex-1-classis', }, }; @@ -105,7 +114,7 @@ function anchorDayName(day: LiturgicalDay): string | undefined { } const year = Number(day.date.slice(0, 4)); if (day.date === config.anchorDate(year)) { - return config.anchorName; + return config.anchorRank ? `${config.anchorName} (${formatRank(config.anchorRank)})` : config.anchorName; } return undefined; } @@ -209,7 +218,7 @@ function temporalLabel(day: LiturgicalDay): string { * 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}`; + return `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name} (${formatRank(octave.wins)})`; } /** Every `kind: 'octave'` commemoration on `day` other than `excludeId` — @@ -249,6 +258,24 @@ function formatRank(rank: FeastClass): string { return RANK_LABELS[rank]; } +/** Sundays/ferias aren't on the `FeastClass` duplex scale at all (that + * scale is for feasts), so this is a separate, plain-string lookup rather + * than another `formatRank` case — used for the final ordinal-temporal- + * label fallback (weekdays, Sundays with no named-feast/anchor-day/octave + * standing of their own). Pre-1955, an ordinary Sunday's own rank is + * Semiduplex regardless of whether it's additionally "privileged" + * (privilege is about resisting supersession, a property of + * `temporalCategory`, not a higher spot on the duplex scale) — so both + * Sunday categories share one label here. */ +const TEMPORAL_CATEGORY_RANK_LABELS: Record = { + 'ordinary-sunday': 'Semiduplex', + 'privileged-sunday': 'Semiduplex', + 'ordinary-feria': 'Feria', + 'privileged-feria-minor': 'Feria', + 'privileged-feria': 'Feria', + 'privileged-feria-major': 'Feria', +}; + /** * The full "day being celebrated" label: a feast name when * calendar/commemorations.ts says the day has one, combined with (or @@ -280,7 +307,7 @@ export function getDayLabel(day: LiturgicalDay): string { // temporal-feasts.ts) and fall through to the ordinal label as before. const namedFeast = getTemporalFeastRecord(day.winner.id); if (namedFeast) { - return namedFeast.name; + return namedFeast.rank ? `${namedFeast.name} (${formatRank(namedFeast.rank)})` : namedFeast.name; } const commemoratedSaint = day.commemorations.find((c) => c.kind === 'sanctoral'); @@ -323,6 +350,6 @@ export function getDayLabel(day: LiturgicalDay): string { return [primary, ...rest].join(' — '); } - const temporal = temporalLabel(day); + const temporal = `${temporalLabel(day)} (${TEMPORAL_CATEGORY_RANK_LABELS[day.temporalCategory]})`; return commemoratedSaint ? `${commemoratedSaint.name} — ${temporal}` : temporal; } diff --git a/tests/calendar/christ-the-king.test.ts b/tests/calendar/christ-the-king.test.ts index 7b60782..e4e2851 100644 --- a/tests/calendar/christ-the-king.test.ts +++ b/tests/calendar/christ-the-king.test.ts @@ -11,7 +11,7 @@ describe('Christ the King (applyChristTheKing)', () => { const day = resolveDay('2026-10-25'); // last Sunday of October 2026 expect(day.winner).toEqual({ kind: 'temporal', id: 'christ-the-king' }); expect(day.commemorations).toContainEqual({ kind: 'temporal', id: 'post-pentecost-22' }); - expect(getDayLabel(day)).toBe('Christ the King'); + expect(getDayLabel(day)).toBe('Christ the King (Duplex I Class)'); }); it('preserves a nested commemoration: a Simplex saint already commemorated under the ordinary Sunday stays commemorated', () => { diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index cde3f02..2b8763f 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -10,8 +10,8 @@ describe('getDayLabel — ordinal temporal label', () => { // the full-year sanctoral import), which would win outright and hide // this purely-temporal label case. 2027's Trinity Sunday is May 23, // so Trinity Monday is May 24 -- free of any sanctoral entry. - expect(getDayLabel(resolveDay('2027-05-24'))).toBe('Monday after Trinity Sunday'); - expect(getDayLabel(resolveDay('2026-01-07'))).toBe('Wednesday after Epiphany'); // Epiphany 2026 is a Tuesday + expect(getDayLabel(resolveDay('2027-05-24'))).toBe('Monday after Trinity Sunday (Feria)'); + expect(getDayLabel(resolveDay('2026-01-07'))).toBe('Wednesday after Epiphany (Feria)'); // Epiphany 2026 is a Tuesday }); it('names the anchor day itself, not "day after itself"', () => { @@ -25,7 +25,7 @@ describe('getDayLabel — ordinal temporal label', () => { // winner/commemorations breakdown), so the label now reflects her, // not Trinity Sunday itself. expect(getDayLabel(resolveDay('2026-05-31'))).toBe('The Queenship of the Blessed Virgin Mary (Duplex II Class)'); - expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter'); + expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter (Duplex I Class)'); expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday'); }); @@ -35,7 +35,7 @@ describe('getDayLabel — ordinal temporal label', () => { // sanctoral import), which wins outright. 2027's Trinity Sunday is // May 23, so 5 full weeks after the first Sunday-after-Trinity // (May 30) is Jun 28 -- free of any sanctoral entry. - expect(getDayLabel(resolveDay('2027-06-28'))).toBe('Monday in the 5th week after Trinity'); + expect(getDayLabel(resolveDay('2027-06-28'))).toBe('Monday in the 5th week after Trinity (Feria)'); }); it("Advent's own anchor Sunday is already week 1, not a separate anchor-week case", () => { @@ -45,8 +45,8 @@ describe('getDayLabel — ordinal temporal label', () => { // reference engine, which shows the same pairing), so the label // reflects both, feast name first, same pattern as Trinity Sunday's // own St. Felix I case above. - expect(getDayLabel(resolveDay('2025-11-30'))).toBe('St. Andrew, Apostle — The 1st Sunday of Advent'); - expect(getDayLabel(resolveDay('2025-12-01'))).toBe('Monday in the 1st week of Advent'); + expect(getDayLabel(resolveDay('2025-11-30'))).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)'); + expect(getDayLabel(resolveDay('2025-12-01'))).toBe('Monday in the 1st week of Advent (Feria)'); }); it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => { @@ -73,7 +73,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // appended to the Sunday's own label per this app's usual // commemorated-Simplex display. expect(getDayLabel(resolveDay('2024-11-10'))).toBe( - 'Ss. Tryphon, Respicius, and Nympha, Martyrs — The 5th Sunday after Epiphany', + 'Ss. Tryphon, Respicius, and Nympha, Martyrs — The 5th Sunday after Epiphany (Semiduplex)', ); // 2024-11-11 is St. Martin of Tours (Duplex, pre-existing content) -- // an ordinary (non-privileged) Monday fully yields to a real winning @@ -83,7 +83,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // Nov-dated Monday near this Sunday pair is clean any more, now that // Nov 4/8/15/16/20 are all fixed real content). expect(getDayLabel(resolveDay('2024-11-11'))).toBe('St. Martin of Tours, Bishop and Confessor (Duplex Majus)'); - expect(getDayLabel(resolveDay('2024-11-17'))).toBe('The 6th Sunday after Epiphany'); + expect(getDayLabel(resolveDay('2024-11-17'))).toBe('The 6th Sunday after Epiphany (Semiduplex)'); // 2024-11-18 is the Dedication of the Basilicas of Ss. Peter and Paul // (pre-existing fixed content) -- again a real winning saint, not the // ferial fallback. @@ -92,7 +92,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // stretch, is genuinely clean (no sanctoral entry, no commemoration) // -- covers the ferial "Monday in the Nth week after Epiphany" format // itself, independent of the specific 5th/6th Sunday pair above. - expect(getDayLabel(resolveDay('2035-10-29'))).toBe('Monday in the 3rd week after Epiphany'); + expect(getDayLabel(resolveDay('2035-10-29'))).toBe('Monday in the 3rd week after Epiphany (Feria)'); }); it('the fixed final Sunday of the year always shows the fixed 23rd-after-Trinity ordinal, not a raw elapsed-week count', () => { @@ -102,7 +102,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // Every year 1900-2100 lands on this same id for its own last Sunday // before Advent, so this ordinal is fixed, not overflow-year-specific // -- confirmed against a non-overflow year (1943) too. - expect(getDayLabel(resolveDay('2026-11-22'))).toBe('The 23rd Sunday after Trinity'); + expect(getDayLabel(resolveDay('2026-11-22'))).toBe('The 23rd Sunday after Trinity (Semiduplex)'); // 1943-11-21 is also the Presentation of the BVM (Duplex Majus, added // 2026-08 — see presentation-of-the-bvm.yml), which meets this app's // existing privileged-Sunday rank threshold (commemorations.ts's @@ -120,7 +120,9 @@ describe('getDayLabel — active octave', () => { // 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'); + expect(getDayLabel(resolveDay('2026-08-12'))).toBe( + '3rd Day within the Octave of St. Lawrence, Martyr (Semiduplex) — St. Clare, Virgin', + ); }); it('shows the octave day alone when nothing is separately commemorated that day', () => { @@ -137,7 +139,7 @@ describe('getDayLabel — active octave', () => { // "real content closed the gap" shape as August's Lawrence/Assumption // overlap. Day 3 of the same octave (2028-11-03, a Friday) is still // genuinely open -- no sanctoral entry, no separate commemoration. - expect(getDayLabel(resolveDay('2028-11-03'))).toBe('3rd Day within the Octave of All Saints'); + expect(getDayLabel(resolveDay('2028-11-03'))).toBe('3rd Day within the Octave of All Saints (Semiduplex)'); }); 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", () => { @@ -147,14 +149,14 @@ describe('getDayLabel — active octave', () => { // muddy this specifically octave-vs-anchor-day check with a sanctoral // winner. 2030's Trinity Sunday (June 16) has no sanctoral entry at // all, isolating the octave interaction cleanly. - expect(getDayLabel(resolveDay('2030-06-16'))).toBe('Trinity Sunday'); + expect(getDayLabel(resolveDay('2030-06-16'))).toBe('Trinity Sunday (Duplex I Class)'); }); it("a temporal day with real standing of its own (not ordinary-feria) never shows an octave name at all, even with one active -- live-verified counterexample: the Christmas Octave's own stack (Dec 30) titles itself off the Sunday, never any of the four octaves layered on top of it", () => { const day = resolveDay('2033-12-30'); expect(day.temporalCategory).toBe('privileged-feria-minor'); expect(getDayLabel(day)).not.toContain('Octave'); - expect(getDayLabel(day)).toBe('Friday in Christmastide'); + expect(getDayLabel(day)).toBe('Friday in Christmastide (Feria)'); }); it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => { @@ -171,9 +173,11 @@ describe('getDayLabel — active octave', () => { // day 3 (semiduplex, genuinely active but outranked by Lawrence's // elevated duplex) still gets named, not dropped, alongside it. expect(getDayLabel(resolveDay('2026-08-17'))).toBe( - '8th Day within the Octave of St. Lawrence, Martyr — The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor', + '8th Day within the Octave of St. Lawrence, Martyr (Duplex) — The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor', + ); + expect(getDayLabel(resolveDay('2026-08-18'))).toBe( + '4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)', ); - expect(getDayLabel(resolveDay('2026-08-18'))).toBe('4th Day within the Octave of The Assumption of the Blessed Virgin Mary'); }); it('a real Sunday with standing of its own wins over both octaves entirely, showing the plain ordinal Sunday label -- not an octave name at all', () => { @@ -185,6 +189,19 @@ describe('getDayLabel — active octave', () => { }); }); +describe('getDayLabel — named temporal feast rank', () => { + it('shows rank after a named temporal feast winner, sourced from its own TemporalFeastRecord', () => { + // Not Christmas Day itself: `resolveTemporalId` maps every day of + // christmastide (Dec 25 included) to `christmas-octave-sunday`, not + // `christmas-day` — a pre-existing, separate gap (`christmas-day`'s + // own record only ever surfaces via the octave layer, never as a + // direct winner id) out of scope here. Pentecost Sunday's own id + // *does* resolve as the direct winner (`temporal-id.ts`'s + // EASTER_OFFSET_IDS), so it's a real, clean case for this branch. + expect(getDayLabel(resolveDay('2026-05-24'))).toBe('Pentecost (Duplex I Class)'); + }); +}); + describe('getDayLabel — feast name combination', () => { const base: LiturgicalDay = { date: '2026-06-15', @@ -208,10 +225,10 @@ describe('getDayLabel — feast name combination', () => { ...base, commemorations: [{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis' }], }; - expect(getDayLabel(day)).toBe('St. Ereden — Monday in the 2nd week after Trinity'); + expect(getDayLabel(day)).toBe('St. Ereden — Monday in the 2nd week after Trinity (Feria)'); }); it('shows just the temporal label when nothing is commemorated at all', () => { - expect(getDayLabel(base)).toBe('Monday in the 2nd week after Trinity'); + expect(getDayLabel(base)).toBe('Monday in the 2nd week after Trinity (Feria)'); }); }); diff --git a/tests/calendar/marian-saturday.test.ts b/tests/calendar/marian-saturday.test.ts index 42959e2..ae0c71e 100644 --- a/tests/calendar/marian-saturday.test.ts +++ b/tests/calendar/marian-saturday.test.ts @@ -17,7 +17,7 @@ describe('Marian Saturday (applyMarianSaturday)', () => { const day = resolveDay('2026-07-04'); expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' }); expect(day.commemorations).toEqual([]); - expect(getDayLabel(day)).toBe("Our Lady's Saturday"); + expect(getDayLabel(day)).toBe("Our Lady's Saturday (Simplex)"); }); it('wins over a Simplex saint, who is commemorated instead of winning outright', () => { diff --git a/tests/hours/compline.test.ts b/tests/hours/compline.test.ts index bd8186e..6448cbf 100644 --- a/tests/hours/compline.test.ts +++ b/tests/hours/compline.test.ts @@ -92,7 +92,7 @@ describe('resolveOrdo("compline", ...)', () => { const eve = resolveOrdo('compline', '2025-11-29'); const last = eve.parts[eve.parts.length - 1]; expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater'); - expect(eve.dayLabel).toBe('St. Andrew, Apostle — The 1st Sunday of Advent'); + expect(eve.dayLabel).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)'); const dayBefore = resolveOrdo('compline', '2025-11-28'); const lastBefore = dayBefore.parts[dayBefore.parts.length - 1];