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:
2026-08-18 06:11:37 -04:00
parent ee3e155bfa
commit 1fc4bd7160
7 changed files with 224 additions and 64 deletions
+60 -6
View File
@@ -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);