diff --git a/TODO.md b/TODO.md index 753d0e7..845a798 100644 --- a/TODO.md +++ b/TODO.md @@ -6022,3 +6022,36 @@ real `Lev 1` responsory text, `palm-sunday`/Sunday resolves real `Num 25-27` tex audit re-run, clean. Remaining 10, the true final gap: `rom` plus 9 books with no citation anywhere in Tempora or Sancti (`bar`, Josue, Judices, Ruth, 1-2 Chronicles, Ezra, Nehemiah, Deuteronomy). + +### Advent reading-plan carryover into Christmastide (2026-09-05) + +User-flagged wrinkle in `data/hours/bible-plan/*.yml`'s own Isaiah plan: it's authored as a full +4-week Advent (`advent-1-sunday` through `advent-4-saturday`, 28 rows), but real Advent is never +that long. Advent's last real day is always Dec 23 (Dec 24 is the Vigil of Christmas's own +temporal id, `calendar/temporal-id.ts`), while Advent 1 Sunday itself — the Sunday nearest Nov 30 +— falls anywhere from Nov 27 to Dec 3. Even the *longest* possible Advent (Nov 27, 27 real days) +is one short of the full 28-row plan; the *shortest* (Dec 3, 21 real days) is a whole week short. +Every single year, at least the plan's last row never lands on its own natural date — not just +"most years," as the request first put it. + +Fixed in `propers/bible-plan.ts`'s new `adventCarryoverReadings`: whatever doesn't land on its own +natural date now carries forward one reading per day starting Christmas Day itself, pooled +alongside whatever Christmastide's own calendar-dated rows already have for that day (never +replacing them) — same "every contributing source pools, none override" design already used +throughout this file and `hours/matins.ts`'s own reading-pool assembly. Computed live from +`calendar/temporal.ts`'s `adventStart(year)` for each date's own year (the one place in this file +that needs real calendar-year awareness, since every other lookup here is year-independent); no +new content authoring needed, since the Isaiah rows were already there, just never reachable on +their own natural date in a short year. + +User's own framing accepted as the trade-off: "for advent only, we need to show all of those +chapters" — i.e. every planned reading should surface somewhere, even if that means spilling into +Christmastide, rather than silently dropping the tail end of a short year's Advent plan. + +Live-verified across the full spread of possible Advent-1-Sunday dates (`adventStart` for +2020-2035 spans exactly the Nov 27 - Dec 3 range): 2028 (Advent 1 Sunday = Dec 3, the latest/ +shortest case) carries all 7 of week 4's Isaiah readings forward across Dec 25-31 in order; 2027/ +2032 (Advent 1 Sunday = Nov 28, near-earliest/longest) carry only the last 2. Added 4 permanent +tests to `tests/propers/bible-plan.test.ts` covering both extremes, the pooling-not-replacing +behavior, and that nothing carries over before Christmas Day itself. `npm test` (2029 passed, ++4 new) and `tsc --noEmit` both pass. diff --git a/src/propers/bible-plan.ts b/src/propers/bible-plan.ts index fc4e031..6b4c37b 100644 --- a/src/propers/bible-plan.ts +++ b/src/propers/bible-plan.ts @@ -22,6 +22,9 @@ import { resolvePassages, type ScriptureCitation } from './octave-readings'; import { getResponsoryForBook } from './matins-responsories'; import { getBookIncipit } from './bible-book-incipits'; import { canonicalBook } from '../scripture'; +import { adventStart } from '../calendar/temporal'; +import { addDays, daysBetween } from '../calendar/date-math'; +import type { Weekday } from '../calendar/types'; const GOSPEL_BOOKS = new Set(['matt', 'mark', 'luke', 'john']); @@ -154,10 +157,54 @@ for (const mod of Object.values(modules)) { } } +const WEEKDAYS: Weekday[] = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + +/** Advent's own reading plan (`advent-1-sunday` through `advent-4-saturday`, + * 28 rows, one per day of a full 4-week Advent) is longer than Advent + * itself ever actually runs: Advent's last real day is Dec 23 (Dec 24 is + * the Vigil of Christmas's own temporal id, calendar/temporal-id.ts) but + * Advent 1 Sunday falls anywhere from Nov 27 to Dec 3 (the Sunday nearest + * Nov 30) — even in the longest possible Advent (Advent 1 Sunday = Nov 27, + * 27 real days before Dec 24) the 28th planned reading never lands on its + * own natural date, and in the shortest (Advent 1 Sunday = Dec 3, 21 real + * days) the entire last week's worth (7 readings) never does. Rather than + * silently drop that content, whatever didn't land on its own natural date + * carries forward one reading per day starting Christmas Day itself, + * pooled alongside whatever Christmastide's own calendar-dated rows + * already have for that day (never replacing them) — same "every + * contributing source pools, none override" design as everywhere else in + * this file and in hours/matins.ts's own reading-pool assembly. User + * direction, 2026-09-05: "for advent only, we need to show all of those + * chapters," accepting the spillover into Christmastide as the trade-off. + * + * Real day-by-day for `date`'s own year: the pool has no calendar-year + * awareness anywhere else (temporal ids are already year-independent), so + * this is the one place in this file that computes real dates. */ +function adventCarryoverReadings(date: string): BiblePlanReading[] { + const year = Number(date.slice(0, 4)); + const christmasDay = `${year}-12-25`; + const daysAfterChristmas = daysBetween(christmasDay, date); + if (daysAfterChristmas < 0) return []; + + const vigilOfChristmas = `${year}-12-24`; + const start = adventStart(year); + const skippedKeys: string[] = []; + for (let i = 0; i < 28; i++) { + if (addDays(start, i) >= vigilOfChristmas) { + const week = Math.floor(i / 7) + 1; + skippedKeys.push(`advent-${week}-${WEEKDAYS[i % 7]}`); + } + } + const key = skippedKeys[daysAfterChristmas]; + return key ? (readingsByTemporalKey.get(key) ?? []) : []; +} + /** Every reading this plan contributes to Matins' pooled reading list for - * `date` — both the (temporalId, weekday) row and the fixed-calendar-date - * row (if any), pooled together rather than one overriding the other, since - * both can genuinely apply to the same date (see this file's header). + * `date` — the (temporalId, weekday) row, the fixed-calendar-date row (if + * any), and (Christmas Day onward) any Advent reading bumped past its own + * natural date by a short Advent that year (see `adventCarryoverReadings`) + * — all pooled together rather than one overriding another, since more + * than one can genuinely apply to the same date (see this file's header). * Empty array, not undefined, when nothing's authored for this day yet — * every caller already treats "no readings" as a valid, renderable state * (see hours/matins.ts), so there's no separate "missing entirely" signal @@ -165,5 +212,6 @@ for (const mod of Object.values(modules)) { export function getBiblePlanReadings(temporalId: string, weekday: string, date: string): BiblePlanReading[] { const byTemporal = readingsByTemporalKey.get(`${temporalId}-${weekday}`) ?? []; const byCalendarDate = readingsByCalendarDate.get(date.slice(5)) ?? []; - return [...byTemporal, ...byCalendarDate]; + const carryover = adventCarryoverReadings(date); + return [...byTemporal, ...byCalendarDate, ...carryover]; } diff --git a/tests/propers/bible-plan.test.ts b/tests/propers/bible-plan.test.ts index eff917f..7ac5397 100644 --- a/tests/propers/bible-plan.test.ts +++ b/tests/propers/bible-plan.test.ts @@ -38,3 +38,40 @@ describe('getBiblePlanReadings label uses the same book-alias normalization as v expect(readings[0]?.label?.la).toBe('Léctio libri Judith'); }); }); + +// The Advent reading plan has 28 rows (advent-1-sunday through +// advent-4-saturday, a full 4-week Advent), but real Advent is never that +// long -- its last real day is always Dec 23 (Dec 24 is the Vigil of +// Christmas's own temporal id), while Advent 1 Sunday itself lands anywhere +// from Nov 27 to Dec 3. Even the longest possible Advent (Nov 27) is 27 +// real days, one short of the full plan; the shortest (Dec 3) is 21, a +// whole week short. Whatever doesn't land on its own natural date carries +// forward one reading per day starting Christmas Day, pooled alongside +// whatever Christmastide's own calendar-dated rows already have. User +// direction, 2026-09-05: "for advent only, we need to show all of those +// chapters," accepting the spillover into Christmastide as the trade-off. +describe('Advent reading-plan carryover into Christmastide', () => { + it('a short Advent (2028: Advent 1 Sunday = Dec 3, latest possible) carries all 7 of week 4\'s readings forward, one per day', () => { + const isaiahCitations = [25, 26, 27, 28, 29, 30, 31].map( + (day) => getBiblePlanReadings('christmas-octave-sunday', 'monday', `2028-12-${day}`).find((r) => r.citation.la?.startsWith('Isa'))?.citation.la, + ); + expect(isaiahCitations).toEqual(['Isa 46-48', 'Isa 49-51', 'Isa 52-54', 'Isa 55-57', 'Isa 58-60', 'Isa 61-63', 'Isa 64-66']); + }); + + it('a long Advent (2027: Advent 1 Sunday = Nov 28, near-earliest) carries only the last 2 readings forward', () => { + const isaiahCitations = [25, 26, 27, 28].map( + (day) => getBiblePlanReadings('christmas-octave-sunday', 'monday', `2027-12-${day}`).find((r) => r.citation.la?.startsWith('Isa'))?.citation.la, + ); + expect(isaiahCitations).toEqual(['Isa 61-63', 'Isa 64-66', undefined, undefined]); + }); + + it('carried-over readings pool alongside Christmastide\'s own calendar-dated rows, never replacing them', () => { + const readings = getBiblePlanReadings('christmas-octave-sunday', 'friday', '2028-12-25'); + expect(readings.map((r) => r.citation.la)).toEqual(['Cant 1', 'Isa 46-48']); + }); + + it('does not carry anything over before Christmas Day', () => { + const readings = getBiblePlanReadings('vigil-of-christmas', 'thursday', '2028-12-24'); + expect(readings.find((r) => r.citation.la?.startsWith('Isa'))).toBeUndefined(); + }); +});