Files
vu/src/calendar/day-label.ts
T
will 397f31f4fc
Deploy / deploy (push) Successful in 52s
Implement octave-vs-octave precedence, and fix a real day-label bug it surfaced
When more than one octave is active on a day with no temporal standing of
its own (St. Lawrence's and the Assumption's genuinely overlap every Aug
16-17), which one governs the day's content/label is now decided by rank
comparison instead of "whichever started first" (an accident of
insertion order). Per direct instruction:

- Highest effective rank wins outright; every other active octave still
  gets commemorated, same as a single octave already outranking a weak
  rival saint.
- A rank tie goes to whichever octave started more recently -- day 1 of
  a new octave needs to be fully present, the whole point of it
  starting. No real tied-rank case exists yet to verify this
  empirically, unlike everything else here -- documented in TODO.md as
  a stated decision, not a live finding.

New OctaveConfig.closingDayRank (default duplex): an octave's own final
day ("in Octava") is elevated above its ordinary in-between rank --
live-verified as a real, general pattern (both St. Lawrence's Aug 17 and
the Assumption's Aug 22 show as Duplex, above their otherwise-Semiduplex
ordinary days), not a one-off. This elevation is *why* Lawrence's octave
beats the Assumption's on their one real overlap day despite the
Assumption being the far higher-ranked feast overall. Feeds both the
existing rival-saint threshold and the new octave-vs-octave comparison.

calendar/octaves.ts gains resolveActiveOctave (+ pickWinningOctave, the
comparison itself factored out for direct unit testing against synthetic
data, since no real tied-rank overlap exists to test against yet).
hours/resolve-common.ts's resolveOfficeWinner and calendar/day-label.ts
both now call it instead of each keeping their own "activeOctavesFor(...)
[0]" logic.

That consolidation surfaced a real, independent bug: getDayLabel never
checked temporalCategory at all before choosing an octave name, unlike
resolveOfficeWinner -- found while testing the real Aug 16 overlap (a
Sunday that year, where the temporal Sunday has standing and should win
outright). Live-verified counterexample: the Christmas Octave's own
stack (Dec 30) was wrongly labeled "3rd Day within the Octave of The Holy
Innocents" instead of the correct plain temporal label -- the real title
never names any of the four stacked octaves there. Fixed by sharing the
exact same ordinary-feria gate resolveOfficeWinner already had, so the
two can no longer disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 09:38:06 -04:00

215 lines
8.7 KiB
TypeScript

// "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, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math';
import { getTemporalFeastRecord } from './temporal-feasts';
import { resolveActiveOctave, type ActiveOctave } from './octaves';
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';
}
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',
},
};
/** 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.anchorName;
}
return undefined;
}
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);
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}`;
}
/**
* 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') {
return day.winner.name;
}
// 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.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) — the others still
// ride along as ordinary octave commemorations, just not separately
// named here.
const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined;
if (activeOctave) {
const primary = octaveLabel(activeOctave);
return commemoratedSaint ? `${primary}${commemoratedSaint.name}` : primary;
}
const temporal = temporalLabel(day);
return commemoratedSaint ? `${commemoratedSaint.name}${temporal}` : temporal;
}