Compare commits
2 Commits
25669dfdae
...
427ba6756a
| Author | SHA1 | Date | |
|---|---|---|---|
| 427ba6756a | |||
| a68ab716b7 |
@@ -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<Record<string, OrdinalSeason>> = {
|
||||
@@ -66,6 +72,7 @@ const ORDINAL_SEASONS: Partial<Record<string, OrdinalSeason>> = {
|
||||
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<Record<string, OrdinalSeason>> = {
|
||||
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<Record<string, OrdinalSeason>> = {
|
||||
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<TemporalCategory, string> = {
|
||||
'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;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
// don't need a record at all (they're just collect-text lookups via
|
||||
// propers/index.ts's getTemporalProper); this only exists for the ones
|
||||
// that do.
|
||||
import type { OctaveConfig } from './types';
|
||||
import type { FeastClass, OctaveConfig } from './types';
|
||||
import { easterOffsetOf } from './temporal';
|
||||
|
||||
export interface TemporalFeastRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
octave?: OctaveConfig;
|
||||
rank?: FeastClass;
|
||||
}
|
||||
|
||||
const temporalFeastModules = import.meta.glob<{ default: TemporalFeastRecord }>(
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
# See calendar/index.ts's applyChristTheKing for the occurrence rule.
|
||||
id: christ-the-king
|
||||
name: "Christ the King"
|
||||
rank: duplex-1-classis
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
# specifically.
|
||||
id: christmas-day
|
||||
name: "Christmas"
|
||||
rank: duplex-1-classis
|
||||
octave:
|
||||
enabled: true
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
# for how a day actually becomes this.
|
||||
id: marian-saturday
|
||||
name: "Our Lady's Saturday"
|
||||
rank: simplex
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# before it gets the chance.
|
||||
id: pentecost-sunday
|
||||
name: "Pentecost"
|
||||
rank: duplex-1-classis
|
||||
octave:
|
||||
enabled: true
|
||||
wins: duplex
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user