Fix octave-tie precedence, day-label commemorations, and rank display
A tie between an occurring saint's rank and an active octave now favors the octave only on its own elevated closing day (live-verified: St. Hyacinth vs. St. Lawrence's own Aug 17 closing day), not on an ordinary octave day, where a tied saint still wins as before -- confirmed against two already-tested counterexamples (St. Thomas of Canterbury, St. Nicholas of Tolentino) that a blanket tie-flip would have broken. getDayLabel previously dropped every commemoration whenever the day's winner was a plain saint, and dropped every non-headline active octave even when an octave itself won -- both fixed. Rank is now shown after the day's own winner's name (previously not shown anywhere in the UI). Also authored assumption-octave-day-3.yml, a real content gap (Aug 17, day 3 of her octave) surfaced while fixing the above.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
// 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 type { Commemoration, FeastClass, LiturgicalDay } from './types';
|
||||
import { easterSunday } from './easter';
|
||||
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
|
||||
import { addDays, daysBetween, toIsoDate } from './date-math';
|
||||
@@ -212,6 +212,43 @@ function octaveLabel(octave: ActiveOctave): string {
|
||||
return `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`;
|
||||
}
|
||||
|
||||
/** 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<Commemoration, { kind: 'octave' }> => c.kind === 'octave' && c.id !== excludeId)
|
||||
.map((c) => c.name);
|
||||
}
|
||||
|
||||
const RANK_LABELS: Record<FeastClass, string> = {
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* The full "day being celebrated" label: a feast name when
|
||||
* calendar/commemorations.ts says the day has one, combined with (or
|
||||
@@ -221,7 +258,19 @@ function octaveLabel(octave: ActiveOctave): string {
|
||||
*/
|
||||
export function getDayLabel(day: LiturgicalDay): string {
|
||||
if (day.winner.kind === 'sanctoral') {
|
||||
return day.winner.name;
|
||||
// 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, ...)
|
||||
@@ -260,13 +309,18 @@ export function getDayLabel(day: LiturgicalDay): string {
|
||||
// "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.
|
||||
// (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);
|
||||
return commemoratedSaint ? `${primary} — ${commemoratedSaint.name}` : primary;
|
||||
const rest = [...otherActiveOctaveNames(day, activeOctave.id), ...(commemoratedSaint ? [commemoratedSaint.name] : [])];
|
||||
return [primary, ...rest].join(' — ');
|
||||
}
|
||||
|
||||
const temporal = temporalLabel(day);
|
||||
|
||||
+40
-10
@@ -3,7 +3,7 @@ import { weekdayOf } from './weekday';
|
||||
import { resolveSeason, resolveTemporalCategory, sundayOnOrBefore } from './temporal';
|
||||
import { resolveTemporalId } from './temporal-id';
|
||||
import { getSanctoralCandidatesFor } from './feasts';
|
||||
import { decideOccurrence, isAtLeast, type OccurrenceResult } from './commemorations';
|
||||
import { decideOccurrence, compareFeastClass, type OccurrenceResult } from './commemorations';
|
||||
import { resolveCollision } from './collision';
|
||||
import { addDays, toIsoDate } from './date-math';
|
||||
import { easterSunday } from './easter';
|
||||
@@ -281,11 +281,36 @@ function applyMarianSaturday(
|
||||
* Layered on top of everything above, not part of it: an octave doesn't
|
||||
* change how a single day's own precedence contest is decided, it just
|
||||
* (a) adds a commemoration for every octave still active on this date, and
|
||||
* (b) occasionally overrides the winner when the occurring saint is too
|
||||
* minor to clear the strictest active octave's threshold, in which case
|
||||
* the day reverts to its own temporal identity and the saint is
|
||||
* commemorated instead — same "demoted, not dropped" shape as every other
|
||||
* (b) occasionally overrides the winner when the occurring saint doesn't
|
||||
* outrank the strictest active octave's threshold, in which case the day
|
||||
* reverts to its own temporal identity and the saint is commemorated
|
||||
* instead — same "demoted, not dropped" shape as every other
|
||||
* commemoration rule in this file.
|
||||
*
|
||||
* A *tie against an octave's own elevated closing day* goes to the
|
||||
* octave, not the occurring saint — live-verified counterexample: St.
|
||||
* Hyacinth (plain Duplex, Aug 17) against St. Lawrence's own octave
|
||||
* closing day that same date (also Duplex, via `closingDayRank`'s
|
||||
* default) — the reference engine's own alternate block for that date is
|
||||
* titled "Commemoratio S. Hyacinthi Confessoris", i.e. Hyacinth is the
|
||||
* one merely commemorated there, Lawrence's own elevated closing day
|
||||
* keeps the office. Matches this file's own `collision.ts` precedent for
|
||||
* the analogous sanctoral-vs-sanctoral tie ("Ties favor `native` — the
|
||||
* incoming feast is the guest here"): the closing day is the
|
||||
* already-running incumbent's own elevated day, an occurring saint is
|
||||
* the guest, and a guest needs to actually outrank it to displace it,
|
||||
* not just match it.
|
||||
*
|
||||
* A tie against an *ordinary* (non-closing) octave day's threshold still
|
||||
* favors the occurring saint, unchanged from the original behavior —
|
||||
* confirmed by two already-verified, live-sourced counterexamples this
|
||||
* file's own tests carry: St. Thomas of Canterbury (plain Semiduplex,
|
||||
* Dec 29) wins outright against the Christmas Octave's own ordinary
|
||||
* `wins: semiduplex` default that day (not its closing day, Jan 1), and
|
||||
* St. Nicholas of Tolentino (plain Semiduplex, Sep 10) likewise against
|
||||
* the Nativity of the BVM's octave (day 3 of 8, not closing). Only a
|
||||
* closing day's own elevated rank carries the "already the incumbent"
|
||||
* weight that breaks a tie in the octave's favor.
|
||||
*/
|
||||
function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner {
|
||||
const octaves = activeOctavesFor(isoDate);
|
||||
@@ -294,11 +319,16 @@ function applyOctaves(isoDate: string, winner: DayWinner, commemorations: Commem
|
||||
}
|
||||
|
||||
let resolvedWinner = winner;
|
||||
if (winner.kind === 'sanctoral' && !isAtLeast(winner.rank, strictestThreshold(octaves))) {
|
||||
const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
|
||||
if (!isOneOfTheseOctaves) {
|
||||
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
|
||||
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
|
||||
if (winner.kind === 'sanctoral') {
|
||||
const threshold = strictestThreshold(octaves);
|
||||
const cmp = compareFeastClass(winner.rank, threshold);
|
||||
const tiedAgainstClosingDay = cmp === 0 && octaves.some((o) => o.isClosingDay && compareFeastClass(o.wins, threshold) === 0);
|
||||
if (cmp < 0 || tiedAgainstClosingDay) {
|
||||
const isOneOfTheseOctaves = octaves.some((o) => o.id === winner.id);
|
||||
if (!isOneOfTheseOctaves) {
|
||||
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
|
||||
resolvedWinner = { kind: 'temporal', id: resolveTemporalId(isoDate) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface ActiveOctave {
|
||||
wins: FeastClass;
|
||||
/** 1 on the feast's own day, counting up from there. */
|
||||
dayNumber: number;
|
||||
/** Whether `dayNumber` is this octave's own final ("in Octava") day —
|
||||
* i.e. whether `wins` above came from `closingDayRank` rather than the
|
||||
* ordinary `wins` config. Used by calendar/index.ts's applyOctaves to
|
||||
* decide which side a *tied* rank favors — see its own doc comment. */
|
||||
isClosingDay: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 8;
|
||||
@@ -58,7 +63,7 @@ function considerCandidate(
|
||||
const dayNumber = offset + 1;
|
||||
const isClosingDay = dayNumber === days;
|
||||
const wins = isClosingDay ? (octave.closingDayRank ?? DEFAULT_CLOSING_DAY_RANK) : (octave.wins ?? DEFAULT_WINS);
|
||||
active.push({ id, name, wins, dayNumber });
|
||||
active.push({ id, name, wins, dayNumber, isClosingDay });
|
||||
}
|
||||
|
||||
/** Every octave (sanctoral or temporal) whose window covers `isoDate`,
|
||||
|
||||
Reference in New Issue
Block a user