// "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 { Commemoration, FeastClass, LiturgicalDay, TemporalCategory } from './types'; import { easterSunday } from './easter'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { addDays, daysBetween, toIsoDate } from './date-math'; import { getTemporalFeastRecord } from './temporal-feasts'; import { resolveActiveOctave, type ActiveOctave } from './octaves'; import { resolveTemporalId } from './temporal-id'; 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`; } } 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'; /** 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> = { 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', anchorRank: 'duplex-1-classis', }, 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', anchorRank: 'duplex-1-classis', }, trinitytide: { anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), 56), includeAnchorWeek: false, anchorName: 'Trinity Sunday', ordinalName: 'Trinity', preposition: 'after', anchorRank: 'duplex-1-classis', }, }; /** 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.anchorRank ? `${config.anchorName} (${formatRank(config.anchorRank)})` : config.anchorName; } return undefined; } /** The fixed final Sunday of the liturgical year (temporal-id.ts's * `post-pentecost-24`) always carries "Sunday XXIV after Pentecost"'s own * formulary, regardless of how many Sundays actually elapsed since * Trinity — confirmed by scanning every year 1900-2100, every single one * lands on this id for its own last Sunday before Advent, never a raw * elapsed-week count. This app's Trinity-counted display is one week off * from that Pentecost-counted id (Trinity Sunday itself = post-pentecost- * 01 = the display's own uncounted anchor day), so the fixed display * ordinal here is XXIV - 1 = 23, not whatever a plain "weeks since * Trinity" calculation would produce. */ const FIXED_LAST_SUNDAY_ORDINAL = 23; /** * Trinitytide's ordinal display can't be pure "weeks since Trinity's own * first Sunday" arithmetic once the season gets late enough — see * temporal-id.ts's own post-Pentecost-XXIII-plus branch. Two cases, both * driven by `resolveTemporalId` directly (the same mechanism the content * layer already trusts, rather than re-deriving the wdist arithmetic here * a second time, so the label can never disagree with what's actually * rendered underneath): * - A resumed post-Epiphany Sunday/week (an overflow year's skipped * Epiphany Sundays, reappearing here) — reads "after Epiphany", not * the next Trinity-counted number in line. * - The fixed final Sunday/week of the year itself — always the fixed * 23rd-after-Trinity ordinal (see FIXED_LAST_SUNDAY_ORDINAL above), * every year, not just overflow ones. * Only ever fires within trinitytide, since that's the only season * resolveTemporalId can return either of these ids for a date outside * their own native season. */ function trinitytideOverrideLabel(day: LiturgicalDay): string | undefined { if (day.season !== 'trinitytide') { return undefined; } const id = resolveTemporalId(day.date); const weekdayName = capitalize(day.weekday); const epiphanyMatch = id.match(/^post-epiphany-(\d)$/); if (epiphanyMatch) { const n = Number(epiphanyMatch[1]); return day.weekday === 'sunday' ? `The ${ordinal(n)} Sunday after Epiphany` : `${weekdayName} in the ${ordinal(n)} week after Epiphany`; } if (id === 'post-pentecost-24') { return day.weekday === 'sunday' ? `The ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} Sunday after Trinity` : `${weekdayName} in the ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} week after Trinity`; } return undefined; } function temporalLabel(day: LiturgicalDay): string { const trinitytideOverride = trinitytideOverrideLabel(day); if (trinitytideOverride) { return trinitytideOverride; } 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); const anchorName = anchorDayName(day); if (anchorName) { return 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}`; } /** "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} (${formatRank(octave.wins)})`; } /** Every `kind: 'octave'` commemoration on `day` other than `excludeId` — * an octave already serving as the day's own headline (a sanctoral * winner sharing an octave's id, or the octave `resolveActiveOctave` * itself picked as primary below) would be redundant to list again. * Plain names, not `octaveLabel`'s "Nth Day within the Octave of ..." * phrasing — that fuller phrasing is reserved for an octave that's * actually the day's own primary identity, not a secondary mention * alongside it (same plain-name convention `commemoratedSaint` already * uses below). Real gap this closes: a *second*, non-winning active * octave (e.g. the Assumption's own day 3, alongside St. Lawrence's * winning closing day) was previously dropped from the label entirely, * regardless of which branch below actually renders the primary name. */ function otherActiveOctaveNames(day: LiturgicalDay, excludeId: string | undefined): string[] { return day.commemorations .filter((c): c is Extract => c.kind === 'octave' && c.id !== excludeId) .map((c) => c.name); } const RANK_LABELS: Record = { simplex: 'Simplex', vigil: 'Vigil', semiduplex: 'Semiduplex', duplex: 'Duplex', 'duplex-majus': 'Duplex Majus', 'duplex-2-classis': 'Duplex II Class', 'duplex-1-classis': 'Duplex I Class', }; /** The day's own winning saint's rank, parenthesized after their name — * rank was previously shown nowhere in this app's UI at all, for any * saint. Only the day's own *winner* gets this treatment, not every * commemoration, matching the "winner is primary, commemorations are * secondary" distinction this file already draws throughout. */ 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 * 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 { if (day.winner.kind === 'sanctoral') { // A sanctoral winner can still share the day with an active octave // it didn't come from (e.g. winning a tie-break against one octave // while a second, unrelated octave is also active) — append those, // same "winner is primary, commemorations ride along" shape every // other branch below already uses. Gated on `ordinary-feria`, same // as the octave-headline branch further down: a day with real // standing of its own (e.g. Trinity Sunday, which is incidentally // also day 8 of Pentecost's own octave) never mentions an octave — // same "nobody calls it that" convention anchorDayName's own doc // comment already established for the anchor-day case. const otherOctaves = day.temporalCategory === 'ordinary-feria' ? otherActiveOctaveNames(day, undefined) : []; const winnerName = `${day.winner.name} (${formatRank(day.winner.rank)})`; return [winnerName, ...otherOctaves].join(' — '); } // A named temporal feast (Christmas, Pentecost, Marian Saturday, ...) // shows its own name rather than the ordinal week label — same // "winner displaces, doesn't combine" rule a sanctoral winner gets // above. Most temporal ids don't have a record at all (see // temporal-feasts.ts) and fall through to the ordinal label as before. const namedFeast = getTemporalFeastRecord(day.winner.id); if (namedFeast) { return namedFeast.rank ? `${namedFeast.name} (${formatRank(namedFeast.rank)})` : 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, ...) 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" — but *only* when the temporal day itself has no standing of // its own (`ordinary-feria`), same gate as hours/resolve-common.ts's // resolveOfficeWinner and for the same reason: live-verified // counterexample is the Christmas Octave's own stack (Dec 30, e.g., // `privileged-feria-minor`), where the real title stays the temporal // Sunday's own ("De Dominica Infra Octavam Nativitatis") with no octave // name in it at all — this label agreeing with resolveOfficeWinner // about which one wins is what makes "the office is Lawrence's" and // "the label says Lawrence" consistent instead of two independent // guesses that can disagree. When more than one octave is active at // once (resolveActiveOctave), the highest-ranked wins the headline // (ties broken by whichever started more recently) — every other // active octave still gets named too (otherActiveOctaveNames), not // dropped: live-verified real case, Aug 17 -- St. Lawrence's own // elevated closing day wins the headline, but the Assumption's own // day 3 (a real, distinct, simultaneously-active octave, not a // duplicate of Lawrence's) still belongs in the label alongside St. // Hyacinth's commemoration. const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined; if (activeOctave) { const primary = octaveLabel(activeOctave); const rest = [...otherActiveOctaveNames(day, activeOctave.id), ...(commemoratedSaint ? [commemoratedSaint.name] : [])]; return [primary, ...rest].join(' — '); } const temporal = `${temporalLabel(day)} (${TEMPORAL_CATEGORY_RANK_LABELS[day.temporalCategory]})`; return commemoratedSaint ? `${commemoratedSaint.name} — ${temporal}` : temporal; }