From db9c844caf2f05c7d185b768fdf33d14f775563b Mon Sep 17 00:00:00 2001 From: Will Estes Date: Mon, 31 Aug 2026 08:53:02 -0400 Subject: [PATCH] Track a transferred saint's origin date on DayWinner and Commemoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds transferredFrom to both DayWinner's sanctoral variant and Commemoration's sanctoral variant, set in calendar/index.ts's applyIncomingTransfer whenever a transferred-in candidate actually lands (wins outright) or ends up merely commemorated (lost a collision, or fell below the landing day's own threshold) — previously this signal was discarded after resolveDay used it once to decide the winner, so nothing downstream could tell a transferred feast apart from a native one. Also adds transferredAway to LiturgicalDay, set from a date's own native occurrence when its candidate couldn't be kept there at all, so a UI can note "this office moved elsewhere" without asserting a specific landing date (which a later, unmodeled multi-hop chain — e.g. into Holy Week — could get wrong). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013g7AsSvMD9oazR17f3BxLz --- src/calendar/index.ts | 39 +++++++++++++++++----- src/calendar/types.ts | 26 +++++++++++++++ tests/calendar/april-sanctoral.test.ts | 9 ++++- tests/calendar/november-sanctoral.test.ts | 1 + tests/calendar/september-sanctoral.test.ts | 1 + tests/calendar/transfer.test.ts | 9 ++++- 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/calendar/index.ts b/src/calendar/index.ts index ce17dbf..6b9f588 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -56,6 +56,7 @@ function applyIncomingTransfer( temporalCategory: TemporalCategory, winner: DayWinner, commemorations: Commemoration[], + fromDate: string, ): DayWinner { if (temporalCategory === 'privileged-feria-major') { // Deferred: a transfer landing on one of these (the concrete case is @@ -67,6 +68,18 @@ function applyIncomingTransfer( // ordinary-sunday landing. return winner; } + // Tags the returned winner with where it transferred from, but only when + // the candidate actually ended up winning here — a collision loss (the + // candidate merely commemorated, incumbent keeps the day) isn't a landing. + const tagIfLanded = (result: DayWinner): DayWinner => + result.kind === 'sanctoral' && result.id === candidate.id ? { ...result, transferredFrom: fromDate } : result; + // Same tagging, but for the candidate's own *commemoration* — it still + // needs marking as arrived-via-transfer even when it lost the landing + // day outright (a collision loss, or falling below the landing day's own + // threshold in decideOccurrence) rather than merely being naturally due + // for commemoration here. + const tagCommemorations = (result: Commemoration[]): Commemoration[] => + result.map((c) => (c.kind === 'sanctoral' && c.id === candidate.id ? { ...c, transferredFrom: fromDate } : c)); if (winner.kind === 'temporal') { // Found via real data: a transferred-in Simplex saint was blindly // winning outright even when it landed on a privileged Sunday (Trinity @@ -74,15 +87,15 @@ function applyIncomingTransfer( // the *same* precedence rules a native occurrence would have used, // not just take over because nothing else was assigned here. const decided = decideOccurrence(temporalCategory, winner.id, candidate); - commemorations.push(...decided.commemorations); + commemorations.push(...tagCommemorations(decided.commemorations)); // If the candidate fails here too (decided.transfer set), that's a // transfer chain — not modeled, same "not delivered rather than // guessed at" stance as the privileged-feria-major case above. - return decided.winner; + return tagIfLanded(decided.winner); } const collision = resolveCollision(candidate, winner); - commemorations.push(...collision.commemorations); - return collision.winner; + commemorations.push(...tagCommemorations(collision.commemorations)); + return tagIfLanded(collision.winner); } /** @@ -99,13 +112,15 @@ export function resolveDay(isoDate: string): LiturgicalDay { let winner = result.winner; const commemorations = [...result.commemorations]; - const yesterday = resolveNativeOccurrence(addDays(isoDate, -1)); + const yesterdayIso = addDays(isoDate, -1); + const yesterday = resolveNativeOccurrence(yesterdayIso); if (yesterday.result.transfer?.direction === 'forward') { - winner = applyIncomingTransfer(yesterday.result.transfer.candidate, temporalCategory, winner, commemorations); + winner = applyIncomingTransfer(yesterday.result.transfer.candidate, temporalCategory, winner, commemorations, yesterdayIso); } - const tomorrow = resolveNativeOccurrence(addDays(isoDate, 1)); + const tomorrowIso = addDays(isoDate, 1); + const tomorrow = resolveNativeOccurrence(tomorrowIso); if (tomorrow.result.transfer?.direction === 'backward') { - winner = applyIncomingTransfer(tomorrow.result.transfer.candidate, temporalCategory, winner, commemorations); + winner = applyIncomingTransfer(tomorrow.result.transfer.candidate, temporalCategory, winner, commemorations, tomorrowIso); } winner = applyOctaves(isoDate, winner, commemorations); @@ -116,7 +131,13 @@ export function resolveDay(isoDate: string): LiturgicalDay { applyEpiphany6Commemoration(isoDate, commemorations); applyAdventFourVigilCommemoration(isoDate, weekday, commemorations); - return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; + // This date's own native candidate (if any) couldn't be kept here and + // moved on instead — see calendar/types.ts's `transferredAway` doc + // comment for why this is tracked separately from `Commemoration`, and + // why no landing date is recorded. + const transferredAway = result.transfer ? { candidate: result.transfer.candidate } : undefined; + + return { date: isoDate, weekday, season, temporalCategory, winner, commemorations, transferredAway }; } /** diff --git a/src/calendar/types.ts b/src/calendar/types.ts index 6156c31..30ebcea 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -161,6 +161,12 @@ export type DayWinner = // First Vespers is being anticipated this evening (i.e. this feast // belongs to *tomorrow*, but is winning tonight's Vespers/Compline). vespersFrom?: 'firstVespersOfTomorrow'; + // Set by calendar/index.ts's resolveDay/applyIncomingTransfer when + // this winner is only here because its own native date (an adjacent + // ISO date) couldn't hold it (calendar/commemorations.ts's + // `decideOccurrence` `transfer` signal) — the ISO date it transferred + // *from*. Absent for a feast winning on its own native date. + transferredFrom?: string; } & SanctoralIdentity); /** @@ -184,6 +190,14 @@ export type Commemoration = // commemoration (a real collision, an octave day, etc.), which // carries no such qualifier. vespersNote?: 'today' | 'tomorrow'; + // Set by calendar/index.ts's applyIncomingTransfer, same meaning as + // `DayWinner`'s own `transferredFrom` — this saint is only + // commemorated here (rather than absent) because a transfer landed + // on this date and lost a collision, or fell below the landing + // day's own outright-winning threshold, not because it's natively + // due for commemoration here. Absent for an ordinary same-day + // commemoration. + transferredFrom?: string; } & SanctoralIdentity) | { kind: 'octave'; id: string; name: string; nameLa?: string }; @@ -199,4 +213,16 @@ export interface LiturgicalDay { winner: DayWinner; /** Everything else commemorated alongside the winner — see the doc comment on Commemoration. */ commemorations: Commemoration[]; + /** Set when this date's own native sanctoral candidate couldn't be kept + * here at all (calendar/commemorations.ts's `decideOccurrence` `transfer` + * signal) and moved to a later/earlier date instead. No landing date is + * recorded here — `resolveDay` only ever checks the immediate adjacent + * date, but a real landing can chain further than that (an unmodeled + * case noted in `applyIncomingTransfer`, e.g. a transfer running into + * Holy Week), so naming a specific "to" date risked asserting a wrong + * one; this exists purely so the display can say "not an omission, this + * office moved elsewhere" without claiming to know where. Distinct from + * — and not implied by — `Commemoration`, since a transferred-away + * candidate gets no commemoration on its own native date at all. */ + transferredAway?: { candidate: SanctoralIdentity }; } diff --git a/tests/calendar/april-sanctoral.test.ts b/tests/calendar/april-sanctoral.test.ts index 2121173..9932405 100644 --- a/tests/calendar/april-sanctoral.test.ts +++ b/tests/calendar/april-sanctoral.test.ts @@ -45,7 +45,14 @@ describe('April sanctoral pull (first pass)', () => { ]); const monday = resolveDay('2029-04-30'); - expect(monday.winner).toEqual({ kind: 'sanctoral', id: 'st-robert', name: 'St. Robert, Abbot', nameLa: 'Sanctus Robertus, Abbas', rank: 'duplex' }); + expect(monday.winner).toEqual({ + kind: 'sanctoral', + id: 'st-robert', + name: 'St. Robert, Abbot', + nameLa: 'Sanctus Robertus, Abbas', + rank: 'duplex', + transferredFrom: '2029-04-29', + }); expect(monday.commemorations).toEqual([ { kind: 'sanctoral', id: 'st-peter-martyr', name: 'St. Peter Martyr, Martyr', nameLa: 'Sanctus Petrus Martyr, Martyr', rank: 'semiduplex' }, ]); diff --git a/tests/calendar/november-sanctoral.test.ts b/tests/calendar/november-sanctoral.test.ts index d229b99..a24c755 100644 --- a/tests/calendar/november-sanctoral.test.ts +++ b/tests/calendar/november-sanctoral.test.ts @@ -85,6 +85,7 @@ describe('November sanctoral pull (first pass)', () => { name: 'St. Clement I, Pope and Martyr', nameLa: 'Sanctus Clemens I, Papa et Martyr', rank: 'semiduplex', + transferredFrom: '2025-11-23', }); expect(followingDay.commemorations).toEqual([ { kind: 'sanctoral', id: 'st-chrysogonus', name: 'St. Chrysogonus, Martyr', nameLa: 'Sanctus Chrysogonus, Martyr', rank: 'simplex' }, diff --git a/tests/calendar/september-sanctoral.test.ts b/tests/calendar/september-sanctoral.test.ts index e67cdfc..f4995a6 100644 --- a/tests/calendar/september-sanctoral.test.ts +++ b/tests/calendar/september-sanctoral.test.ts @@ -28,6 +28,7 @@ describe('September sanctoral pull (first pass)', () => { name: 'St. Raymond Nonnatus, Confessor', nameLa: 'Sanctus Raymundus Nonnatus, Confessor', rank: 'semiduplex', + transferredFrom: '2025-08-31', }); expect(day.commemorations).toEqual([ { kind: 'sanctoral', id: 'ss-twelve-brothers', name: 'Ss. Twelve Brothers, Martyrs', nameLa: 'Duodecim Fratres, Martyres', rank: 'simplex' }, diff --git a/tests/calendar/transfer.test.ts b/tests/calendar/transfer.test.ts index 1abed20..25801ae 100644 --- a/tests/calendar/transfer.test.ts +++ b/tests/calendar/transfer.test.ts @@ -28,7 +28,14 @@ describe('transfer mechanism (resolveDay integration)', () => { rank: 'semiduplex', }); expect(saturday.commemorations).toEqual([ - { kind: 'sanctoral', id: 'vigil-of-st-lawrence', name: 'Vigil of St. Lawrence', nameLa: 'Vigilia Sancti Laurentii', rank: 'vigil' }, + { + kind: 'sanctoral', + id: 'vigil-of-st-lawrence', + name: 'Vigil of St. Lawrence', + nameLa: 'Vigilia Sancti Laurentii', + rank: 'vigil', + transferredFrom: '2026-08-09', + }, ]); // The Sunday's own winner is unaffected by the vigil, but St. Romanus