diff --git a/TODO.md b/TODO.md index 35601e6..dfbd8db 100644 --- a/TODO.md +++ b/TODO.md @@ -67,40 +67,54 @@ for Terce/Sext/None, antiphon only for Prime (its chapter needed no new file — see above). The same 31-saint backlog above applies here too, one id at a time, as each gets a Lauds override. -### Overlapping octaves — real gap found, not yet fixed +### Overlapping octaves — done (2026-08) -`resolveOfficeWinner` (and `getDayLabel`) currently pick whichever active -octave started *first* (`activeOctavesFor`'s own ordering) whenever more -than one is active on a day with no temporal standing of its own — purely -an accident of insertion order, not a real precedence rule. St. Lawrence's -octave (Aug 10-17) and the Assumption's octave (Aug 15-22) genuinely -overlap on Aug 16-17 every year, so this isn't a hypothetical. +Was: `resolveOfficeWinner`/`getDayLabel` picked whichever active octave +started *first* whenever more than one was active — an accident of +insertion order, not a real precedence rule. St. Lawrence's octave (Aug +10-17) and the Assumption's (Aug 15-22) genuinely overlap on Aug 16-17 +every year, so this wasn't hypothetical. -Live-checked both overlap days (Monastic Tridentinum 1617, 2026): Aug 16 -that year happens to be a Sunday, so the *temporal* Sunday wins outright -and both octaves are merely commemorated (already correctly handled by -the existing `ordinary-feria` gate — not actually a two-octave contest at -all). Aug 17 is the real case: "**In Octava S. Laurentii Martyris ~ -Duplex**" wins outright, with "*Tertia die infra Octavam S. Assumptionis*" -merely commemorated — Lawrence's octave beats the Assumption's despite the -Assumption being the far higher-ranked feast overall (Duplex I. classis vs. -Lawrence's Duplex II. classis). Why: Aug 17 is specifically Lawrence's own -**final octave day** ("in Octava," the 8th and closing day), which real -practice elevates to its own higher rank (Duplex here, above the ordinary -Semiduplex of the days in between) — a concept this app doesn't model at -all yet (`OctaveConfig` has no notion of an elevated final day, every day -of an octave is currently treated as uniform strength). +Design (per direct instruction, not independently re-derived from a +primary source the way most of this project is): when more than one +octave is active on a day with no temporal standing of its own to yield +to first, compare them by rank — highest wins outright, exactly like a +single octave already out-ranks a weak rival saint; every other active +octave still gets commemorated, never silently dropped. A rank tie goes +to whichever octave started more *recently* — reasoning given: day 1 of +a newly started octave needs to be fully present, which is the whole +point of an octave starting, so the older one yields instead. No real +tied-rank case exists yet in this app's calendar to verify that +empirically, unlike almost everything else in this project — flagged as +a stated, unverified-by-evidence design decision, not a live finding. -Proposed design (not yet built, needs a decision): when multiple octaves -are active with no temporal standing to yield to, compare them by rank -the same way a single octave already compares against a rival saint — -highest rank wins outright, the rest are commemorated instead of silently -dropped to "whichever came first." Needs `OctaveConfig` to carry an -elevated final-day rank (distinct from the ordinary in-between-days rank) -to get Aug 17 right for a real reason rather than by lucky insertion -order. Low urgency in practice (currently only this one real overlap -pair), but worth fixing before adding more octaves that could collide the -same way. +Also needed `OctaveConfig.closingDayRank` (default `duplex`): an +octave's own *closing* day ("in Octava," the 8th/final day) is elevated +above its ordinary in-between-days rank — live-verified as a real, +general pattern (not a St. Lawrence quirk): both his own Aug 17 and the +Assumption's own Aug 22 show as Duplex, above their otherwise-Semiduplex +ordinary days. This is *why* Lawrence's octave beats the Assumption's on +their one real overlap day (Aug 17) despite the Assumption being the far +higher-ranked feast overall (Duplex I. classis vs. Lawrence's Duplex II. +classis) — his closing-day elevation (Duplex) beats her ordinary day-3 +strength (Semiduplex). Feeds both `strictestThreshold` (a rival saint +now needs Duplex+, not just Semiduplex+, to beat an octave specifically +on its own closing day) and the new octave-vs-octave comparison +(`calendar/octaves.ts`'s `resolveActiveOctave`/`pickWinningOctave`) — +same underlying "how strong is this octave today" question, two +different comparison partners. + +`getDayLabel` was carrying its own separate, never-actually-correct +octave-selection logic that additionally never checked +`temporalCategory` at all — found while testing the real Aug 16 overlap +(a Sunday that year): it was showing an octave name even though the +*temporal* Sunday has real standing and wins outright, live-verified +counterexample being the Christmas Octave's own stack (Dec 30), which +was wrongly showing "3rd Day within the Octave of The Holy Innocents" +instead of the correct plain temporal label. Fixed by having +`getDayLabel` share the exact same `ordinary-feria` gate and +`resolveActiveOctave` call as `resolveOfficeWinner`, so the two can never +disagree about which identity governs a day. ### Benedictus antiphons for temporal (non-sanctoral) days — done diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 3318756..f8734b0 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -14,7 +14,7 @@ import { easterSunday } from './easter'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { addDays, daysBetween, toIsoDate } from './date-math'; import { getTemporalFeastRecord } from './temporal-feasts'; -import { activeOctavesFor, type ActiveOctave } from './octaves'; +import { resolveActiveOctave, type ActiveOctave } from './octaves'; function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); @@ -186,16 +186,26 @@ export function getDayLabel(day: LiturgicalDay): string { return commemoratedSaint ? `${commemoratedSaint.name} — ${anchorName}` : anchorName; } - // An active octave (St. Lawrence's, Christmas's own stack, ...) 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". Picks the oldest-started octave when - // several are stacked (activeOctavesFor's own ordering) as the headline; - // the others still ride along as ordinary octave commemorations, just - // not separately named here. - const octaves = activeOctavesFor(day.date); - if (octaves.length > 0) { - const primary = octaveLabel(octaves[0]!); + // 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; } diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 782b722..eb9d49e 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -239,7 +239,7 @@ export function isSundayOrFeast(day: LiturgicalDay): boolean { export { compareFeastClass, isAtLeast, decideOccurrence } from './commemorations'; export { resolveCollision } from './collision'; export { resolveTemporalId } from './temporal-id'; -export { activeOctavesFor, strictestThreshold } from './octaves'; +export { activeOctavesFor, strictestThreshold, resolveActiveOctave } from './octaves'; export type { ActiveOctave } from './octaves'; export type { LiturgicalDay, diff --git a/src/calendar/octaves.ts b/src/calendar/octaves.ts index 4f8f248..42d9674 100644 --- a/src/calendar/octaves.ts +++ b/src/calendar/octaves.ts @@ -17,7 +17,13 @@ import { compareFeastClass } from './commemorations'; export interface ActiveOctave { id: string; name: string; - /** Rank threshold below which the occurring saint loses the day to this octave. */ + /** This octave's own effective rank *today* — the ordinary `wins` + * threshold on every day except its own closing day, where it's + * `closingDayRank` instead (see calendar/types.ts's OctaveConfig). + * Doubles as both "how strong a rival saint must be to displace this + * octave" and "this octave's own strength when compared against + * another simultaneously-active octave" (resolveActiveOctave) — same + * underlying question, two different comparison partners. */ wins: FeastClass; /** 1 on the feast's own day, counting up from there. */ dayNumber: number; @@ -25,6 +31,7 @@ export interface ActiveOctave { const DEFAULT_DAYS = 8; const DEFAULT_WINS: FeastClass = 'semiduplex'; +const DEFAULT_CLOSING_DAY_RANK: FeastClass = 'duplex'; /** How far back to look for an octave's own start date — must cover the * longest configured `days` a caller might use; 7 covers the standard * 8-day octave (day 1 = the start itself, day 8 = 7 days later). */ @@ -48,7 +55,10 @@ function considerCandidate( return; } seen.add(id); - active.push({ id, name, wins: octave.wins ?? DEFAULT_WINS, dayNumber: offset + 1 }); + 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 }); } /** Every octave (sanctoral or temporal) whose window covers `isoDate`, @@ -89,3 +99,49 @@ export function strictestThreshold(octaves: ActiveOctave[]): FeastClass { octaves[0]?.wins ?? DEFAULT_WINS, ); } + +/** + * Which single active octave actually governs a day's own content/label + * when more than one is active at once (this app's first real case: St. + * Lawrence's, Aug 10-17, and the Assumption's, Aug 15-22, genuinely + * overlap every year) and no rival saint has already displaced all of + * them outright (that's calendar/index.ts's applyOctaves — this only + * runs octave-vs-octave). Per direct instruction: + * + * - Highest effective rank (`wins`, already elevated on either octave's + * own closing day) wins outright. + * - Tied rank: the more recently *started* octave wins (smaller + * `dayNumber` today) — the reasoning given was that day 1 of a newly + * started octave needs to be fully present, which is the whole point + * of it starting; the older octave that's already been running is + * commemorated instead, same as any octave that loses this comparison. + * + * Undefined when no octave is active at all. Every other active octave + * still gets commemorated regardless of which one wins here — this + * function only decides whose *content* (and day-label name) governs, + * not who gets left out of the commemoration list entirely (see + * calendar/index.ts's applyOctaves, unchanged by this). + */ +export function resolveActiveOctave(isoDate: string): ActiveOctave | undefined { + return pickWinningOctave(activeOctavesFor(isoDate)); +} + +/** The comparison itself, factored out from resolveActiveOctave so the + * precedence rule (rank, then recency) is directly unit-testable against + * synthetic ActiveOctave data — no real equal-rank overlap exists yet in + * this app's own calendar to exercise the tie-break against. */ +export function pickWinningOctave(active: ActiveOctave[]): ActiveOctave | undefined { + return active.reduce((best, candidate) => { + if (!best) { + return candidate; + } + const rankCmp = compareFeastClass(candidate.wins, best.wins); + if (rankCmp > 0) { + return candidate; + } + if (rankCmp < 0) { + return best; + } + return candidate.dayNumber < best.dayNumber ? candidate : best; + }, undefined); +} diff --git a/src/calendar/types.ts b/src/calendar/types.ts index 761acc4..e9afccd 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -137,6 +137,15 @@ export interface OctaveConfig { * data/calendar/temporal-feasts/pentecost-sunday.yml), which is * stricter (`duplex`). */ wins?: FeastClass; + /** The octave's own effective rank on its *last* day specifically (the + * "in Octava" closing day) instead of `wins` — live-verified as a real, + * general pattern rather than a per-saint quirk: both St. Lawrence's + * own Aug 17 and the Assumption's own Aug 22 show as Duplex, elevated + * above their otherwise-Semiduplex ordinary in-between days. Default + * 'duplex'. Feeds both the rival-saint threshold (calendar/octaves.ts's + * `strictestThreshold`) and octave-vs-octave precedence + * (`resolveActiveOctave`) on that one day. */ + closingDayRank?: FeastClass; } export type DayWinner = diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index ff734d5..3311e93 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types' import type { ProperText } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers'; import { getSaintRecord } from '../calendar/feasts'; -import { activeOctavesFor, isAtLeast } from '../calendar'; +import { resolveActiveOctave, isAtLeast } from '../calendar'; import { splitAntiphon } from './antiphon'; function toResolvedText(proper: ProperText): ResolvedText { @@ -60,8 +60,16 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner { if (day.temporalCategory !== 'ordinary-feria') { return day.winner; } - for (const octave of activeOctavesFor(day.date)) { - const saint = getSaintRecord(octave.id); + // When more than one octave is active at once (St. Lawrence's and the + // Assumption's genuinely overlap every Aug 16-17), resolveActiveOctave + // picks the one that actually governs the day — see its own doc + // comment for the precedence rule. Falls through to the plain temporal + // default if that octave turns out to be a temporal-only one with no + // saint record (e.g. Christmas's or Pentecost's own octave id) — no + // sanctoral content to synthesize from those. + const activeOctave = resolveActiveOctave(day.date); + if (activeOctave) { + const saint = getSaintRecord(activeOctave.id); if (saint) { return { kind: 'sanctoral', id: saint.id, name: saint.name, rank: saint.rank }; } diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 29def87..2649b55 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -62,6 +62,26 @@ describe('getDayLabel — active octave', () => { it("a season's own named anchor day outranks an active octave, even though Trinity Sunday is technically also day 8 of Pentecost's own octave", () => { expect(getDayLabel(resolveDay('2026-05-31'))).toBe('Trinity Sunday'); }); + + it("a temporal day with real standing of its own (not ordinary-feria) never shows an octave name at all, even with one active -- live-verified counterexample: the Christmas Octave's own stack (Dec 30) titles itself off the Sunday, never any of the four octaves layered on top of it", () => { + const day = resolveDay('2033-12-30'); + expect(day.temporalCategory).toBe('privileged-feria-minor'); + expect(getDayLabel(day)).not.toContain('Octave'); + expect(getDayLabel(day)).toBe('Friday in Christmastide'); + }); + + it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall", () => { + expect(getDayLabel(resolveDay('2026-08-17'))).toBe('8th Day within the Octave of St. Lawrence, Martyr'); + expect(getDayLabel(resolveDay('2026-08-18'))).toBe('4th Day within the Octave of The Assumption of the Blessed Virgin Mary'); + }); + + it('a real Sunday with standing of its own wins over both octaves entirely, showing the plain ordinal Sunday label -- not an octave name at all', () => { + // 2026-08-16 is a Sunday genuinely within both St. Lawrence's and the + // Assumption's overlapping octave windows. + const day = resolveDay('2026-08-16'); + expect(day.weekday).toBe('sunday'); + expect(getDayLabel(day)).not.toContain('Octave'); + }); }); describe('getDayLabel — feast name combination', () => { diff --git a/tests/calendar/octaves.test.ts b/tests/calendar/octaves.test.ts index 31e1135..83c6dc6 100644 --- a/tests/calendar/octaves.test.ts +++ b/tests/calendar/octaves.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { activeOctavesFor, strictestThreshold } from '../../src/calendar/octaves'; +import { activeOctavesFor, strictestThreshold, resolveActiveOctave, pickWinningOctave } from '../../src/calendar/octaves'; +import type { ActiveOctave } from '../../src/calendar/octaves'; describe('activeOctavesFor', () => { it('finds nothing outside any octave window', () => { @@ -11,10 +12,12 @@ describe('activeOctavesFor', () => { expect(day1).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1 }]); // Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) -- - // the two run concurrently this time of year. + // the two run concurrently this time of year. Day 8 is Lawrence's own + // closing day ("in Octava"), elevated to duplex -- live-verified real + // pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig). const day8 = activeOctavesFor('2026-08-17'); expect(day8).toEqual([ - { id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 8 }, + { id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8 }, { id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary', wins: 'semiduplex', dayNumber: 3 }, ]); @@ -40,3 +43,39 @@ describe('activeOctavesFor', () => { expect(octaves.map((o) => o.dayNumber)).toEqual([5, 4, 3, 2]); }); }); + +describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave precedence', () => { + it('returns undefined when no octave is active, and the sole octave when only one is', () => { + expect(resolveActiveOctave('2026-07-15')).toBeUndefined(); + expect(resolveActiveOctave('2026-08-10')?.id).toBe('st-lawrence'); + }); + + it("St. Lawrence's own elevated closing day (duplex) outranks the Assumption's ordinary day 3 (semiduplex) on their one real overlap, Aug 17", () => { + const winner = resolveActiveOctave('2026-08-17'); + expect(winner?.id).toBe('st-lawrence'); + expect(winner?.wins).toBe('duplex'); + }); + + it("gives way to the Assumption once Lawrence's own octave has ended (Aug 18-22)", () => { + expect(resolveActiveOctave('2026-08-18')?.id).toBe('assumption'); + }); + + // pickWinningOctave directly, since no real equal-rank overlap exists yet + // in this app's own calendar to exercise the tie-break against live data. + const higher = (dayNumber: number): ActiveOctave => ({ id: 'higher', name: 'Higher', wins: 'duplex', dayNumber }); + const lower = (dayNumber: number): ActiveOctave => ({ id: 'lower', name: 'Lower', wins: 'semiduplex', dayNumber }); + const equalA = (dayNumber: number): ActiveOctave => ({ id: 'equal-a', name: 'Equal A', wins: 'duplex', dayNumber }); + const equalB = (dayNumber: number): ActiveOctave => ({ id: 'equal-b', name: 'Equal B', wins: 'duplex', dayNumber }); + + it('picks the higher-ranked octave regardless of order or start date', () => { + expect(pickWinningOctave([lower(2), higher(6)])?.id).toBe('higher'); + expect(pickWinningOctave([higher(6), lower(2)])?.id).toBe('higher'); + }); + + it('on a tied rank, picks the more recently started octave (smaller dayNumber) -- per direct instruction: day 1 of a newly started octave needs to be fully present, the whole point of it starting; the older, tied-rank octave loses and is commemorated instead', () => { + // equalB started later (day 2, vs equalA's day 5 -- fewer days have + // passed since it began) and should win the tie. + expect(pickWinningOctave([equalA(5), equalB(2)])?.id).toBe('equal-b'); + expect(pickWinningOctave([equalB(2), equalA(5)])?.id).toBe('equal-b'); + }); +});