diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts new file mode 100644 index 0000000..f436377 --- /dev/null +++ b/src/calendar/day-label.ts @@ -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> = { + 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; +} diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 4ebcc5b..069456d 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -1,6 +1,7 @@ import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; import type { LiturgicalDay } from '../calendar/types'; import { resolveEveningDay } from '../calendar/vespers'; +import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getCommonProper } from '../propers'; import { getHymnDoxologyId } from './hymn-doxology'; @@ -97,5 +98,5 @@ export function resolveOrdo(date: string): ResolvedOrdo { // need to keep a separate "real" day around. const day = resolveEveningDay(date); const parts = complineDefinition.parts.flatMap((part) => resolvePart(part, day)); - return { hourId: 'compline', date, parts }; + return { hourId: 'compline', date, parts, dayLabel: getDayLabel(day) }; } diff --git a/src/hours/prime.ts b/src/hours/prime.ts index 2f4ca72..178c857 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -1,6 +1,7 @@ import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; import type { LiturgicalDay, Weekday } from '../calendar/types'; import { resolveDay, isSundayOrFeast } from '../calendar'; +import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getMartyrologyEntryFor } from '../martyrology'; @@ -117,5 +118,5 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved export function resolveOrdo(date: string): ResolvedOrdo { const day = resolveDay(date); const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day)); - return { hourId: 'prime', date, parts }; + return { hourId: 'prime', date, parts, dayLabel: getDayLabel(day) }; } diff --git a/src/hours/types.ts b/src/hours/types.ts index 4fb2e03..c3f3b01 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -135,4 +135,12 @@ export interface ResolvedOrdo { parts: ResolvedPart[]; /** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */ 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; } diff --git a/src/ui/hour-view.ts b/src/ui/hour-view.ts index 56713ff..bd32966 100644 --- a/src/ui/hour-view.ts +++ b/src/ui/hour-view.ts @@ -130,6 +130,7 @@ export function renderHourView(container: HTMLElement): void { container.innerHTML = `

${hourLabel(selectedHour)}

+ ${ordo.dayLabel ? `

${escapeHtml(ordo.dayLabel)}

` : ''} ${ordo.parts.map((part) => renderPart(part, languages)).join('')}
`; diff --git a/src/ui/styles.css b/src/ui/styles.css index fea90cd..3e1bfe9 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -261,6 +261,14 @@ button:focus-visible { 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 { font-family: var(--font-ui); font-size: 0.85em; diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts new file mode 100644 index 0000000..eb0bacd --- /dev/null +++ b/tests/calendar/day-label.test.ts @@ -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'); + }); +}); diff --git a/tests/hours/compline.test.ts b/tests/hours/compline.test.ts index 70fcfb1..bea9aa7 100644 --- a/tests/hours/compline.test.ts +++ b/tests/hours/compline.test.ts @@ -85,11 +85,12 @@ describe('resolveOrdo("compline", ...)', () => { 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. 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('The 1st Sunday of Advent'); const dayBefore = resolveOrdo('compline', '2025-11-28'); const lastBefore = dayBefore.parts[dayBefore.parts.length - 1];