Adds a real label under each hour's heading — "Monday in the 10th week after Trinity", "The 1st Sunday of Advent" — computed from whichever LiturgicalDay the hour actually resolved against, which can differ from the nav date for Compline's evening anticipation. calendar/day-label.ts combines two things: an ordinal week-within-season label (pure date arithmetic on the season anchors from calendar/temporal.ts and calendar/easter.ts — Trinity-counted, not Divinum Officium's own Pentecost-counted convention; meant to become configurable later via the same day->id indirection already used for the sanctoral calendar, not hardcoded forever), and a feast name from calendar/commemorations.ts's occurrence decision — shown alone if the feast displaces the day outright, prefixed onto the temporal label if merely commemorated, or omitted entirely if nothing's occurring.
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
// "Day being celebrated" label — combines an ordinal week-within-season
|
||||||
|
// label (pure date arithmetic on the season anchors already computed
|
||||||
|
// elsewhere in calendar/) with a feast name when
|
||||||
|
// calendar/commemorations.ts's occurrence decision says one applies.
|
||||||
|
//
|
||||||
|
// Counting convention is Trinity-counted ("Nth Sunday/week after Trinity"),
|
||||||
|
// not Divinum Officium's own "after Pentecost" counting — a deliberate
|
||||||
|
// choice, one week off from Pentecost-counting for the same date. Meant to
|
||||||
|
// 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 { LiturgicalDay } from './types';
|
||||||
|
import { easterSunday } from './easter';
|
||||||
|
import { adventStart } from './temporal';
|
||||||
|
import { addDays, daysBetween, toIsoDate } from './date-math';
|
||||||
|
|
||||||
|
function capitalize(text: string): string {
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n: number): string {
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 13) {
|
||||||
|
return `${n}th`;
|
||||||
|
}
|
||||||
|
switch (n % 10) {
|
||||||
|
case 1:
|
||||||
|
return `${n}st`;
|
||||||
|
case 2:
|
||||||
|
return `${n}nd`;
|
||||||
|
case 3:
|
||||||
|
return `${n}rd`;
|
||||||
|
default:
|
||||||
|
return `${n}th`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayOf(isoDate: string): number {
|
||||||
|
return new Date(`${isoDate}T00:00:00Z`).getUTCDay(); // 0 = Sunday
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first Sunday strictly after `isoDate` (even if `isoDate` is itself a Sunday). */
|
||||||
|
function firstSundayStrictlyAfter(isoDate: string): string {
|
||||||
|
const dow = weekdayOf(isoDate);
|
||||||
|
return addDays(isoDate, dow === 0 ? 7 : 7 - dow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sundayOnOrBefore(isoDate: string): string {
|
||||||
|
return addDays(isoDate, -weekdayOf(isoDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrdinalSeason {
|
||||||
|
/** ISO date of the season's own anchor day, for a given calendar year. */
|
||||||
|
anchorDate(year: number): string;
|
||||||
|
/** Advent: the anchor Sunday itself is "week 1". Trinity/Epiphany/Easter/
|
||||||
|
* Lent: the anchor is its own named day, excluded from the count — the
|
||||||
|
* numbered weeks start the following Sunday. */
|
||||||
|
includeAnchorWeek: boolean;
|
||||||
|
/** Used in "Weekday after {anchorName}" for the anchor's own partial week. */
|
||||||
|
anchorName: string;
|
||||||
|
/** Used in "the Nth Sunday/week {preposition} {ordinalName}". */
|
||||||
|
ordinalName: string;
|
||||||
|
preposition: 'after' | 'of';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDINAL_SEASONS: Partial<Record<string, OrdinalSeason>> = {
|
||||||
|
advent: {
|
||||||
|
anchorDate: adventStart,
|
||||||
|
includeAnchorWeek: true,
|
||||||
|
anchorName: 'Advent',
|
||||||
|
ordinalName: 'Advent',
|
||||||
|
preposition: 'of',
|
||||||
|
},
|
||||||
|
epiphanytide: {
|
||||||
|
anchorDate: (year) => `${year}-01-06`,
|
||||||
|
includeAnchorWeek: false,
|
||||||
|
anchorName: 'Epiphany',
|
||||||
|
ordinalName: 'Epiphany',
|
||||||
|
preposition: 'after',
|
||||||
|
},
|
||||||
|
lent: {
|
||||||
|
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), -46),
|
||||||
|
includeAnchorWeek: false,
|
||||||
|
anchorName: 'Ash Wednesday',
|
||||||
|
ordinalName: 'Lent',
|
||||||
|
preposition: 'of',
|
||||||
|
},
|
||||||
|
eastertide: {
|
||||||
|
anchorDate: (year) => toIsoDate(easterSunday(year)),
|
||||||
|
includeAnchorWeek: false,
|
||||||
|
anchorName: 'Easter',
|
||||||
|
ordinalName: 'Easter',
|
||||||
|
preposition: 'after',
|
||||||
|
},
|
||||||
|
trinitytide: {
|
||||||
|
anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), 56),
|
||||||
|
includeAnchorWeek: false,
|
||||||
|
anchorName: 'Trinity Sunday',
|
||||||
|
ordinalName: 'Trinity',
|
||||||
|
preposition: 'after',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function temporalLabel(day: LiturgicalDay): string {
|
||||||
|
const weekdayName = capitalize(day.weekday);
|
||||||
|
const config = ORDINAL_SEASONS[day.season];
|
||||||
|
if (!config) {
|
||||||
|
// No ordinal convention modeled for this season (Septuagesima-tide,
|
||||||
|
// Passiontide, Ascensiontide, Pentecost, Christmastide, the
|
||||||
|
// Corpus-Christi/Sacred-Heart single-day seasons) — their few days
|
||||||
|
// mostly have their own proper names rather than ordinal counting, so
|
||||||
|
// this fallback is expected to be seen, not a gap to fill later.
|
||||||
|
return `${weekdayName} in ${capitalize(day.season.replace(/-/g, ' '))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 firstNumberedSunday = config.includeAnchorWeek ? anchor : firstSundayStrictlyAfter(anchor);
|
||||||
|
if (day.date < firstNumberedSunday) {
|
||||||
|
return `${weekdayName} after ${config.anchorName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeksSince = daysBetween(firstNumberedSunday, sundayOnOrBefore(day.date)) / 7;
|
||||||
|
const ordinalStr = ordinal(weeksSince + 1);
|
||||||
|
if (day.weekday === 'sunday') {
|
||||||
|
return `The ${ordinalStr} Sunday ${config.preposition} ${config.ordinalName}`;
|
||||||
|
}
|
||||||
|
return `${weekdayName} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full "day being celebrated" label: a feast name when
|
||||||
|
* calendar/commemorations.ts says the day has one, combined with (or
|
||||||
|
* replaced by) the ordinal temporal label depending on whether the feast
|
||||||
|
* won outright or was merely commemorated. See the plan discussion this
|
||||||
|
* came from for the three cases.
|
||||||
|
*/
|
||||||
|
export function getDayLabel(day: LiturgicalDay): string {
|
||||||
|
const winner = day.occurring.find((feast) => !feast.commemorated);
|
||||||
|
if (winner) {
|
||||||
|
return winner.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporal = temporalLabel(day);
|
||||||
|
const commemorated = day.occurring.find((feast) => feast.commemorated);
|
||||||
|
return commemorated ? `${commemorated.name} — ${temporal}` : temporal;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
|
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
|
||||||
import type { LiturgicalDay } from '../calendar/types';
|
import type { LiturgicalDay } from '../calendar/types';
|
||||||
import { resolveEveningDay } from '../calendar/vespers';
|
import { resolveEveningDay } from '../calendar/vespers';
|
||||||
|
import { getDayLabel } from '../calendar/day-label';
|
||||||
import { getPsalmVerses } from '../psalter';
|
import { getPsalmVerses } from '../psalter';
|
||||||
import { getCommonProper } from '../propers';
|
import { getCommonProper } from '../propers';
|
||||||
import { getHymnDoxologyId } from './hymn-doxology';
|
import { getHymnDoxologyId } from './hymn-doxology';
|
||||||
@@ -97,5 +98,5 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
|||||||
// need to keep a separate "real" day around.
|
// need to keep a separate "real" day around.
|
||||||
const day = resolveEveningDay(date);
|
const day = resolveEveningDay(date);
|
||||||
const parts = complineDefinition.parts.flatMap((part) => resolvePart(part, day));
|
const parts = complineDefinition.parts.flatMap((part) => resolvePart(part, day));
|
||||||
return { hourId: 'compline', date, parts };
|
return { hourId: 'compline', date, parts, dayLabel: getDayLabel(day) };
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
|
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
|
||||||
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
||||||
import { resolveDay, isSundayOrFeast } from '../calendar';
|
import { resolveDay, isSundayOrFeast } from '../calendar';
|
||||||
|
import { getDayLabel } from '../calendar/day-label';
|
||||||
import { getPsalmsFor } from '../psalter/distribution';
|
import { getPsalmsFor } from '../psalter/distribution';
|
||||||
import { getPsalmVerses } from '../psalter';
|
import { getPsalmVerses } from '../psalter';
|
||||||
import { getMartyrologyEntryFor } from '../martyrology';
|
import { getMartyrologyEntryFor } from '../martyrology';
|
||||||
@@ -117,5 +118,5 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
|||||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||||
const day = resolveDay(date);
|
const day = resolveDay(date);
|
||||||
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day));
|
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day));
|
||||||
return { hourId: 'prime', date, parts };
|
return { hourId: 'prime', date, parts, dayLabel: getDayLabel(day) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,4 +135,12 @@ export interface ResolvedOrdo {
|
|||||||
parts: ResolvedPart[];
|
parts: ResolvedPart[];
|
||||||
/** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */
|
/** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */
|
||||||
notImplemented?: true;
|
notImplemented?: true;
|
||||||
|
/**
|
||||||
|
* "Monday in the 10th week after Trinity", "St. Ereden — ...", etc. —
|
||||||
|
* see calendar/day-label.ts. Computed from whichever LiturgicalDay the
|
||||||
|
* hour actually resolved against, which can differ from `date` itself
|
||||||
|
* (Compline may anticipate tomorrow — see calendar/vespers.ts). Absent
|
||||||
|
* on not-yet-built hours.
|
||||||
|
*/
|
||||||
|
dayLabel?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function renderHourView(container: HTMLElement): void {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="hour-view">
|
<div class="hour-view">
|
||||||
<h2>${hourLabel(selectedHour)}</h2>
|
<h2>${hourLabel(selectedHour)}</h2>
|
||||||
|
${ordo.dayLabel ? `<p class="day-label">${escapeHtml(ordo.dayLabel)}</p>` : ''}
|
||||||
${ordo.parts.map((part) => renderPart(part, languages)).join('')}
|
${ordo.parts.map((part) => renderPart(part, languages)).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -261,6 +261,14 @@ button:focus-visible {
|
|||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.day-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-brass);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.ordo-part-citation {
|
.ordo-part-citation {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveDay } from '../../src/calendar';
|
||||||
|
import { getDayLabel } from '../../src/calendar/day-label';
|
||||||
|
import type { LiturgicalDay } from '../../src/calendar/types';
|
||||||
|
|
||||||
|
describe('getDayLabel — ordinal temporal label', () => {
|
||||||
|
it("labels an anchor's own partial week as \"Weekday after {Anchor}\"", () => {
|
||||||
|
// Trinity Sunday 2026 is May 31.
|
||||||
|
expect(getDayLabel(resolveDay('2026-06-01'))).toBe('Monday after Trinity Sunday');
|
||||||
|
expect(getDayLabel(resolveDay('2026-01-07'))).toBe('Wednesday after Epiphany'); // Epiphany 2026 is a Tuesday
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the anchor day itself, not "day after itself"', () => {
|
||||||
|
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('Trinity Sunday');
|
||||||
|
expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter');
|
||||||
|
expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the correct week-after-Trinity ordinal', () => {
|
||||||
|
// Trinity Sunday 2026 is May 31; Jul 6 falls 5 full weeks after the
|
||||||
|
// first Sunday-after-Trinity (Jun 7).
|
||||||
|
expect(getDayLabel(resolveDay('2026-07-06'))).toBe('Monday in the 5th week after Trinity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Advent's own anchor Sunday is already week 1, not a separate anchor-week case", () => {
|
||||||
|
expect(getDayLabel(resolveDay('2025-11-30'))).toBe('The 1st Sunday of Advent');
|
||||||
|
expect(getDayLabel(resolveDay('2025-12-01'))).toBe('Monday in the 1st week of Advent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => {
|
||||||
|
expect(getDayLabel(resolveDay('2026-05-15'))).toContain('in Ascensiontide');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDayLabel — feast name combination', () => {
|
||||||
|
const base: LiturgicalDay = {
|
||||||
|
date: '2026-06-15',
|
||||||
|
weekday: 'monday',
|
||||||
|
season: 'trinitytide',
|
||||||
|
temporalCategory: 'ordinary-feria',
|
||||||
|
occurring: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('shows just the feast name when it wins outright', () => {
|
||||||
|
const day: LiturgicalDay = {
|
||||||
|
...base,
|
||||||
|
occurring: [{ id: 'x', name: 'St. Ereden', rank: 'duplex', commemorated: false }],
|
||||||
|
};
|
||||||
|
expect(getDayLabel(day)).toBe('St. Ereden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows both, feast first, when the feast is merely commemorated', () => {
|
||||||
|
const day: LiturgicalDay = {
|
||||||
|
...base,
|
||||||
|
occurring: [{ id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis', commemorated: true }],
|
||||||
|
};
|
||||||
|
expect(getDayLabel(day)).toBe('St. Ereden — Monday in the 2nd week after Trinity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows just the temporal label when nothing is occurring at all', () => {
|
||||||
|
expect(getDayLabel(base)).toBe('Monday in the 2nd week after Trinity');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,11 +85,12 @@ describe('resolveOrdo("compline", ...)', () => {
|
|||||||
expect(last?.kind === 'preces' ? last.text.text.en : undefined).toContain('Hail holy Queen');
|
expect(last?.kind === 'preces' ? last.text.text.en : undefined).toContain('Hail holy Queen');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('anticipates Advent I: the Saturday evening before switches to the Alma Redemptoris Mater', () => {
|
it('anticipates Advent I: the Saturday evening before switches to the Alma Redemptoris Mater and shows the anticipated day label', () => {
|
||||||
// Nov 30, 2025 is Advent I Sunday.
|
// Nov 30, 2025 is Advent I Sunday.
|
||||||
const eve = resolveOrdo('compline', '2025-11-29');
|
const eve = resolveOrdo('compline', '2025-11-29');
|
||||||
const last = eve.parts[eve.parts.length - 1];
|
const last = eve.parts[eve.parts.length - 1];
|
||||||
expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater');
|
expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater');
|
||||||
|
expect(eve.dayLabel).toBe('The 1st Sunday of Advent');
|
||||||
|
|
||||||
const dayBefore = resolveOrdo('compline', '2025-11-28');
|
const dayBefore = resolveOrdo('compline', '2025-11-28');
|
||||||
const lastBefore = dayBefore.parts[dayBefore.parts.length - 1];
|
const lastBefore = dayBefore.parts[dayBefore.parts.length - 1];
|
||||||
|
|||||||
Reference in New Issue
Block a user