Track a transferred saint's origin date on DayWinner and Commemoration
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013g7AsSvMD9oazR17f3BxLz
This commit is contained in:
+30
-9
@@ -56,6 +56,7 @@ function applyIncomingTransfer(
|
|||||||
temporalCategory: TemporalCategory,
|
temporalCategory: TemporalCategory,
|
||||||
winner: DayWinner,
|
winner: DayWinner,
|
||||||
commemorations: Commemoration[],
|
commemorations: Commemoration[],
|
||||||
|
fromDate: string,
|
||||||
): DayWinner {
|
): DayWinner {
|
||||||
if (temporalCategory === 'privileged-feria-major') {
|
if (temporalCategory === 'privileged-feria-major') {
|
||||||
// Deferred: a transfer landing on one of these (the concrete case is
|
// Deferred: a transfer landing on one of these (the concrete case is
|
||||||
@@ -67,6 +68,18 @@ function applyIncomingTransfer(
|
|||||||
// ordinary-sunday landing.
|
// ordinary-sunday landing.
|
||||||
return winner;
|
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') {
|
if (winner.kind === 'temporal') {
|
||||||
// Found via real data: a transferred-in Simplex saint was blindly
|
// Found via real data: a transferred-in Simplex saint was blindly
|
||||||
// winning outright even when it landed on a privileged Sunday (Trinity
|
// 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,
|
// the *same* precedence rules a native occurrence would have used,
|
||||||
// not just take over because nothing else was assigned here.
|
// not just take over because nothing else was assigned here.
|
||||||
const decided = decideOccurrence(temporalCategory, winner.id, candidate);
|
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
|
// If the candidate fails here too (decided.transfer set), that's a
|
||||||
// transfer chain — not modeled, same "not delivered rather than
|
// transfer chain — not modeled, same "not delivered rather than
|
||||||
// guessed at" stance as the privileged-feria-major case above.
|
// guessed at" stance as the privileged-feria-major case above.
|
||||||
return decided.winner;
|
return tagIfLanded(decided.winner);
|
||||||
}
|
}
|
||||||
const collision = resolveCollision(candidate, winner);
|
const collision = resolveCollision(candidate, winner);
|
||||||
commemorations.push(...collision.commemorations);
|
commemorations.push(...tagCommemorations(collision.commemorations));
|
||||||
return collision.winner;
|
return tagIfLanded(collision.winner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,13 +112,15 @@ export function resolveDay(isoDate: string): LiturgicalDay {
|
|||||||
let winner = result.winner;
|
let winner = result.winner;
|
||||||
const commemorations = [...result.commemorations];
|
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') {
|
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') {
|
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);
|
winner = applyOctaves(isoDate, winner, commemorations);
|
||||||
@@ -116,7 +131,13 @@ export function resolveDay(isoDate: string): LiturgicalDay {
|
|||||||
applyEpiphany6Commemoration(isoDate, commemorations);
|
applyEpiphany6Commemoration(isoDate, commemorations);
|
||||||
applyAdventFourVigilCommemoration(isoDate, weekday, 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -161,6 +161,12 @@ export type DayWinner =
|
|||||||
// First Vespers is being anticipated this evening (i.e. this feast
|
// First Vespers is being anticipated this evening (i.e. this feast
|
||||||
// belongs to *tomorrow*, but is winning tonight's Vespers/Compline).
|
// belongs to *tomorrow*, but is winning tonight's Vespers/Compline).
|
||||||
vespersFrom?: 'firstVespersOfTomorrow';
|
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);
|
} & SanctoralIdentity);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,6 +190,14 @@ export type Commemoration =
|
|||||||
// commemoration (a real collision, an octave day, etc.), which
|
// commemoration (a real collision, an octave day, etc.), which
|
||||||
// carries no such qualifier.
|
// carries no such qualifier.
|
||||||
vespersNote?: 'today' | 'tomorrow';
|
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)
|
} & SanctoralIdentity)
|
||||||
| { kind: 'octave'; id: string; name: string; nameLa?: string };
|
| { kind: 'octave'; id: string; name: string; nameLa?: string };
|
||||||
|
|
||||||
@@ -199,4 +213,16 @@ export interface LiturgicalDay {
|
|||||||
winner: DayWinner;
|
winner: DayWinner;
|
||||||
/** Everything else commemorated alongside the winner — see the doc comment on Commemoration. */
|
/** Everything else commemorated alongside the winner — see the doc comment on Commemoration. */
|
||||||
commemorations: 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,14 @@ describe('April sanctoral pull (first pass)', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const monday = resolveDay('2029-04-30');
|
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([
|
expect(monday.commemorations).toEqual([
|
||||||
{ kind: 'sanctoral', id: 'st-peter-martyr', name: 'St. Peter Martyr, Martyr', nameLa: 'Sanctus Petrus Martyr, Martyr', rank: 'semiduplex' },
|
{ kind: 'sanctoral', id: 'st-peter-martyr', name: 'St. Peter Martyr, Martyr', nameLa: 'Sanctus Petrus Martyr, Martyr', rank: 'semiduplex' },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ describe('November sanctoral pull (first pass)', () => {
|
|||||||
name: 'St. Clement I, Pope and Martyr',
|
name: 'St. Clement I, Pope and Martyr',
|
||||||
nameLa: 'Sanctus Clemens I, Papa et Martyr',
|
nameLa: 'Sanctus Clemens I, Papa et Martyr',
|
||||||
rank: 'semiduplex',
|
rank: 'semiduplex',
|
||||||
|
transferredFrom: '2025-11-23',
|
||||||
});
|
});
|
||||||
expect(followingDay.commemorations).toEqual([
|
expect(followingDay.commemorations).toEqual([
|
||||||
{ kind: 'sanctoral', id: 'st-chrysogonus', name: 'St. Chrysogonus, Martyr', nameLa: 'Sanctus Chrysogonus, Martyr', rank: 'simplex' },
|
{ kind: 'sanctoral', id: 'st-chrysogonus', name: 'St. Chrysogonus, Martyr', nameLa: 'Sanctus Chrysogonus, Martyr', rank: 'simplex' },
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('September sanctoral pull (first pass)', () => {
|
|||||||
name: 'St. Raymond Nonnatus, Confessor',
|
name: 'St. Raymond Nonnatus, Confessor',
|
||||||
nameLa: 'Sanctus Raymundus Nonnatus, Confessor',
|
nameLa: 'Sanctus Raymundus Nonnatus, Confessor',
|
||||||
rank: 'semiduplex',
|
rank: 'semiduplex',
|
||||||
|
transferredFrom: '2025-08-31',
|
||||||
});
|
});
|
||||||
expect(day.commemorations).toEqual([
|
expect(day.commemorations).toEqual([
|
||||||
{ kind: 'sanctoral', id: 'ss-twelve-brothers', name: 'Ss. Twelve Brothers, Martyrs', nameLa: 'Duodecim Fratres, Martyres', rank: 'simplex' },
|
{ kind: 'sanctoral', id: 'ss-twelve-brothers', name: 'Ss. Twelve Brothers, Martyrs', nameLa: 'Duodecim Fratres, Martyres', rank: 'simplex' },
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ describe('transfer mechanism (resolveDay integration)', () => {
|
|||||||
rank: 'semiduplex',
|
rank: 'semiduplex',
|
||||||
});
|
});
|
||||||
expect(saturday.commemorations).toEqual([
|
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
|
// The Sunday's own winner is unaffected by the vigil, but St. Romanus
|
||||||
|
|||||||
Reference in New Issue
Block a user